diff --git a/.circleci/config.yml b/.circleci/config.yml index b66ac53c71..4593de71fc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,7 +5,7 @@ version: 2.1 # Orbs are reusable packages of CircleCI configuration that you may share across projects, enabling you to create encapsulated, parameterized commands, jobs, and executors that can be used across multiple projects. orbs: android: circleci/android@2.3.0 - codecov: codecov/codecov@3.2.4 + codecov: codecov/codecov@3.3.0 jobs: # Below is the definition of your job to build and test your app, you can rename and customize it as you want. @@ -14,7 +14,7 @@ jobs: executor: name: android/android-machine resource-class: large - tag: 2023.07.1 + tag: 2023.11.1 steps: - checkout @@ -34,11 +34,19 @@ jobs: - android/run-tests: test-command: ./gradlew --stacktrace jacocoAllDebugReport - # And finally run the release build - # - run: - # name: Assemble release build - # command: | - # ./gradlew assembleRelease + - run: + name: Save test results + command: | + mkdir -p ~/test-results/junit/ + find . -type f -regex ".*/build/outputs/androidTest-results/.*xml" -exec cp {} ~/test-results/junit/ \; + when: always + + - store_test_results: + path: ~/test-results + + - store_artifacts: + path: ~/test-results/junit + - codecov/upload: file: './build/reports/jacoco/jacocoAllDebugReport/jacocoAllDebugReport.xml' diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9f6683654a..8db9f1ace1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -153,6 +153,7 @@ android { //Deleting it causes a binding error buildFeatures { dataBinding = true + buildConfig = true } } diff --git a/app/src/main/kotlin/app/aaps/MainActivity.kt b/app/src/main/kotlin/app/aaps/MainActivity.kt index b5296ab7c4..bf01cacc20 100644 --- a/app/src/main/kotlin/app/aaps/MainActivity.kt +++ b/app/src/main/kotlin/app/aaps/MainActivity.kt @@ -276,7 +276,7 @@ class MainActivity : DaggerAppCompatActivityWithResult() { }) // Setup views on 2nd and next activity start // On 1st start app is still initializing, start() is delayed and run from EventAppInitialized - if (config.appInitialized) start() + if (config.appInitialized) setupViews() } private fun start() { diff --git a/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt b/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt index 6ea627c117..36c1cd22c0 100644 --- a/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt +++ b/app/src/main/kotlin/app/aaps/di/PluginsListModule.kt @@ -47,7 +47,7 @@ import app.aaps.plugins.source.PoctechPlugin import app.aaps.plugins.source.RandomBgPlugin import app.aaps.plugins.source.TomatoPlugin import app.aaps.plugins.source.XdripSourcePlugin -import app.aaps.plugins.sync.dataBroadcaster.DataBroadcastPlugin +import app.aaps.plugins.sync.tizen.TizenPlugin import app.aaps.plugins.sync.nsclient.NSClientPlugin import app.aaps.plugins.sync.nsclientV3.NSClientV3Plugin import app.aaps.plugins.sync.openhumans.OpenHumansUploaderPlugin @@ -350,7 +350,7 @@ abstract class PluginsListModule { @AllConfigs @IntoMap @IntKey(368) - abstract fun bindDataBroadcastPlugin(plugin: DataBroadcastPlugin): PluginBase + abstract fun bindDataBroadcastPlugin(plugin: TizenPlugin): PluginBase @Binds @AllConfigs diff --git a/build.gradle.kts b/build.gradle.kts index ecca9a158d..19ec3cd8ae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ buildscript { mavenCentral() } dependencies { - classpath("com.android.tools.build:gradle:8.1.3") + classpath("com.android.tools.build:gradle:8.2.0") classpath("com.google.gms:google-services:4.4.0") classpath("com.google.firebase:firebase-crashlytics-gradle:2.9.9") @@ -22,7 +22,7 @@ buildscript { } plugins { - id("org.jlleitschuh.gradle.ktlint") version "11.6.1" + id("org.jlleitschuh.gradle.ktlint") version "12.0.3" } allprojects { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 7cd2f0a647..223da698c9 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,7 +1,7 @@ object KtsBuildVersions { - const val gradle = "8.1.3" - const val kotlin = "1.9.0" + const val gradle = "8.2.0" + const val kotlin = "1.9.10" } plugins { diff --git a/buildSrc/src/main/kotlin/Libs.kt b/buildSrc/src/main/kotlin/Libs.kt index 719175d1a0..ecc70d9727 100644 --- a/buildSrc/src/main/kotlin/Libs.kt +++ b/buildSrc/src/main/kotlin/Libs.kt @@ -152,8 +152,11 @@ object Libs { } object Mockito { + private const val mockitoVersion = "5.6.0" - const val jupiter = "org.mockito:mockito-junit-jupiter:5.6.0" + const val android = "org.mockito:mockito-android:$mockitoVersion" + const val core = "org.mockito:mockito-core:$mockitoVersion" + const val jupiter = "org.mockito:mockito-junit-jupiter:$mockitoVersion" const val kotlin = "org.mockito.kotlin:mockito-kotlin:5.1.0" } @@ -197,19 +200,5 @@ object Libs { const val commonCodecs = "commons-codec:commons-codec:1.16.0" const val kulid = "com.github.guepardoapps:kulid:2.0.0.0" const val xstream = "com.thoughtworks.xstream:xstream:1.4.20" - - const val ormLite = "4.46" - - const val junit = "4.13.2" - const val mockito = "5.6.0" - const val dexmaker = "1.2" - const val byteBuddy = "1.12.8" - - const val androidx_junit = "1.1.5" - const val androidx_rules = "1.5.0" - - const val kotlinx_datetime = "0.4.1" - const val kotlinx_serialization = "1.6.0" - - const val caverock_androidsvg = "1.4" + const val connectiqSdk = "com.garmin.connectiq:ciq-companion-app-sdk:2.0.3@aar" } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 80f8350075..17b2346624 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -2,7 +2,7 @@ import org.gradle.api.JavaVersion object Versions { - const val appVersion = "3.2.0.2" + const val appVersion = "3.2.0.3" const val versionCode = 1500 const val ndkVersion = "21.1.6352462" diff --git a/buildSrc/src/main/kotlin/test-module-dependencies.gradle.kts b/buildSrc/src/main/kotlin/test-module-dependencies.gradle.kts index 22fc2175b7..bd520b0863 100644 --- a/buildSrc/src/main/kotlin/test-module-dependencies.gradle.kts +++ b/buildSrc/src/main/kotlin/test-module-dependencies.gradle.kts @@ -22,6 +22,9 @@ dependencies { androidTestImplementation(Libs.AndroidX.Test.rules) androidTestImplementation(Libs.Google.truth) androidTestImplementation(Libs.AndroidX.Test.uiAutomator) + androidTestImplementation(Libs.Mockito.core) + androidTestImplementation(Libs.Mockito.android) + androidTestImplementation(Libs.Mockito.kotlin) } tasks.withType { diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpSync.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpSync.kt index 5a887617e8..ad30c2b047 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpSync.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpSync.kt @@ -1,5 +1,6 @@ package app.aaps.core.interfaces.pump +import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.profile.Profile import app.aaps.core.interfaces.pump.defs.PumpType import app.aaps.core.interfaces.utils.DateUtil @@ -257,6 +258,26 @@ interface PumpSync { **/ fun insertTherapyEventIfNewWithTimestamp(timestamp: Long, type: DetailedBolusInfo.EventType, note: String? = null, pumpId: Long? = null, pumpType: PumpType, pumpSerial: String): Boolean + /** + * Synchronization of FINGER_STICK_BG_VALUE events + * + * Assuming there will be no clash on timestamp from different pumps + * only timestamp and type is compared + * + * If db record doesn't exist, new record is created. + * If exists, data is ignored + * + * @param timestamp timestamp of event from pump history + * @param glucose glucose value + * @param glucoseUnit glucose unit + * @param note note + * @param pumpId pump id from history if available + * @param pumpType pump type like PumpType.ACCU_CHEK_COMBO + * @param pumpSerial pump serial number + * @return true if new record is created + **/ + fun insertFingerBgIfNewWithTimestamp(timestamp: Long, glucose: Double, glucoseUnit: GlucoseUnit, note: String? = null, pumpId: Long? = null, pumpType: PumpType, pumpSerial: String): Boolean + /** * Create an announcement * diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/receivers/Intents.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/receivers/Intents.kt index fbcfca67e6..c4d74505a0 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/receivers/Intents.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/receivers/Intents.kt @@ -25,6 +25,7 @@ interface Intents { const val EXTRA_BG_SLOPE = "com.eveningoutpost.dexdrip.Extras.BgSlope" const val EXTRA_BG_SLOPE_NAME = "com.eveningoutpost.dexdrip.Extras.BgSlopeName" const val EXTRA_SENSOR_BATTERY = "com.eveningoutpost.dexdrip.Extras.SensorBattery" + const val EXTRA_SENSOR_STARTED_AT = "com.eveningoutpost.dexdrip.Extras.SensorStartedAt" const val EXTRA_TIMESTAMP = "com.eveningoutpost.dexdrip.Extras.Time" const val EXTRA_RAW = "com.eveningoutpost.dexdrip.Extras.Raw" const val XDRIP_DATA_SOURCE_DESCRIPTION = "com.eveningoutpost.dexdrip.Extras.SourceDesc" diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/MidnightUtils.kt b/core/utils/src/main/kotlin/app/aaps/core/utils/MidnightUtils.kt index 3d36fa494a..49b808f125 100644 --- a/core/utils/src/main/kotlin/app/aaps/core/utils/MidnightUtils.kt +++ b/core/utils/src/main/kotlin/app/aaps/core/utils/MidnightUtils.kt @@ -1,22 +1,56 @@ package app.aaps.core.utils -import org.joda.time.DateTime +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime -object MidnightUtils { - /* +/** * Midnight time conversion */ +object MidnightUtils { + + /** + * Actual passed seconds from midnight ignoring DST change + * (thus always having 24 hours in a day, not 23 or 25 in days where DST changes) + * + * @return seconds + */ fun secondsFromMidnight(): Int { - val passed = DateTime().millisOfDay.toLong() - return (passed / 1000).toInt() + val nowZoned = ZonedDateTime.now() + val localTime = nowZoned.toLocalTime() + val midnight = nowZoned.toLocalDate().atStartOfDay(nowZoned.zone).toLocalTime() + val duration = Duration.between(midnight, localTime) + return duration.seconds.toInt() } - fun secondsFromMidnight(date: Long): Int { - val passed = DateTime(date).millisOfDay.toLong() - return (passed / 1000).toInt() + /** + * Passed seconds from midnight for specified time ignoring DST change + * (thus always having 24 hours in a day, not 23 or 25 in days where DST changes) + * + * @param timestamp time + * @return seconds + */ + fun secondsFromMidnight(timestamp: Long): Int { + val timeZoned = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()) + val localTime = timeZoned.toLocalTime() + val midnight = timeZoned.toLocalDate().atStartOfDay(timeZoned.zone).toLocalTime() + val duration: Duration = Duration.between(midnight, localTime) + return duration.seconds.toInt() } - fun milliSecFromMidnight(date: Long): Long { - return DateTime(date).millisOfDay.toLong() + /** + * Passed milliseconds from midnight for specified time ignoring DST change + * (thus always having 24 hours in a day, not 23 or 25 in days where DST changes) + * + * @param timestamp time + * @return milliseconds + */ + fun milliSecFromMidnight(timestamp: Long): Long { + val timeZoned = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()) + val localTime = timeZoned.toLocalTime() + val midnight = timeZoned.toLocalDate().atStartOfDay(timeZoned.zone).toLocalTime() + val duration = Duration.between(midnight, localTime) + return duration.toMillis() } } \ No newline at end of file diff --git a/core/utils/src/test/kotlin/app/aaps/core/utils/MidnightUtilsTest.kt b/core/utils/src/test/kotlin/app/aaps/core/utils/MidnightUtilsTest.kt new file mode 100644 index 0000000000..46d4b46fae --- /dev/null +++ b/core/utils/src/test/kotlin/app/aaps/core/utils/MidnightUtilsTest.kt @@ -0,0 +1,67 @@ +package app.aaps.core.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.util.TimeZone + +class MidnightUtilsTest { + + @BeforeEach fun setUp() { + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Amsterdam")) + } + + @Test + fun secondsFromMidnight() { + val time = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + assertThat(MidnightUtils.secondsFromMidnight(time)).isIn(0..24 * 3600) + } + + @Test + fun testSecondsFromMidnight() { + val midnight = LocalDate.now().atTime(LocalTime.MIDNIGHT).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + assertThat(MidnightUtils.secondsFromMidnight(midnight)).isEqualTo(0) + val oneHourAfter = LocalDateTime.ofInstant(Instant.ofEpochMilli(midnight), ZoneId.systemDefault()).atZone(ZoneId.systemDefault()).plusHours(1).toInstant().toEpochMilli() + assertThat(MidnightUtils.secondsFromMidnight(oneHourAfter)).isEqualTo(3600) + } + + @Test + fun milliSecFromMidnight() { + val midnight = LocalDate.now().atTime(LocalTime.MIDNIGHT).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + assertThat(MidnightUtils.secondsFromMidnight(midnight)).isEqualTo(0) + val oneHourAfter = LocalDateTime.ofInstant(Instant.ofEpochMilli(midnight), ZoneId.systemDefault()).atZone(ZoneId.systemDefault()).plusHours(1).toInstant().toEpochMilli() + assertThat(MidnightUtils.milliSecFromMidnight(oneHourAfter)).isEqualTo(3600 * 1000) + } + + @Test fun testDateTimeToDuration() { + val dateTime = ZonedDateTime.of(1991, 8, 13, 23, 5, 1, 0, ZoneId.of("Europe/Amsterdam")).toInstant().toEpochMilli() + assertThat(MidnightUtils.secondsFromMidnight(dateTime)).isEqualTo(83101) + assertThat(MidnightUtils.milliSecFromMidnight(dateTime)).isEqualTo(83101 * 1000L) + } + + @Test fun testDateTimeToDurationAtDstChange() { + val dateTime = ZonedDateTime.of(2020, 10, 25, 23, 5, 1, 0, ZoneId.of("Europe/Amsterdam")).toInstant().toEpochMilli() + assertThat(MidnightUtils.secondsFromMidnight(dateTime)).isEqualTo(83101) + assertThat(MidnightUtils.milliSecFromMidnight(dateTime)).isEqualTo(83101 * 1000L) + } + + @Test fun testDateTimeToDurationAtDstReverseChange() { + val dateTime = ZonedDateTime.of(2020, 3, 29, 23, 5, 1, 0, ZoneId.of("Europe/Amsterdam")).toInstant().toEpochMilli() + assertThat(MidnightUtils.secondsFromMidnight(dateTime)).isEqualTo(83101) + assertThat(MidnightUtils.milliSecFromMidnight(dateTime)).isEqualTo(83101 * 1000L) + } + + @Test fun testDateTimeInOtherZone() { + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")) + assertThat(ZoneId.systemDefault().id).isEqualTo("America/Los_Angeles") + val dateTime = ZonedDateTime.of(2020, 3, 29, 23, 5, 1, 0, ZoneId.of("America/Los_Angeles")).toInstant().toEpochMilli() + assertThat(MidnightUtils.secondsFromMidnight(dateTime)).isEqualTo(83101) + assertThat(MidnightUtils.milliSecFromMidnight(dateTime)).isEqualTo(83101 * 1000L) + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 743829c702..8e28702445 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,7 +17,7 @@ # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects org.gradle.parallel=true org.gradle.warning.mode=all -org.gradle.jvmargs=-Xmx3g -XX:+UseParallelGC +org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC android.enableJetifier=false android.useAndroidX=true @@ -32,5 +32,4 @@ android.nonTransitiveRClass=true # null: KtCallExpression # https://youtrack.jetbrains.com/issue/KT-58027 kapt.use.jvm.ir=false -android.defaults.buildfeatures.buildconfig=true android.nonFinalResIds=true diff --git a/implementation/src/main/kotlin/app/aaps/implementation/pump/PumpSyncImplementation.kt b/implementation/src/main/kotlin/app/aaps/implementation/pump/PumpSyncImplementation.kt index 1db60cde05..bac571d297 100644 --- a/implementation/src/main/kotlin/app/aaps/implementation/pump/PumpSyncImplementation.kt +++ b/implementation/src/main/kotlin/app/aaps/implementation/pump/PumpSyncImplementation.kt @@ -1,5 +1,6 @@ package app.aaps.implementation.pump +import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag import app.aaps.core.interfaces.logging.UserEntryLogger @@ -16,6 +17,7 @@ import app.aaps.core.interfaces.sharedPreferences.SP import app.aaps.core.interfaces.utils.DateUtil import app.aaps.core.interfaces.utils.T import app.aaps.core.main.events.EventNewNotification +import app.aaps.core.main.extensions.fromConstant import app.aaps.core.main.pump.fromDbPumpType import app.aaps.core.main.pump.toDbPumpType import app.aaps.core.main.pump.toDbSource @@ -291,6 +293,42 @@ class PumpSyncImplementation @Inject constructor( } } + override fun insertFingerBgIfNewWithTimestamp(timestamp: Long, glucose: Double, glucoseUnit: GlucoseUnit, note: String?, pumpId: Long?, pumpType: PumpType, pumpSerial: String): Boolean { + if (!confirmActivePump(timestamp, pumpType, pumpSerial)) return false + var type = TherapyEvent.Type.FINGER_STICK_BG_VALUE + val therapyEvent = TherapyEvent( + timestamp = timestamp, + type = type, + duration = 0, + note = note, + enteredBy = "AndroidAPS", + glucose = glucose, + glucoseType = TherapyEvent.MeterType.FINGER, + glucoseUnit = TherapyEvent.GlucoseUnit.fromConstant(glucoseUnit), + interfaceIDs_backing = InterfaceIDs( + pumpId = pumpId, + pumpType = pumpType.toDbPumpType(), + pumpSerial = pumpSerial + ) + ) + uel.log( + action = UserEntry.Action.CAREPORTAL, + source = pumpType.source.toDbSource(), + note = note, + timestamp = timestamp, + ValueWithUnit.Timestamp(timestamp), ValueWithUnit.TherapyEventType(type) + ) + repository.runTransactionForResult(InsertIfNewByTimestampTherapyEventTransaction(therapyEvent)) + .doOnError { + aapsLogger.error(LTag.DATABASE, "Error while saving TherapyEvent", it) + } + .blockingGet() + .also { result -> + result.inserted.forEach { aapsLogger.debug(LTag.DATABASE, "Inserted TherapyEvent $it") } + return result.inserted.size > 0 + } + } + override fun insertAnnouncement(error: String, pumpId: Long?, pumpType: PumpType, pumpSerial: String) { if (!confirmActivePump(dateUtil.now(), pumpType, pumpSerial)) return disposable += repository.runTransaction(InsertTherapyEventAnnouncementTransaction(error, pumpId, pumpType.toDbPumpType(), pumpSerial)) diff --git a/implementation/src/main/kotlin/app/aaps/implementation/queue/CommandQueueImplementation.kt b/implementation/src/main/kotlin/app/aaps/implementation/queue/CommandQueueImplementation.kt index 9f41eacf24..1bf97596c8 100644 --- a/implementation/src/main/kotlin/app/aaps/implementation/queue/CommandQueueImplementation.kt +++ b/implementation/src/main/kotlin/app/aaps/implementation/queue/CommandQueueImplementation.kt @@ -119,13 +119,13 @@ class CommandQueueImplementation @Inject constructor( return@subscribe } aapsLogger.debug(LTag.PROFILE, "onEventProfileSwitchChanged") - val effective = repository.getEffectiveProfileSwitchActiveAt(dateUtil.now()).blockingGet() profileFunction.getRequestedProfile()?.let { setProfile(ProfileSealed.PS(it), it.interfaceIDs.nightscoutId != null, object : Callback() { override fun run() { if (!result.success) { uiInteraction.runAlarm(result.comment, rh.gs(app.aaps.core.ui.R.string.failed_update_basal_profile), app.aaps.core.ui.R.raw.boluserror) - } else if (result.enacted || effective is ValueWrapper.Existing && effective.value.originalEnd < dateUtil.now() && effective.value.originalDuration != 0L) { + } else /* if (result.enacted || effective is ValueWrapper.Existing && effective.value.originalEnd < dateUtil.now() && effective.value.originalDuration != 0L) */ { + // Pump may return enacted == false if basal profile is the same, but IC/ISF can be different val nonCustomized = ProfileSealed.PS(it).convertToNonCustomizedProfile(dateUtil) EffectiveProfileSwitch( timestamp = dateUtil.now(), diff --git a/insight/src/main/res/values-ru-rRU/alert_titles.xml b/insight/src/main/res/values-ru-rRU/alert_titles.xml index 4cb9822a47..9ae65db2bd 100644 --- a/insight/src/main/res/values-ru-rRU/alert_titles.xml +++ b/insight/src/main/res/values-ru-rRU/alert_titles.xml @@ -1,7 +1,7 @@ - Дать болюс - Недоставленный болюс + Ввести болюс + Несостоявшийся болюс Будильник Замените инфузионный набор TBR завершен diff --git a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopPlugin.kt b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopPlugin.kt index fa6cd4cdd5..a27f79c4b6 100644 --- a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopPlugin.kt +++ b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/loop/LoopPlugin.kt @@ -79,6 +79,7 @@ import dagger.android.HasAndroidInjector import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import org.json.JSONObject +import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton import kotlin.math.abs @@ -135,6 +136,8 @@ class LoopPlugin @Inject constructor( disposable += rxBus .toObservable(EventTempTargetChange::class.java) .observeOn(aapsSchedulers.io) + // Skip db change of ending previous TT + .debounce(10L, TimeUnit.SECONDS) .subscribe({ invoke("EventTempTargetChange", true) }, fabricPrivacy::logException) } diff --git a/plugins/aps/src/main/res/values-es-rES/strings.xml b/plugins/aps/src/main/res/values-es-rES/strings.xml index b9b3566744..f46d421cb7 100644 --- a/plugins/aps/src/main/res/values-es-rES/strings.xml +++ b/plugins/aps/src/main/res/values-es-rES/strings.xml @@ -1,7 +1,7 @@ Habilitar la relación de sensibilidad basada en TDD para modificar las basales y el objetivo de glucosa - Utiliza las últimas 24h TDD/7D TDD para calcular el ratio de sensibilidad utilizado para aumentar o disminuir la tasa basal, y también ajustar el objetivo de glucosa si estas opciones están activadas, de la misma forma que lo hace Autosens. Se recomienda comenzar con esta opción desactivada + Utiliza las últimas 24h TDD/7D TDD para calcular el factor de sensibilidad utilizado para aumentar o disminuir la tasa basal, y también ajusta el objetivo de glucosa si estas opciones están activadas, de la misma forma que lo hace Autosens. Se recomienda comenzar con esta opción desactivada Factor de ajuste de ISF Dinámico % Factor de ajuste para ISF Dinámico. Establezca más de 100% para una corrección más agresiva, y menos de 100% para correcciones más susves. Objetivo temporal alto aumenta la sensibilidad diff --git a/plugins/aps/src/main/res/values-fr-rFR/strings.xml b/plugins/aps/src/main/res/values-fr-rFR/strings.xml index f4eebd4873..93cb120907 100644 --- a/plugins/aps/src/main/res/values-fr-rFR/strings.xml +++ b/plugins/aps/src/main/res/values-fr-rFR/strings.xml @@ -1,5 +1,7 @@ + Activer le ratio de sensibilité basé sur DTQ pour la modification du basal et la cible des glycémies + Utilise le dernier DTQ 24h/DTQ 7j pour calculer le ratio de sensibilité utilisé pour augmenter ou diminuer le taux de basal et ajuster aussi la cible de glycémie si ces options sont activées, de la même manière que Autosens. Il est recommandé de commencer avec cette option désactivée Facteur d\'ajustement Si dynamique % Facteur d\'ajustement pour Si dynamique. Définissez plus de 100 % pour des corrections plus agressives et moins de 100 % pour des corrections moins agressives. Cible temp. haute élève la sensibilité diff --git a/plugins/aps/src/main/res/values-iw-rIL/strings.xml b/plugins/aps/src/main/res/values-iw-rIL/strings.xml index 9e5a0e2956..f38db2d5d0 100644 --- a/plugins/aps/src/main/res/values-iw-rIL/strings.xml +++ b/plugins/aps/src/main/res/values-iw-rIL/strings.xml @@ -1,5 +1,7 @@ + אפשר יחס רגישות המבוסס על המינון היומי הכולל לשינוי מינון בזאלי וערכי מטרה + השתמש במינון יומי כולל של 24 שעות\\7 ימים האחרונים כדי לחשב יחס רגישות לצורך העלאה או הורדה של המינון הבזאלי ובנוסף להתאים את ערך המטרה אם אפשרויות אלו מופעלותת באופן דומה ל-Autosense. מומלץ להתחיל כשאפשרות זו כבויה כיוונון פקטור הרגישות הדינאמית % כיוונון פקטור הרגישות הדינאמית. הגדירו יותר מ-100% לקבלת תיקונים אגרסיביים יותר ופחות מ-100% לקבלת תיקונים עדינים יותר. ערך מטרה זמני גבוה מעלה את הרגישות diff --git a/plugins/aps/src/main/res/values-nl-rNL/strings.xml b/plugins/aps/src/main/res/values-nl-rNL/strings.xml index 1b23289673..7530c62297 100644 --- a/plugins/aps/src/main/res/values-nl-rNL/strings.xml +++ b/plugins/aps/src/main/res/values-nl-rNL/strings.xml @@ -1,5 +1,7 @@ + Inschakelen van TDD gebaseerde gevoeligheid ratio voor basaal en glucose doel aanpassingen + Gebruikt de laatste 24u TDD/7D om de gevoeligheid te berekenen voor het verhogen of verlagen van de basaalstand en past het glucosedoel aan als deze opties zijn ingeschakeld, op dezelfde manier als Autosens dat doet. Advies is om te starten met deze optie uitgeschakeld Dynamische Isf aanpassingsfactor % Aanpassingsfactor voor DynamicISF. Stel meer dan 100% in voor een agressievere correctie en minder dan 100% voor minder agressieve correctie. Hoog tijdelijk streefdoel verhoogt gevoeligheid diff --git a/plugins/aps/src/main/res/values-ro-rRO/strings.xml b/plugins/aps/src/main/res/values-ro-rRO/strings.xml index 51754ad6c4..c3f58255cc 100644 --- a/plugins/aps/src/main/res/values-ro-rRO/strings.xml +++ b/plugins/aps/src/main/res/values-ro-rRO/strings.xml @@ -1,5 +1,7 @@ + Activează raportul de sensibilitate bazat pe TDD pentru modificarea bazalei și a țintei glicemice + Folosește ultimele 24 de ore TDD/7D TDD pentru a calcula raportul de sensibilitate utilizat pentru creșterea sau scăderea ratei bazale, şi de asemenea ajustează ţinta glicemic[ dacă aceste opţiuni sunt activate, în acelaşi mod în care procedează Autosens. Este recomandat ca opțiunea să fie oprită la început Factor de ajustare dinamic % Factorul de ajustare pentru DynamicISF. Setează mai mult de 100% pentru doze de corecție mai agresive și mai puțin de 100% pentru corecții mai puțin agresive. Ținte temporare mai mari cresc sensibilitatea diff --git a/plugins/aps/src/main/res/values-ru-rRU/strings.xml b/plugins/aps/src/main/res/values-ru-rRU/strings.xml index 9b405486f5..69ec53722a 100644 --- a/plugins/aps/src/main/res/values-ru-rRU/strings.xml +++ b/plugins/aps/src/main/res/values-ru-rRU/strings.xml @@ -10,9 +10,9 @@ = 100]]> OpenAPS СМБ Динамический ISF - Как часто СМБ будут подаваться в минутах - Сопротивляемость понижает цель - При обнаружении сопротивляемости целевой уровень гликемии понижается + Как часто SMB будут подаваться в минутах + Резистентность понижает цель + При обнаружении резистентности целевой уровень гликемии понижается Чувствительность поднимает цель При обнаружении чувствительности целевой уровень глюкозы повышается модуль не активен diff --git a/plugins/aps/src/main/res/values-sk-rSK/strings.xml b/plugins/aps/src/main/res/values-sk-rSK/strings.xml index 606aceb1d2..035f543be6 100644 --- a/plugins/aps/src/main/res/values-sk-rSK/strings.xml +++ b/plugins/aps/src/main/res/values-sk-rSK/strings.xml @@ -1,5 +1,7 @@ + Povoliť, aby citlivosť založená na CDD mohla upravovať bazál a cieľovú glykémiu + K výpočtu citlivosti použitej pre zvýšenie, alebo zníženie bazálu sa používa CDD za posledných 24 h / 7 D a tiež upravuje cieľovú glykémiu, pokiaľ je táto možnosť povolená, rovnako, ako to robí Autosens. Túto možnosť je doporučené na začiatok vypnúť. Korekčný faktor pre Dynamickú ISF v % Korekčný faktor pre dynamickú ISF. Pre agresivnejšie korekčné dávky nastavte hodnoty väčšie ako 100%, pre menej agresívne korekcie, menšie než 100%. Vysoký dočasný cieľ zvýši citlivosť diff --git a/plugins/configuration/src/main/res/values-ru-rRU/strings.xml b/plugins/configuration/src/main/res/values-ru-rRU/strings.xml index e27f2817c2..4421a56416 100644 --- a/plugins/configuration/src/main/res/values-ru-rRU/strings.xml +++ b/plugins/configuration/src/main/res/values-ru-rRU/strings.xml @@ -35,7 +35,7 @@ Напоминание: новые профили инсулина требуют как минимум 5 часов длительности действия DIA. 5-6 часовая DIA эквивалентна трехчасовой на старых профилях. Профиль Переключить профиль - Пропустить Мастер установки + Пропустить Мастер настройки Нажмите на кнопку ниже, чтобы AAPS мог предложить/внести изменения в базал Плагин чувствительности Sensitivity применяется для определения чувствительности к инсулину и вычисления активных углеводов COB. Дополнительная информация: https://wiki.aaps.app/en/latest/Configuration/Sensitivity-detection-and-COB.html diff --git a/plugins/constraints/src/main/kotlin/app/aaps/plugins/constraints/versionChecker/AllowedVersions.kt b/plugins/constraints/src/main/kotlin/app/aaps/plugins/constraints/versionChecker/AllowedVersions.kt index de331c6bd5..69dc86b752 100644 --- a/plugins/constraints/src/main/kotlin/app/aaps/plugins/constraints/versionChecker/AllowedVersions.kt +++ b/plugins/constraints/src/main/kotlin/app/aaps/plugins/constraints/versionChecker/AllowedVersions.kt @@ -1,9 +1,11 @@ package app.aaps.plugins.constraints.versionChecker -import org.joda.time.LocalDate import org.json.JSONArray import org.json.JSONException import org.json.JSONObject +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId class AllowedVersions { @@ -38,7 +40,8 @@ class AllowedVersions { fun endDateToMilliseconds(endDate: String): Long? { try { val dateTime = LocalDate.parse(endDate) - return dateTime.toDate().time + val instant = dateTime.atTime(LocalTime.MIDNIGHT).atZone(ZoneId.systemDefault()).toInstant() + return instant.toEpochMilli() } catch (ignored: Exception) { } return null diff --git a/plugins/constraints/src/main/res/values-nb-rNO/strings.xml b/plugins/constraints/src/main/res/values-nb-rNO/strings.xml index 7e56ad8011..510add70ed 100644 --- a/plugins/constraints/src/main/res/values-nb-rNO/strings.xml +++ b/plugins/constraints/src/main/res/values-nb-rNO/strings.xml @@ -16,7 +16,7 @@ Versjon %1$s utløper den %2$s - Bruker rekalkulerte data + Rekalkulerte data benyttes fordi noe BS-data mangler eller at BS ankommer på et ikke-forventet tidspunkt. Vil nullstille seg etter ca 24 timer. Ingen handling kreves BS for nær:\n%1$s\n%2$s beregnet på nytt doble registreringer diff --git a/plugins/constraints/src/main/res/values-pl-rPL/objectives.xml b/plugins/constraints/src/main/res/values-pl-rPL/objectives.xml index 09bff75cad..404b9e0b07 100644 --- a/plugins/constraints/src/main/res/values-pl-rPL/objectives.xml +++ b/plugins/constraints/src/main/res/values-pl-rPL/objectives.xml @@ -2,7 +2,7 @@ Start Zweryfikuj - %1$d. Cel + Zadanie %1$d. Zadanie %1$d nierozpoczęte Zadanie %1$d nieukończone Konfiguracja wizualizacji i monitorowania, analiza dawek bazowych i współczynników diff --git a/plugins/constraints/src/main/res/values-ru-rRU/objectives.xml b/plugins/constraints/src/main/res/values-ru-rRU/objectives.xml index bf60945ad5..901b20b971 100644 --- a/plugins/constraints/src/main/res/values-ru-rRU/objectives.xml +++ b/plugins/constraints/src/main/res/values-ru-rRU/objectives.xml @@ -12,7 +12,7 @@ Используйте режим Открытого цикла на протяжении нескольких дней и вручную выставляйте временные цели. Настройте ВЦ по умолчанию (ВЦ для Нагрузки, Гипо, Ожидания приема пищи) и используйте их. Открытый цикл может быть использован для получения рекомендаций по терапии, если у вас нет совместимой помпы или если вы не готовы закрыть цикл. Глубже понимаем работу открытого цикла, включая рекомендации по ВБС - На основе накопленного опыта, определяем максимальную величину базала и задаем ее в помпе и в настройки AndroidAPS + На основе накопленного опыта, определяем максимальную величину базала и задаем ее в помпе и в настройках AAPS Примите меры предосторожности и корректируйте, если необходимо, параметры безопасности. Начинаем замыкать цикл с прекращением подачи инсулина при низких значениях ГК (режим Low Glucose Suspend) Работа в замкнутом цикле с maxIOB = 0 на протяжении нескольких дней. Старайтесь избегать низкой ГК, чтобы не вызывать события приостановки подачи инсулина на низких сахарах. diff --git a/plugins/constraints/src/test/kotlin/app/aaps/plugins/constraints/versionChecker/AllowedVersionsTest.kt b/plugins/constraints/src/test/kotlin/app/aaps/plugins/constraints/versionChecker/AllowedVersionsTest.kt index 0d8f29b5ad..5e18206329 100644 --- a/plugins/constraints/src/test/kotlin/app/aaps/plugins/constraints/versionChecker/AllowedVersionsTest.kt +++ b/plugins/constraints/src/test/kotlin/app/aaps/plugins/constraints/versionChecker/AllowedVersionsTest.kt @@ -1,11 +1,12 @@ package app.aaps.plugins.constraints.versionChecker import com.google.common.truth.Truth.assertThat -import app.aaps.plugins.constraints.versionChecker.AllowedVersions -import org.joda.time.LocalDate import org.json.JSONArray import org.json.JSONObject import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId class AllowedVersionsTest { @@ -71,10 +72,10 @@ class AllowedVersionsTest { @Test fun endDateToMilliseconds() { val definition = generateSupportedVersions() - val endDate = AllowedVersions().endDateToMilliseconds(AllowedVersions().findByVersion(definition, "2.9.0-beta1")?.getString("endDate") ?: "1000/01/01") - val dateTime = LocalDate(endDate) + val endDate = AllowedVersions().endDateToMilliseconds(AllowedVersions().findByVersion(definition, "2.9.0-beta1")?.getString("endDate") ?: "1000/01/01") ?: 0L + val dateTime = LocalDate.ofInstant(Instant.ofEpochMilli(endDate), ZoneId.systemDefault()) assertThat(dateTime.year).isEqualTo(2021) - assertThat(dateTime.monthOfYear).isEqualTo(11) + assertThat(dateTime.monthValue).isEqualTo(11) assertThat(dateTime.dayOfMonth).isEqualTo(7) assertThat(AllowedVersions().endDateToMilliseconds("abdef")).isNull() diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/persistentNotification/PersistentNotificationPlugin.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/persistentNotification/PersistentNotificationPlugin.kt index e9f46e8f65..23f27dddbb 100644 --- a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/persistentNotification/PersistentNotificationPlugin.kt +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/persistentNotification/PersistentNotificationPlugin.kt @@ -124,7 +124,7 @@ class PersistentNotificationPlugin @Inject constructor( val lastBG = iobCobCalculator.ads.lastBg() val glucoseStatus = glucoseStatusProvider.glucoseStatusData if (lastBG != null) { - line1aa = profileUtil.fromMgdlToStringInUnits(lastBG.value) + line1aa = profileUtil.fromMgdlToStringInUnits(lastBG.recalculated) line1 = line1aa if (glucoseStatus != null) { line1 += (" Δ" + profileUtil.fromMgdlToSignedStringInUnits(glucoseStatus.delta) diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/smsCommunicator/SmsCommunicatorPlugin.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/smsCommunicator/SmsCommunicatorPlugin.kt index 67bd789803..9726e1bd4a 100644 --- a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/smsCommunicator/SmsCommunicatorPlugin.kt +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/smsCommunicator/SmsCommunicatorPlugin.kt @@ -356,11 +356,11 @@ class SmsCommunicatorPlugin @Inject constructor( var reply = "" val units = profileUtil.units if (actualBG != null) { - reply = rh.gs(R.string.sms_actual_bg) + " " + profileUtil.fromMgdlToStringInUnits(actualBG.value) + ", " + reply = rh.gs(R.string.sms_actual_bg) + " " + profileUtil.fromMgdlToStringInUnits(actualBG.recalculated) + ", " } else if (lastBG != null) { val agoMilliseconds = dateUtil.now() - lastBG.timestamp val agoMin = (agoMilliseconds / 60.0 / 1000.0).toInt() - reply = rh.gs(R.string.sms_last_bg) + " " + profileUtil.valueInCurrentUnitsDetect(lastBG.value) + " " + rh.gs(R.string.sms_min_ago, agoMin) + ", " + reply = rh.gs(R.string.sms_last_bg) + " " + profileUtil.valueInCurrentUnitsDetect(lastBG.recalculated) + " " + rh.gs(R.string.sms_min_ago, agoMin) + ", " } val glucoseStatus = glucoseStatusProvider.glucoseStatusData if (glucoseStatus != null) reply += rh.gs(R.string.sms_delta) + " " + profileUtil.fromMgdlToUnits(glucoseStatus.delta) + " " + units + ", " diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/profile/ProfileFragment.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/profile/ProfileFragment.kt index 5eb7f3d41d..0415399cc4 100644 --- a/plugins/main/src/main/kotlin/app/aaps/plugins/main/profile/ProfileFragment.kt +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/profile/ProfileFragment.kt @@ -300,7 +300,7 @@ class ProfileFragment : DaggerFragment() { binding.profileRemove.setOnClickListener { activity?.let { activity -> - OKDialog.showConfirmation(activity, rh.gs(R.string.delete_current_profile), { + OKDialog.showConfirmation(activity, rh.gs(R.string.delete_current_profile, profilePlugin.currentProfile()?.name), { uel.log( UserEntry.Action.PROFILE_REMOVED, UserEntry.Sources.LocalProfile, ValueWithUnit.SimpleString( profilePlugin.currentProfile()?.name diff --git a/plugins/main/src/main/res/values-nb-rNO/strings.xml b/plugins/main/src/main/res/values-nb-rNO/strings.xml index f12a2a69a2..730cb98170 100644 --- a/plugins/main/src/main/res/values-nb-rNO/strings.xml +++ b/plugins/main/src/main/res/values-nb-rNO/strings.xml @@ -211,7 +211,7 @@ Endre dine inndata! OpenAPS Opplaster-batteri - BS data status + Status BS-data Innstillinger for hurtigknapp Hold skjermen påslått Forhindre Android fra å slå av skjermen. Mobilen vil bruke mye batteri hvis den ikke kobles til strømledning. diff --git a/plugins/smoothing/src/main/res/values-nb-rNO/strings.xml b/plugins/smoothing/src/main/res/values-nb-rNO/strings.xml index f24cdc1d85..36e143d917 100644 --- a/plugins/smoothing/src/main/res/values-nb-rNO/strings.xml +++ b/plugins/smoothing/src/main/res/values-nb-rNO/strings.xml @@ -2,9 +2,9 @@ UTJEVNING Eksponentiell utjevning - "Andre ordens algoritme for eksponentiell utjevning" + "Algoritme for eksponentiell utjevning, nyeste BS-verdi påvirkes" Gjennomsnittlig utjevning - "Gjennomsnittlig utjevnings-algoritme, nyeste verdi påvirkes ikke" + "Algoritme for gjennomsnittlig utjevning, nyeste BS-verdi påvirkes ikke. Kan minne om BYODA G6 sin utjevningsalgoritme" Ingen utjevning - "Ingen utjevning utføres på motatte blodsukkerverdier. Bruk dette valget når du allerede har filtrerte data, f.eks. fra BYODA G6." + "Ingen utjevning utføres på mottatte blodsukkerverdier. Bruk dette valget når du allerede har filtrerte data, f.eks. fra BYODA G6." diff --git a/plugins/source/build.gradle.kts b/plugins/source/build.gradle.kts index df0c6cf34a..4b0ac810c4 100644 --- a/plugins/source/build.gradle.kts +++ b/plugins/source/build.gradle.kts @@ -22,6 +22,8 @@ dependencies { implementation(project(":core:ui")) implementation(project(":core:utils")) + testImplementation(Libs.AndroidX.Work.testing) + testImplementation(project(":shared:tests")) kapt(Libs.Dagger.compiler) diff --git a/plugins/source/src/main/kotlin/app/aaps/plugins/source/XdripSourcePlugin.kt b/plugins/source/src/main/kotlin/app/aaps/plugins/source/XdripSourcePlugin.kt index 7bcdddc2d0..0ee8e1d7e8 100644 --- a/plugins/source/src/main/kotlin/app/aaps/plugins/source/XdripSourcePlugin.kt +++ b/plugins/source/src/main/kotlin/app/aaps/plugins/source/XdripSourcePlugin.kt @@ -1,20 +1,28 @@ package app.aaps.plugins.source import android.content.Context +import android.os.Bundle import androidx.work.WorkerParameters import androidx.work.workDataOf import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.logging.UserEntryLogger import app.aaps.core.interfaces.plugin.PluginBase import app.aaps.core.interfaces.plugin.PluginDescription import app.aaps.core.interfaces.plugin.PluginType import app.aaps.core.interfaces.receivers.Intents import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.sharedPreferences.SP import app.aaps.core.interfaces.source.BgSource import app.aaps.core.interfaces.source.XDripSource +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.interfaces.utils.T import app.aaps.core.main.utils.worker.LoggingWorker import app.aaps.core.utils.receivers.DataWorkerStorage import app.aaps.database.entities.GlucoseValue +import app.aaps.database.entities.UserEntry.Action +import app.aaps.database.entities.UserEntry.Sources +import app.aaps.database.entities.ValueWithUnit import app.aaps.database.impl.AppRepository import app.aaps.database.impl.transactions.CgmSourceTransaction import app.aaps.database.transactions.TransactionGlucoseValue @@ -22,6 +30,7 @@ import dagger.android.HasAndroidInjector import kotlinx.coroutines.Dispatchers import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.abs import kotlin.math.round @Singleton @@ -34,7 +43,7 @@ class XdripSourcePlugin @Inject constructor( .mainType(PluginType.BGSOURCE) .fragmentClass(BGSourceFragment::class.java.name) .pluginIcon((app.aaps.core.main.R.drawable.ic_blooddrop_48)) - .preferencesId(R.xml.pref_bgsource) + .preferencesId(R.xml.pref_dexcom) .pluginName(R.string.source_xdrip) .description(R.string.description_source_xdrip), aapsLogger, rh, injector @@ -63,8 +72,25 @@ class XdripSourcePlugin @Inject constructor( ) : LoggingWorker(context, params, Dispatchers.IO) { @Inject lateinit var xdripSourcePlugin: XdripSourcePlugin + @Inject lateinit var sp: SP + @Inject lateinit var dateUtil: DateUtil @Inject lateinit var repository: AppRepository @Inject lateinit var dataWorkerStorage: DataWorkerStorage + @Inject lateinit var uel: UserEntryLogger + + fun getSensorStartTime(bundle: Bundle): Long? { + val now = dateUtil.now() + var sensorStartTime: Long? = if (sp.getBoolean(R.string.key_dexcom_log_ns_sensor_change, false)) { + bundle.getLong(Intents.EXTRA_SENSOR_STARTED_AT, 0) + } else { + null + } + // check start time validity + sensorStartTime?.let { + if (abs(it - now) > T.months(1).msecs() || it > now) sensorStartTime = null + } + return sensorStartTime + } override suspend fun doWorkAndLog(): Result { var ret = Result.success() @@ -86,17 +112,27 @@ class XdripSourcePlugin @Inject constructor( ?: "" ) ) - repository.runTransactionForResult(CgmSourceTransaction(glucoseValues, emptyList(), null)) + val sensorStartTime = getSensorStartTime(bundle) + repository.runTransactionForResult(CgmSourceTransaction(glucoseValues, emptyList(), sensorStartTime)) .doOnError { aapsLogger.error(LTag.DATABASE, "Error while saving values from Xdrip", it) ret = Result.failure(workDataOf("Error" to it.toString())) } .blockingGet() - .also { savedValues -> - savedValues.all().forEach { + .also { result -> + result.all().forEach { xdripSourcePlugin.detectSource(it) aapsLogger.debug(LTag.DATABASE, "Inserted bg $it") } + result.sensorInsertionsInserted.forEach { + uel.log( + Action.CAREPORTAL, + Sources.Xdrip, + ValueWithUnit.Timestamp(it.timestamp), + ValueWithUnit.TherapyEventType(it.type) + ) + aapsLogger.debug(LTag.DATABASE, "Inserted sensor insertion $it") + } } xdripSourcePlugin.sensorBatteryLevel = bundle.getInt(Intents.EXTRA_SENSOR_BATTERY, -1) return ret diff --git a/plugins/source/src/test/kotlin/app/aaps/plugins/source/XdripSourcePluginTest.kt b/plugins/source/src/test/kotlin/app/aaps/plugins/source/XdripSourcePluginTest.kt index 3e645fb3e9..6afe6d546a 100644 --- a/plugins/source/src/test/kotlin/app/aaps/plugins/source/XdripSourcePluginTest.kt +++ b/plugins/source/src/test/kotlin/app/aaps/plugins/source/XdripSourcePluginTest.kt @@ -1,25 +1,115 @@ package app.aaps.plugins.source +import android.content.Context +import android.os.Bundle +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.TestListenableWorkerBuilder +import app.aaps.core.interfaces.receivers.Intents import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.interfaces.utils.T +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.utils.receivers.DataWorkerStorage +import app.aaps.shared.impl.utils.DateUtilImpl +import app.aaps.shared.tests.BundleMock import app.aaps.shared.tests.TestBase import com.google.common.truth.Truth.assertThat import dagger.android.AndroidInjector +import dagger.android.HasAndroidInjector import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.Mock +import org.mockito.Mockito.`when` class XdripSourcePluginTest : TestBase() { - private lateinit var xdripSourcePlugin: XdripSourcePlugin + abstract class ContextWithInjector : Context(), HasAndroidInjector + private lateinit var xdripSourcePlugin: XdripSourcePlugin + private lateinit var dateUtil: DateUtil + private lateinit var dataWorkerStorage: DataWorkerStorage + + private val injector = HasAndroidInjector { + AndroidInjector { + if (it is XdripSourcePlugin.XdripSourceWorker) { + it.dataWorkerStorage = dataWorkerStorage + it.dateUtil = dateUtil + it.sp = sp + } + } + } + + @Mock lateinit var sp: SP @Mock lateinit var rh: ResourceHelper + @Mock lateinit var context: ContextWithInjector @BeforeEach fun setup() { - xdripSourcePlugin = XdripSourcePlugin({ AndroidInjector { } }, rh, aapsLogger) + `when`(context.applicationContext).thenReturn(context) + `when`(context.androidInjector()).thenReturn(injector.androidInjector()) + xdripSourcePlugin = XdripSourcePlugin(injector, rh, aapsLogger) + dateUtil = DateUtilImpl(context) + dataWorkerStorage = DataWorkerStorage(context) + } + + private fun prepareWorker( + sensorStartTime: Long? = dateUtil.now(), + logNsSensorChange: Boolean = true, + ): Pair { + val bundle = BundleMock.mock() + sensorStartTime?.let { bundle.putLong(Intents.EXTRA_SENSOR_STARTED_AT, sensorStartTime) } + `when`(sp.getBoolean(R.string.key_dexcom_log_ns_sensor_change, false)).thenReturn(logNsSensorChange) + + lateinit var worker: XdripSourcePlugin.XdripSourceWorker + TestListenableWorkerBuilder(context) + .setWorkerFactory(object: WorkerFactory() { + override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters): XdripSourcePlugin.XdripSourceWorker { + worker = XdripSourcePlugin.XdripSourceWorker(context, workerParameters) + return worker + } + }) + .setInputData(dataWorkerStorage.storeInputData(bundle, Intents.ACTION_NEW_BG_ESTIMATE)).build() + + return Pair(bundle, worker) } @Test fun advancedFilteringSupported() { assertThat(xdripSourcePlugin.advancedFilteringSupported()).isFalse() } + + @Test fun getSensorStartTime_withoutValue_returnsNull() { + val (bundle, worker) = prepareWorker(sensorStartTime = null) + + val result = worker.getSensorStartTime(bundle) + + assertThat(result).isNull() + } + + @Test fun getSensorStartTime_withSettingDisabled_returnsNull() { + val sensorStartTime = dateUtil.now() + val (bundle, worker) = prepareWorker(sensorStartTime = sensorStartTime, logNsSensorChange = false) + + val result = worker.getSensorStartTime(bundle) + + assertThat(result).isNull() + } + + @Test fun getSensorStartTime_withRecentValue_returnsStartTime() { + val sensorStartTime = dateUtil.now() + val (bundle, worker) = prepareWorker(sensorStartTime = sensorStartTime) + + val result = worker.getSensorStartTime(bundle) + + assertThat(result).isEqualTo(sensorStartTime) + } + + @Test fun getSensorStartTime_withOldValue_returnsNull() { + val sensorStartTime = dateUtil.now() - T.months(2).msecs() + val (bundle, worker) = prepareWorker(sensorStartTime = sensorStartTime) + + val result = worker.getSensorStartTime(bundle) + + assertThat(result).isNull() + } } diff --git a/plugins/sync/build.gradle.kts b/plugins/sync/build.gradle.kts index b6b885370c..3261c6db02 100644 --- a/plugins/sync/build.gradle.kts +++ b/plugins/sync/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { testImplementation(project(":shared:tests")) testImplementation(project(":implementation")) testImplementation(project(":plugins:aps")) + androidTestImplementation(project(":shared:tests")) // OpenHuman api(Libs.Squareup.Okhttp3.okhttp) @@ -49,6 +50,10 @@ dependencies { // DataLayerListenerService api(Libs.Google.Android.PlayServices.wearable) + // Garmin + api(Libs.connectiqSdk) + androidTestImplementation(Libs.connectiqSdk) + kapt(Libs.Dagger.compiler) kapt(Libs.Dagger.androidProcessor) } \ No newline at end of file diff --git a/plugins/sync/src/androidTest/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClientTest.kt b/plugins/sync/src/androidTest/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClientTest.kt new file mode 100644 index 0000000000..4f63ea3671 --- /dev/null +++ b/plugins/sync/src/androidTest/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClientTest.kt @@ -0,0 +1,301 @@ +package app.aaps.plugins.sync.garmin + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.os.Binder +import android.os.IBinder +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.aaps.shared.tests.TestBase +import com.garmin.android.apps.connectmobile.connectiq.IConnectIQService +import com.garmin.android.connectiq.ConnectIQ +import com.garmin.android.connectiq.IQApp +import com.garmin.android.connectiq.IQDevice +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.timeout +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.kotlin.argThat +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import java.util.concurrent.Executor + +@RunWith(AndroidJUnit4::class) +class GarminDeviceClientTest: TestBase() { + private val serviceDescriptor = "com.garmin.android.apps.connectmobile.connectiq.IConnectIQService" + private lateinit var client: GarminDeviceClient + private lateinit var serviceConnection: ServiceConnection + private lateinit var device: GarminDevice + private val packageName = "TestPackage" + private val actions = mutableMapOf() + // Maps app ids to intent actions. + private val receivers = mutableMapOf() + + private val receiver = mock() + private val binder = mock() { + on { isBinderAlive } doReturn true + } + private val ciqService = mock() { + on { asBinder() } doReturn binder + on { connectedDevices } doReturn listOf(IQDevice(1L, "TDevice")) + on { registerApp(any(), any(), any()) }.doAnswer { i -> + receivers[i.getArgument(0).applicationId] = i.getArgument(1) + } + } + private val context = mock() { + on { packageName } doReturn this@GarminDeviceClientTest.packageName + on { registerReceiver(any(), any()) } doAnswer { i -> + actions[i.getArgument(1).getAction(0)] = i.getArgument(0) + Intent() + } + on { unregisterReceiver(any()) } doAnswer { i -> + val keys = actions.entries.filter {(_, br) -> br == i.getArgument(0) }.map { (k, _) -> k } + keys.forEach { k -> actions.remove(k) } + } + on { bindService(any(), eq(Context.BIND_AUTO_CREATE), any(), any()) }. doAnswer { i -> + serviceConnection = i.getArgument(3) + i.getArgument(2).execute { + serviceConnection.onServiceConnected( + GarminDeviceClient.CONNECTIQ_SERVICE_COMPONENT, + Binder().apply { attachInterface(ciqService, serviceDescriptor) }) + } + true + } + on { bindService(any(), any(), eq(Context.BIND_AUTO_CREATE)) }. doAnswer { i -> + serviceConnection = i.getArgument(1) + serviceConnection.onServiceConnected( + GarminDeviceClient.CONNECTIQ_SERVICE_COMPONENT, + Binder().apply { attachInterface(ciqService, serviceDescriptor) }) + true + } + } + + @Before + fun setup() { + client = GarminDeviceClient(aapsLogger, context, receiver, retryWaitFactor = 0L) + device = GarminDevice(client, 1L, "TDevice") + verify(receiver, timeout(2_000L)).onConnect(client) + } + + @After + fun shutdown() { + if (::client.isInitialized) client.dispose() + assertEquals(0, actions.size) // make sure all broadcastReceivers were unregistered + verify(context).unbindService(serviceConnection) + } + + @Test + fun connect() { + } + + @Test + fun disconnect() { + serviceConnection.onServiceDisconnected(GarminDeviceClient.CONNECTIQ_SERVICE_COMPONENT) + verify(receiver).onDisconnect(client) + assertEquals(0, actions.size) + } + + @Test + fun connectedDevices() { + assertEquals(listOf(device), client.connectedDevices) + verify(ciqService).connectedDevices + } + + @Test + fun reconnectDeadBinder() { + whenever(binder.isBinderAlive).thenReturn(false, true) + assertEquals(listOf(device), client.connectedDevices) + + verify(ciqService).connectedDevices + verify(ciqService, times(2)).asBinder() + verify(context, times(2)) + .bindService(any(), eq(Context.BIND_AUTO_CREATE), any(), any()) + + verifyNoMoreInteractions(ciqService) + verifyNoMoreInteractions(receiver) + } + + @Test + fun sendMessage() { + val appId = "APPID1" + val data = "Hello, World!".toByteArray() + + client.sendMessage(GarminApplication(device, appId, "$appId-name"), data) + verify(ciqService).sendMessage( + argThat { iqMsg -> data.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat { iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId }) + + val intent = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal) + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId) + } + actions[client.sendMessageAction]!!.onReceive(context, intent) + actions[client.sendMessageAction]!!.onReceive(context, intent) // extra on receive will be ignored + verify(receiver).onSendMessage(client, device.id, appId, null) + } + + @Test + fun sendMessage_failNoRetry() { + val appId = "APPID1" + val data = "Hello, World!".toByteArray() + + client.sendMessage(GarminApplication(device, appId, "$appId-name"), data) + verify(ciqService).sendMessage( + argThat { iqMsg -> data.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId }) + + val intent = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.FAILURE_MESSAGE_TOO_LARGE.ordinal) + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId) + } + actions[client.sendMessageAction]!!.onReceive(context, intent) + verify(receiver).onSendMessage(client, device.id, appId, "error FAILURE_MESSAGE_TOO_LARGE") + } + + @Test + fun sendMessage_failRetry() { + val appId = "APPID1" + val data = "Hello, World!".toByteArray() + + client.sendMessage(GarminApplication(device, appId, "$appId-name"), data) + verify(ciqService).sendMessage( + argThat { iqMsg -> data.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId }) + + val intent = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.FAILURE_DURING_TRANSFER.ordinal) + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId) + } + actions[client.sendMessageAction]!!.onReceive(context, intent) + verifyNoMoreInteractions(receiver) + + // Verify retry ... + verify(ciqService, timeout(10_000L).times( 2)).sendMessage( + argThat { iqMsg -> data.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId }) + + intent.putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal) + actions[client.sendMessageAction]!!.onReceive(context, intent) + verify(receiver).onSendMessage(client, device.id, appId, null) + } + + @Test + fun sendMessage_2toSameApp() { + val appId = "APPID1" + val data1 = "m1".toByteArray() + val data2 = "m2".toByteArray() + + client.sendMessage(GarminApplication(device, appId, "$appId-name"), data1) + client.sendMessage(GarminApplication(device, appId, "$appId-name"), data2) + verify(ciqService).sendMessage( + argThat { iqMsg -> data1.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId }) + verify(ciqService, atLeastOnce()).asBinder() + verifyNoMoreInteractions(ciqService) + + val intent = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal) + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId) + } + actions[client.sendMessageAction]!!.onReceive(context, intent) + verify(receiver).onSendMessage(client, device.id, appId, null) + + verify(ciqService, timeout(5000L)).sendMessage( + argThat { iqMsg -> data2.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId }) + + actions[client.sendMessageAction]!!.onReceive(context, intent) + verify(receiver, times(2)).onSendMessage(client, device.id, appId, null) + } + + @Test + fun sendMessage_2to2Apps() { + val appId1 = "APPID1" + val appId2 = "APPID2" + val data1 = "m1".toByteArray() + val data2 = "m2".toByteArray() + + client.sendMessage(GarminApplication(device, appId1, "$appId1-name"), data1) + client.sendMessage(GarminApplication(device, appId2, "$appId2-name"), data2) + verify(ciqService).sendMessage( + argThat { iqMsg -> data1.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId1 }) + verify(ciqService, timeout(5000L)).sendMessage( + argThat { iqMsg -> data2.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId2 }) + + val intent1 = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal) + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId1) + } + actions[client.sendMessageAction]!!.onReceive(context, intent1) + verify(receiver).onSendMessage(client, device.id, appId1, null) + + val intent2 = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal) + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId2) + } + actions[client.sendMessageAction]!!.onReceive(context, intent2) + verify(receiver).onSendMessage(client, device.id, appId2, null) + } + + @Test + fun receiveMessage() { + val app = GarminApplication(GarminDevice(client, 1L, "D1"), "APPID1", "N1") + client.registerForMessages(app) + assertTrue(receivers.contains(app.id)) + val intent = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, app.device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_PAYLOAD, "foo".toByteArray()) + } + actions[receivers[app.id]]!!.onReceive(context, intent) + verify(receiver).onReceiveMessage( + eq(client), + eq(app.device.id), + eq(app.id), + argThat { payload -> "foo" == String(payload) }) + } +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminApplication.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminApplication.kt new file mode 100644 index 0000000000..6ff37f0a62 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminApplication.kt @@ -0,0 +1,32 @@ +package app.aaps.plugins.sync.garmin + +data class GarminApplication( + val device: GarminDevice, + val id: String, + val name: String?) { + + val client get() = device.client + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GarminApplication + + if (client != other.client) return false + if (device != other.device) return false + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + var result = client.hashCode() + result = 31 * result + device.hashCode() + result = 31 * result + id.hashCode() + return result + } + + override fun toString() = "A[$device:$id:$name]" +} + diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminClient.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminClient.kt new file mode 100644 index 0000000000..6333f2ae82 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminClient.kt @@ -0,0 +1,16 @@ +package app.aaps.plugins.sync.garmin + +import io.reactivex.rxjava3.disposables.Disposable + +interface GarminClient: Disposable { + /** Name of the client. */ + val name: String + + val connectedDevices: List + + /** Register to receive messages from the given up. */ + fun registerForMessages(app: GarminApplication) + + /** Asynchronously sends a message to an application. */ + fun sendMessage(app: GarminApplication, data: ByteArray) +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDevice.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDevice.kt new file mode 100644 index 0000000000..1df255d38a --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDevice.kt @@ -0,0 +1,36 @@ +package app.aaps.plugins.sync.garmin + +import com.garmin.android.connectiq.IQDevice + +data class GarminDevice( + val client: GarminClient, + val id: Long, + var name: String) { + + constructor(client: GarminClient, iqDevice: IQDevice): this( + client, + iqDevice.deviceIdentifier, + iqDevice.friendlyName) {} + + override fun toString(): String = "D[$name/$id]" + + fun toIQDevice() = IQDevice(id, name) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GarminDevice + + if (client != other.client) return false + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + var result = client.hashCode() + result = 31 * result + id.hashCode() + return result + } +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt new file mode 100644 index 0000000000..ec81ddb512 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt @@ -0,0 +1,292 @@ +package app.aaps.plugins.sync.garmin + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.os.Build +import android.os.IBinder +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.utils.waitMillis +import com.garmin.android.apps.connectmobile.connectiq.IConnectIQService +import com.garmin.android.connectiq.ConnectIQ.IQMessageStatus +import com.garmin.android.connectiq.IQApp +import com.garmin.android.connectiq.IQDevice +import com.garmin.android.connectiq.IQMessage +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.jetbrains.annotations.VisibleForTesting +import java.lang.Thread.UncaughtExceptionHandler +import java.time.Instant +import java.util.LinkedList +import java.util.Queue +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + + +/** GarminClient that talks via the ConnectIQ app to a physical device. */ +class GarminDeviceClient( + private val aapsLogger: AAPSLogger, + private val context: Context, + private val receiver: GarminReceiver, + private val retryWaitFactor: Long = 5L): Disposable, GarminClient { + + override val name = "Device" + private var executor = Executors.newSingleThreadExecutor { r -> + Thread(r).apply { + name = "Garmin callback" + isDaemon = true + uncaughtExceptionHandler = UncaughtExceptionHandler { _, e -> + aapsLogger.error(LTag.GARMIN, "ConnectIQ callback failed", e) } + } + } + private var bindLock = Object() + private var ciqService: IConnectIQService? = null + get() { + synchronized (bindLock) { + if (field?.asBinder()?.isBinderAlive != true) { + field = null + if (state !in arrayOf(State.BINDING, State.RECONNECTING)) { + aapsLogger.info(LTag.GARMIN, "reconnecting to ConnectIQ service") + state = State.RECONNECTING + bindService() + } + bindLock.waitMillis(2_000L) + if (field?.asBinder()?.isBinderAlive != true) { + field = null + // The [serviceConnection] didn't have a chance to reassign ciqService, + // i.e. the wait timed out. Give up. + aapsLogger.warn(LTag.GARMIN, "no ciqservice $this") + } + } + return field + } + } + + private val registeredActions = mutableSetOf() + private val broadcastReceiver = mutableListOf() + private var state = State.DISCONNECTED + private val serviceIntent get() = Intent(CONNECTIQ_SERVICE_ACTION).apply { + component = CONNECTIQ_SERVICE_COMPONENT } + + @VisibleForTesting + val sendMessageAction = createAction("SEND_MESSAGE") + + private enum class State { + BINDING, + CONNECTED, + DISCONNECTED, + DISPOSED, + RECONNECTING, + } + + private val ciqServiceConnection = object: ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + var notifyReceiver: Boolean + val ciq: IConnectIQService + synchronized(bindLock) { + aapsLogger.info(LTag.GARMIN, "ConnectIQ App connected") + ciq = IConnectIQService.Stub.asInterface(service) + notifyReceiver = state != State.RECONNECTING + state = State.CONNECTED + ciqService = ciq + bindLock.notifyAll() + } + if (notifyReceiver) receiver.onConnect(this@GarminDeviceClient) + } + + override fun onServiceDisconnected(name: ComponentName?) { + synchronized(bindLock) { + aapsLogger.info(LTag.GARMIN, "ConnectIQ App disconnected") + ciqService = null + if (state != State.DISPOSED) state = State.DISCONNECTED + } + broadcastReceiver.forEach { br -> context.unregisterReceiver(br) } + broadcastReceiver.clear() + registeredActions.clear() + receiver.onDisconnect(this@GarminDeviceClient) + } + } + + init { + aapsLogger.info(LTag.GARMIN, "binding to ConnectIQ service") + registerReceiver(sendMessageAction, ::onSendMessage) + state = State.BINDING + bindService() + } + + private fun bindService() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + context.bindService(serviceIntent, Context.BIND_AUTO_CREATE, executor, ciqServiceConnection) + } else { + context.bindService(serviceIntent, ciqServiceConnection, Context.BIND_AUTO_CREATE) + } + } + + override val connectedDevices: List + get() = ciqService?.connectedDevices?.map { iqDevice -> GarminDevice(this, iqDevice) } + ?: emptyList() + + override fun isDisposed() = state == State.DISPOSED + override fun dispose() { + broadcastReceiver.forEach { context.unregisterReceiver(it) } + broadcastReceiver.clear() + registeredActions.clear() + try { + context.unbindService(ciqServiceConnection) + } catch (e: Exception) { + aapsLogger.warn(LTag.GARMIN, "unbind CIQ failed ${e.message}") + } + state = State.DISPOSED + } + + /** Creates a unique action name for ConnectIQ callbacks. */ + private fun createAction(action: String) = "${javaClass.`package`!!.name}.$action" + + /** Registers a callback [BroadcastReceiver] under the given action that will + * used by the ConnectIQ app for callbacks.*/ + private fun registerReceiver(action: String, receive: (intent: Intent) -> Unit) { + val recv = object: BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { receive(intent) } + } + broadcastReceiver.add(recv) + context.registerReceiver(recv, IntentFilter(action)) + } + + override fun registerForMessages(app: GarminApplication) { + aapsLogger.info(LTag.GARMIN, "registerForMessage $name $app") + val action = createAction("ON_MESSAGE_${app.device.id}_${app.id}") + val iqApp = IQApp(app.id) + synchronized (registeredActions) { + if (!registeredActions.contains(action)) { + registerReceiver(action) { intent: Intent -> onReceiveMessage(iqApp, intent) } + ciqService?.registerApp(iqApp, action, context.packageName) + registeredActions.add(action) + } else { + aapsLogger.info(LTag.GARMIN, "registerForMessage $action already registered") + } + } + } + + @Suppress("Deprecation") + private fun onReceiveMessage(iqApp: IQApp, intent: Intent) { + val iqDevice = intent.getParcelableExtra(EXTRA_REMOTE_DEVICE) as IQDevice? + val data = intent.getByteArrayExtra(EXTRA_PAYLOAD) + if (iqDevice != null && data != null) + receiver.onReceiveMessage(this, iqDevice.deviceIdentifier, iqApp.applicationId, data) + } + + /** Receives callback from ConnectIQ about message transfers. */ + private fun onSendMessage(intent: Intent) { + val statusOrd = intent.getIntExtra(EXTRA_STATUS, IQMessageStatus.FAILURE_UNKNOWN.ordinal) + val status = IQMessageStatus.values().firstOrNull { s -> s.ordinal == statusOrd } ?: IQMessageStatus.FAILURE_UNKNOWN + val deviceId = getDevice(intent) + val appId = intent.getStringExtra(EXTRA_APPLICATION_ID)?.uppercase() + if (deviceId == null || appId == null) { + aapsLogger.warn(LTag.GARMIN, "onSendMessage device='$deviceId' app='$appId'") + } else { + synchronized (messageQueues) { + val queue = messageQueues[deviceId to appId] + val msg = queue?.peek() + if (queue == null || msg == null) { + aapsLogger.warn(LTag.GARMIN, "onSendMessage unknown message $deviceId, $appId, $status") + return + } + + var errorMessage: String? = null + when (status) { + IQMessageStatus.SUCCESS -> {} + IQMessageStatus.FAILURE_DEVICE_NOT_CONNECTED, + IQMessageStatus.FAILURE_DURING_TRANSFER -> { + if (msg.attempt < MAX_RETRIES) { + val delaySec = retryWaitFactor * msg.attempt + Schedulers.io().scheduleDirect({ retryMessage(deviceId, appId) }, delaySec, TimeUnit.SECONDS) + return + } + } + else -> { + errorMessage = "error $status" + } + } + queue.poll() + receiver.onSendMessage(this, msg.app.device.id, msg.app.id, errorMessage) + if (queue.isNotEmpty()) { + Schedulers.io().scheduleDirect { retryMessage(deviceId, appId) } + } + } + } + } + + @Suppress("Deprecation") + private fun getDevice(intent: Intent): Long? { + val rawDevice = intent.extras?.get(EXTRA_REMOTE_DEVICE) + return if (rawDevice is Long) rawDevice else (rawDevice as IQDevice?)?.deviceIdentifier + ?: return null + } + + private class Message( + val app: GarminApplication, + val data: ByteArray) { + var attempt: Int = 0 + val creation = Instant.now() + var lastAttempt: Instant? = null + val iqApp get() = IQApp(app.id, app.name, 0) + val iqDevice get() = app.device.toIQDevice() + } + + private val messageQueues = mutableMapOf, Queue> () + + override fun sendMessage(app: GarminApplication, data: ByteArray) { + val msg = synchronized (messageQueues) { + val msg = Message(app, data) + val oldMessageCutOff = Instant.now().minusSeconds(30) + val queue = messageQueues.getOrPut(app.device.id to app.id) { LinkedList() } + while (true) { + val oldMsg = queue.peek() ?: break + if ((oldMsg.lastAttempt ?: oldMsg.creation).isBefore(oldMessageCutOff)) { + aapsLogger.warn(LTag.GARMIN, "remove old msg ${msg.app}") + queue.poll() + } else { + break + } + } + queue.add(msg) + // Make sure we have only one outstanding message per app, so we ensure + // that always the first message in the queue is currently send. + if (queue.size == 1) msg else null + } + if (msg != null) sendMessage(msg) + } + + private fun retryMessage(deviceId: Long, appId: String) { + val msg = synchronized (messageQueues) { + messageQueues[deviceId to appId]?.peek() ?: return + } + sendMessage(msg) + } + + private fun sendMessage(msg: Message) { + msg.attempt++ + msg.lastAttempt = Instant.now() + val iqMsg = IQMessage(msg.data, context.packageName, sendMessageAction) + ciqService?.sendMessage(iqMsg, msg.iqDevice, msg.iqApp) + } + + override fun toString() = "$name[$state]" + + companion object { + const val CONNECTIQ_SERVICE_ACTION = "com.garmin.android.apps.connectmobile.CONNECTIQ_SERVICE_ACTION" + const val EXTRA_APPLICATION_ID = "com.garmin.android.connectiq.EXTRA_APPLICATION_ID" + const val EXTRA_REMOTE_DEVICE = "com.garmin.android.connectiq.EXTRA_REMOTE_DEVICE" + const val EXTRA_PAYLOAD = "com.garmin.android.connectiq.EXTRA_PAYLOAD" + const val EXTRA_STATUS = "com.garmin.android.connectiq.EXTRA_STATUS" + val CONNECTIQ_SERVICE_COMPONENT = ComponentName( + "com.garmin.android.apps.connectmobile", + "com.garmin.android.apps.connectmobile.connectiq.ConnectIQService") + + const val MAX_RETRIES = 10 + } +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt new file mode 100644 index 0000000000..feaf1743fc --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt @@ -0,0 +1,123 @@ +package app.aaps.plugins.sync.garmin + +import android.content.Context +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import io.reactivex.rxjava3.disposables.Disposable + +class GarminMessenger( + private val aapsLogger: AAPSLogger, + private val context: Context, + applicationIdNames: Map, + private val messageCallback: (app: GarminApplication, msg: Any) -> Unit, + enableConnectIq: Boolean, + enableSimulator: Boolean): Disposable, GarminReceiver { + + private var disposed: Boolean = false + /** All devices that where connected since this instance was created. */ + private val devices = mutableMapOf() + private val clients = mutableListOf() + private val appIdNames = mutableMapOf() + init { + aapsLogger.info(LTag.GARMIN, "init CIQ debug=$enableSimulator") + appIdNames.putAll(applicationIdNames) + if (enableConnectIq) startDeviceClient() + if (enableSimulator) { + appIdNames["SimApp"] = "SimulatorApp" + GarminSimulatorClient(aapsLogger, this) + } + } + + private fun getDevice(client: GarminClient, deviceId: Long): GarminDevice { + synchronized (devices) { + return devices.getOrPut(deviceId) { + client.connectedDevices.firstOrNull { d -> d.id == deviceId } ?: + GarminDevice(client, deviceId, "unknown") } + } + } + + private fun getApplication(client: GarminClient, deviceId: Long, appId: String): GarminApplication { + return GarminApplication(getDevice(client, deviceId), appId, appIdNames[appId]) + } + + private fun startDeviceClient() { + GarminDeviceClient(aapsLogger, context, this) + } + + override fun onConnect(client: GarminClient) { + aapsLogger.info(LTag.GARMIN, "onConnect $client") + clients.add(client) + } + + override fun onDisconnect(client: GarminClient) { + aapsLogger.info(LTag.GARMIN, "onDisconnect ${client.name}") + clients.remove(client) + synchronized (devices) { + val deviceIds = devices.filter { (_, d) -> d.client == client }.map { (id, _) -> id } + deviceIds.forEach { id -> devices.remove(id) } + } + client.dispose() + when (client) { + is GarminDeviceClient -> startDeviceClient() + is GarminSimulatorClient -> GarminSimulatorClient(aapsLogger, this) + else -> aapsLogger.warn(LTag.GARMIN, "onDisconnect unknown client $client") + } + } + + override fun onReceiveMessage(client: GarminClient, deviceId: Long, appId: String, data: ByteArray) { + val app = getApplication(client, deviceId, appId) + val msg = GarminSerializer.deserialize(data) + if (msg == null) { + aapsLogger.warn(LTag.GARMIN, "receive NULL msg") + } else { + aapsLogger.info(LTag.GARMIN, "receive ${data.size} bytes") + messageCallback(app, msg) + } + } + + /** Receives status notifications for a sent message. */ + override fun onSendMessage(client: GarminClient, deviceId: Long, appId: String, errorMessage: String?) { + val app = getApplication(client, deviceId, appId) + aapsLogger.info(LTag.GARMIN, "onSendMessage $app ${errorMessage ?: "OK"}") + } + + fun sendMessage(device: GarminDevice, msg: Any) { + appIdNames.forEach { (appId, _) -> + sendMessage(getApplication(device.client, device.id, appId), msg) + } + } + + /** Sends a message to all applications on all devices. */ + fun sendMessage(msg: Any) { + clients.forEach { cl -> cl.connectedDevices.forEach { d -> sendMessage(d, msg) }} + } + + private fun sendMessage(app: GarminApplication, msg: Any) { + // Convert msg to string for logging. + val s = when (msg) { + is Map<*,*> -> + msg.entries.joinToString(", ", "(", ")") { (k, v) -> "$k=$v" } + is List<*> -> + msg.joinToString(", ", "(", ")") + else -> + msg.toString() + } + val data = GarminSerializer.serialize(msg) + aapsLogger.info(LTag.GARMIN, "sendMessage $app ${data.size} bytes $s") + try { + app.client.sendMessage(app, data) + } catch (e: IllegalStateException) { + aapsLogger.error(LTag.GARMIN, "${app.client} not connected", e) + } + } + + override fun dispose() { + if (!disposed) { + clients.forEach { c -> c.dispose() } + disposed = true + } + clients.clear() + } + + override fun isDisposed() = disposed +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt index 5d20fd3c70..cc8661828f 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt @@ -1,5 +1,6 @@ package app.aaps.plugins.sync.garmin +import android.content.Context import androidx.annotation.VisibleForTesting import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.logging.AAPSLogger @@ -14,10 +15,15 @@ import app.aaps.core.interfaces.rx.events.EventPreferenceChange import app.aaps.core.interfaces.sharedPreferences.SP import app.aaps.database.entities.GlucoseValue import app.aaps.plugins.sync.R +import com.google.gson.JsonArray import com.google.gson.JsonObject import dagger.android.HasAndroidInjector import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.schedulers.Schedulers +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode +import java.net.HttpURLConnection import java.net.SocketAddress import java.net.URI import java.time.Clock @@ -42,6 +48,7 @@ class GarminPlugin @Inject constructor( injector: HasAndroidInjector, aapsLogger: AAPSLogger, resourceHelper: ResourceHelper, + private val context: Context, private val loopHub: LoopHub, private val rxBus: RxBus, private val sp: SP, @@ -57,7 +64,19 @@ class GarminPlugin @Inject constructor( ) { /** HTTP Server for local HTTP server communication (device app requests values) .*/ private var server: HttpServer? = null + var garminMessenger: GarminMessenger? = null + /** Garmin ConnectIQ application id for native communication. Phone pushes values. */ + private val glucoseAppIds = mapOf( + "C9E90EE7E6924829A8B45E7DAFFF5CB4" to "GlucoseWatch_Dev", + "1107CA6C2D5644B998D4BCB3793F2B7C" to "GlucoseDataField_Dev", + "928FE19A4D3A4259B50CB6F9DDAF0F4A" to "GlucoseWidget_Dev", + "662DFCF7F5A147DE8BD37F09574ADB11" to "GlucoseWatch", + "815C7328C21248C493AD9AC4682FE6B3" to "GlucoseDataField", + "4BDDCC1740084A1FAB83A3B2E2FCF55B" to "GlucoseWidget", + ) + + @VisibleForTesting private val disposable = CompositeDisposable() @VisibleForTesting @@ -68,10 +87,24 @@ class GarminPlugin @Inject constructor( var newValue: Condition = valueLock.newCondition() private var lastGlucoseValueTimestamp: Long? = null private val glucoseUnitStr get() = if (loopHub.glucoseUnit == GlucoseUnit.MGDL) "mgdl" else "mmoll" + private val garminAapsKey get() = sp.getString("garmin_aaps_key", "") private fun onPreferenceChange(event: EventPreferenceChange) { - aapsLogger.info(LTag.GARMIN, "preferences change ${event.changedKey}") - setupHttpServer() + when (event.changedKey) { + "communication_debug_mode" -> setupGarminMessenger() + "communication_http", "communication_http_port" -> setupHttpServer() + "garmin_aaps_key" -> sendPhoneAppMessage() + } + } + + private fun setupGarminMessenger() { + val enableDebug = sp.getBoolean("communication_ciq_debug_mode", false) + garminMessenger?.dispose() + garminMessenger = null + aapsLogger.info(LTag.GARMIN, "initialize IQ messenger in debug=$enableDebug") + garminMessenger = GarminMessenger( + aapsLogger, context, glucoseAppIds, {_, _ -> }, + true, enableDebug).also { disposable.add(it) } } override fun onStart() { @@ -83,19 +116,34 @@ class GarminPlugin @Inject constructor( .observeOn(Schedulers.io()) .subscribe(::onPreferenceChange) ) + disposable.add( + rxBus + .toObservable(EventNewBG::class.java) + .observeOn(Schedulers.io()) + .subscribe(::onNewBloodGlucose) + ) setupHttpServer() + if (garminAapsKey.isNotEmpty()) + setupGarminMessenger() } private fun setupHttpServer() { + setupHttpServer(Duration.ZERO) + } + + @VisibleForTesting + fun setupHttpServer(wait: Duration) { if (sp.getBoolean("communication_http", false)) { val port = sp.getInt("communication_http_port", 28891) if (server != null && server?.port == port) return aapsLogger.info(LTag.GARMIN, "starting HTTP server on $port") server?.close() server = HttpServer(aapsLogger, port).apply { - registerEndpoint("/get", ::onGetBloodGlucose) - registerEndpoint("/carbs", ::onPostCarbs) - registerEndpoint("/connect", ::onConnectPump) + registerEndpoint("/get", requestHandler(::onGetBloodGlucose)) + registerEndpoint("/carbs", requestHandler(::onPostCarbs)) + registerEndpoint("/connect", requestHandler(::onConnectPump)) + registerEndpoint("/sgv.json", requestHandler(::onSgv)) + awaitReady(wait) } } else if (server != null) { aapsLogger.info(LTag.GARMIN, "stopping HTTP server") @@ -104,7 +152,7 @@ class GarminPlugin @Inject constructor( } } - override fun onStop() { + public override fun onStop() { disposable.clear() aapsLogger.info(LTag.GARMIN, "Stop") server?.close() @@ -128,6 +176,36 @@ class GarminPlugin @Inject constructor( } } + @VisibleForTesting + fun onConnectDevice(device: GarminDevice) { + if (garminAapsKey.isNotEmpty()) { + aapsLogger.info(LTag.GARMIN, "onConnectDevice $device sending glucose") + sendPhoneAppMessage(device) + } + } + + private fun sendPhoneAppMessage(device: GarminDevice) { + garminMessenger?.sendMessage(device, getGlucoseMessage()) + } + + private fun sendPhoneAppMessage() { + garminMessenger?.sendMessage(getGlucoseMessage()) + } + + @VisibleForTesting + fun getGlucoseMessage() = mapOf( + "key" to garminAapsKey, + "command" to "glucose", + "profile" to loopHub.currentProfileName.first().toString(), + "encodedGlucose" to encodedGlucose(getGlucoseValues()), + "remainingInsulin" to loopHub.insulinOnboard, + "glucoseUnit" to glucoseUnitStr, + "temporaryBasalRate" to + (loopHub.temporaryBasal.takeIf(java.lang.Double::isFinite) ?: 1.0), + "connected" to loopHub.isConnected, + "timestamp" to clock.instant().epochSecond + ) + /** Gets the last 2+ hours of glucose values. */ @VisibleForTesting fun getGlucoseValues(): List { @@ -161,21 +239,33 @@ class GarminPlugin @Inject constructor( val glucoseMgDl: Int = glucose.value.roundToInt() encodedGlucose.add(timeSec, glucoseMgDl) } - aapsLogger.info( - LTag.GARMIN, - "retrieved ${glucoseValues.size} last ${Date(glucoseValues.lastOrNull()?.timestamp ?: 0L)} ${encodedGlucose.size}" - ) return encodedGlucose.encodedBase64() } + @VisibleForTesting + fun requestHandler(action: (URI) -> CharSequence) = { + caller: SocketAddress, uri: URI, _: String? -> + val key = garminAapsKey + val deviceKey = getQueryParameter(uri, "key") + if (key.isNotEmpty() && key != deviceKey) { + aapsLogger.warn(LTag.GARMIN, "Invalid AAPS Key from $caller, got '$deviceKey' want '$key' $uri") + sendPhoneAppMessage() + Thread.sleep(1000L) + HttpURLConnection.HTTP_UNAUTHORIZED to "{}" + } else { + aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri") + HttpURLConnection.HTTP_OK to action(uri).also { + aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri, result: $it") + } + } + } + /** Responses to get glucose value request by the device. * * Also, gets the heart rate readings from the device. */ @VisibleForTesting - @Suppress("UNUSED_PARAMETER") - fun onGetBloodGlucose(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence { - aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri") + fun onGetBloodGlucose(uri: URI): CharSequence { receiveHeartRate(uri) val profileName = loopHub.currentProfileName val waitSec = getQueryParameter(uri, "wait", 0L) @@ -189,9 +279,7 @@ class GarminPlugin @Inject constructor( } jo.addProperty("profile", profileName.first().toString()) jo.addProperty("connected", loopHub.isConnected) - return jo.toString().also { - aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri, result: $it") - } + return jo.toString() } private fun getQueryParameter(uri: URI, name: String) = (uri.query ?: "") @@ -223,6 +311,19 @@ class GarminPlugin @Inject constructor( } } + private fun toLong(v: Any?) = (v as? Number?)?.toLong() ?: 0L + + @VisibleForTesting + fun receiveHeartRate(msg: Map, test: Boolean) { + val avg: Int = msg.getOrDefault("hr", 0) as Int + val samplingStartSec: Long = toLong(msg["hrStart"]) + val samplingEndSec: Long = toLong(msg["hrEnd"]) + val device: String? = msg["device"] as String? + receiveHeartRate( + Instant.ofEpochSecond(samplingStartSec), Instant.ofEpochSecond(samplingEndSec), + avg, device, test) + } + @VisibleForTesting fun receiveHeartRate(uri: URI) { val avg: Int = getQueryParameter(uri, "hr", 0L).toInt() @@ -237,20 +338,18 @@ class GarminPlugin @Inject constructor( private fun receiveHeartRate( samplingStart: Instant, samplingEnd: Instant, avg: Int, device: String?, test: Boolean) { - aapsLogger.info(LTag.GARMIN, "average heart rate $avg BPM test=$test") + aapsLogger.info(LTag.GARMIN, "average heart rate $avg BPM $samplingStart to $samplingEnd") if (test) return if (avg > 10 && samplingStart > Instant.ofEpochMilli(0L) && samplingEnd > samplingStart) { loopHub.storeHeartRate(samplingStart, samplingEnd, avg, device) - } else { + } else if (avg > 0) { aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd") } } /** Handles carb notification from the device. */ @VisibleForTesting - @Suppress("UNUSED_PARAMETER") - fun onPostCarbs(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence { - aapsLogger.info(LTag.GARMIN, "carbs from $caller, req: $uri") + fun onPostCarbs(uri: URI): CharSequence { postCarbs(getQueryParameter(uri, "carbs", 0L).toInt()) return "" } @@ -263,9 +362,7 @@ class GarminPlugin @Inject constructor( /** Handles pump connected notification that the user entered on the Garmin device. */ @VisibleForTesting - @Suppress("UNUSED_PARAMETER") - fun onConnectPump(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence { - aapsLogger.info(LTag.GARMIN, "connect from $caller, req: $uri") + fun onConnectPump(uri: URI): CharSequence { val minutes = getQueryParameter(uri, "disconnectMinutes", 0L).toInt() if (minutes > 0) { loopHub.disconnectPump(minutes) @@ -277,4 +374,61 @@ class GarminPlugin @Inject constructor( jo.addProperty("connected", loopHub.isConnected) return jo.toString() } + + private fun glucoseSlopeMgDlPerMilli(glucose1: GlucoseValue, glucose2: GlucoseValue): Double { + return (glucose2.value - glucose1.value) / (glucose2.timestamp - glucose1.timestamp) + } + + /** Returns glucose values in Nightscout/Xdrip format. */ + @VisibleForTesting + fun onSgv(uri: URI): CharSequence { + val count = getQueryParameter(uri,"count", 24L) + .toInt().coerceAtMost(1000).coerceAtLeast(1) + val briefMode = getQueryParameter(uri, "brief_mode", false) + + // Guess a start time to get [count+1] readings. This is a heuristic that only works if we get readings + // every 5 minutes and we're not missing readings. We truncate in case we get more readings but we'll + // get less, e.g., in case we're missing readings for the last half hour. We get one extra reading, + // to compute the glucose delta. + val from = clock.instant().minus(Duration.ofMinutes(5L * (count + 1))) + val glucoseValues = loopHub.getGlucoseValues(from, false) + val joa = JsonArray() + for (i in 0 until count.coerceAtMost(glucoseValues.size)) { + val jo = JsonObject() + val glucose = glucoseValues[i] + if (!briefMode) { + jo.addProperty("_id", glucose.id.toString()) + jo.addProperty("device", glucose.sourceSensor.toString()) + val timestamp = Instant.ofEpochMilli(glucose.timestamp) + jo.addProperty("deviceString", timestamp.toString()) + jo.addProperty("sysTime", timestamp.toString()) + glucose.raw?.let { raw -> jo.addProperty("unfiltered", raw) } + } + jo.addProperty("date", glucose.timestamp) + jo.addProperty("sgv", glucose.value.roundToInt()) + if (i + 1 < glucoseValues.size) { + // Compute the 5 minute delta. + val delta = 300_000.0 * glucoseSlopeMgDlPerMilli(glucoseValues[i + 1], glucose) + jo.addProperty("delta", BigDecimal(delta, MathContext(3, RoundingMode.HALF_UP))) + } + jo.addProperty("direction", glucose.trendArrow.text) + glucose.noise?.let { n -> jo.addProperty("noise", n) } + if (i == 0) { + when (loopHub.glucoseUnit) { + GlucoseUnit.MGDL -> jo.addProperty("units_hint", "mgdl") + GlucoseUnit.MMOL -> jo.addProperty("units_hint", "mmol") + } + jo.addProperty("iob", loopHub.insulinOnboard + loopHub.insulinBasalOnboard) + loopHub.temporaryBasal.also { + if (!it.isNaN()) { + val temporaryBasalRateInPercent = (it * 100.0).toInt() + jo.addProperty("tbr", temporaryBasalRateInPercent) + } + } + jo.addProperty("cob", loopHub.carbsOnboard) + } + joa.add(jo) + } + return joa.toString() + } } diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminReceiver.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminReceiver.kt new file mode 100644 index 0000000000..e8e93bac51 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminReceiver.kt @@ -0,0 +1,23 @@ +package app.aaps.plugins.sync.garmin + +/** + * Callback interface for a @see ConnectIqClient. + */ +interface GarminReceiver { + /** + * Notifies that the client is ready, i.e. the app client as bound to the Garmin + * Android app. + */ + fun onConnect(client: GarminClient) + fun onDisconnect(client: GarminClient) + + /** + * Delivers received device app messages. + */ + fun onReceiveMessage(client: GarminClient, deviceId: Long, appId: String, data: ByteArray) + + /** + * Delivers status of @see ConnectIqClient#sendMessage requests. + */ + fun onSendMessage(client: GarminClient, deviceId: Long, appId: String, errorMessage: String?) +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSerializer.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSerializer.kt new file mode 100644 index 0000000000..495278e9da --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSerializer.kt @@ -0,0 +1,254 @@ +package app.aaps.plugins.sync.garmin + +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.nio.ByteBuffer +import java.util.ArrayDeque +import java.util.Queue + +/** + * Serialize and Deserialize objects in Garmin format. + * + * Format is as follows: + * ... + * + * Serialized data starts with an optional string block. The string block is preceded with the STRS_MARKER, + * followed by the total length of the reminder (4 bytes). Then foreach string, the string length + * (2 bytes), followed by the string bytes, followed by a \0 byte. + * + * Objects are stored starting with OBJS_MARKER, followed by the total length (4 bytes), followed + * by a flat list of objects. Each object starts with its type (1 byte), followed by the data + * for numbers in Boolean. Strings a represented by an index into the string block. Arrays only have + * the length, the actual objects will be in the list of objects. Similarly, maps only have the + * length and the entries are represented by 2 objects (key + val) in the list of objects. + */ +object GarminSerializer { + private const val NULL = 0 + private const val INT = 1 + private const val FLOAT = 2 + private const val STRING = 3 + private const val ARRAY = 5 + private const val BOOLEAN = 9 + private const val MAP = 11 + private const val LONG = 14 + private const val DOUBLE = 15 + private const val CHAR = 19 + + private const val STRINGS_MARKER = -1412584499 + private const val OBJECTS_MARKER = -629482886 + // ArrayDeque doesn't like null so we use this instead. + private val NULL_MARKER = object {} + + private interface Container { + fun read(buf: ByteBuffer, strings: Map, container: Queue) + } + + private class ListContainer( + val size: Int, + val list: MutableList + ) : Container { + + override fun read(buf: ByteBuffer, strings: Map, container: Queue) { + for (i in 0 until size) { + list.add(readObject(buf, strings, container)) + } + } + } + + private class MapContainer( + val size: Int, + val map: MutableMap + ) : Container { + + override fun read(buf: ByteBuffer, strings: Map, container: Queue) { + for (i in 0 until size) { + val k = readObject(buf, strings, container) + val v = readObject(buf, strings, container) + map[k!!] = v + } + } + } + + + fun serialize(obj: Any?): ByteArray { + val strsOut = ByteArrayOutputStream() + val strsDataOut = DataOutputStream(strsOut) + val objsOut = ByteArrayOutputStream() + val strings = mutableMapOf() + val q = ArrayDeque() + + q.add(obj ?: NULL_MARKER) + while (!q.isEmpty()) { + serialize(q.poll(), strsDataOut, DataOutputStream(objsOut), strings, q) + } + + var bufLen = 8 + objsOut.size() + if (strsOut.size() > 0) { + bufLen += 8 + strsOut.size() + } + + val buf = ByteBuffer.allocate(bufLen) + if (strsOut.size() > 0) { + buf.putInt(STRINGS_MARKER) + buf.putInt(strsOut.size()) + buf.put(strsOut.toByteArray(), 0, strsOut.size()) + } + buf.putInt(OBJECTS_MARKER) + buf.putInt(objsOut.size()) + buf.put(objsOut.toByteArray(), 0, objsOut.size()) + return buf.array() + } + + private fun serialize( + obj: Any?, + strOut: DataOutputStream, + objOut: DataOutputStream, + strings: MutableMap, + q: Queue + ) { + when (obj) { + NULL_MARKER -> objOut.writeByte(NULL) + + is Int -> { + objOut.writeByte(INT) + objOut.writeInt(obj) + } + + is Float -> { + objOut.writeByte(FLOAT) + objOut.writeFloat(obj) + } + + is String -> { + objOut.writeByte(STRING) + val offset = strings[obj] + if (offset == null) { + strings[obj] = strOut.size() + val bytes = obj.toByteArray(Charsets.UTF_8) + strOut.writeShort(bytes.size + 1) + strOut.write(bytes) + strOut.write(0) + } + objOut.writeInt(strings[obj]!!) + } + + is List<*> -> { + objOut.writeByte(ARRAY) + objOut.writeInt(obj.size) + obj.forEach { o -> q.add(o ?: NULL_MARKER) } + } + + is Boolean -> { + objOut.writeByte(BOOLEAN) + objOut.writeByte(if (obj) 1 else 0) + } + + is Map<*, *> -> { + objOut.writeByte(MAP) + objOut.writeInt(obj.size) + obj.entries.forEach { (k, v) -> + q.add(k ?: NULL_MARKER); q.add(v ?: NULL_MARKER) } + } + + is Long -> { + objOut.writeByte(LONG) + objOut.writeLong(obj) + } + + is Double -> { + objOut.writeByte(DOUBLE) + objOut.writeDouble(obj) + } + + is Char -> { + objOut.writeByte(CHAR) + objOut.writeInt(obj.code) + } + + else -> + throw IllegalArgumentException("Unsupported type ${obj?.javaClass} '$obj'") + } + } + + fun deserialize(data: ByteArray): Any? { + val buf = ByteBuffer.wrap(data) + val marker1 = buf.getInt(0) + val strings = if (marker1 == STRINGS_MARKER) { + buf.int // swallow the marker + readStrings(buf) + } else { + emptyMap() + } + val marker2 = buf.int // swallow the marker + if (marker2 != OBJECTS_MARKER) { + throw IllegalArgumentException("expected data marker, got $marker2") + } + return readObjects(buf, strings) + } + + private fun readStrings(buf: ByteBuffer): Map { + val strings = mutableMapOf() + val strBufferLen = buf.int + val offset = buf.position() + while (buf.position() - offset < strBufferLen) { + val pos = buf.position() - offset + val strLen = buf.short.toInt() - 1 // ignore \0 byte + val strBytes = ByteArray(strLen) + buf.get(strBytes) + strings[pos] = String(strBytes, Charsets.UTF_8) + buf.get() // swallow \0 byte + } + return strings + } + + private fun readObjects(buf: ByteBuffer, strings: Map): Any? { + val objBufferLen = buf.int + if (objBufferLen > buf.remaining()) { + throw IllegalArgumentException("expect $objBufferLen bytes got ${buf.remaining()}") + } + + val container = ArrayDeque() + val r = readObject(buf, strings, container) + while (container.isNotEmpty()) { + container.pollFirst()?.read(buf, strings, container) + } + + return r + } + + private fun readObject(buf: ByteBuffer, strings: Map, q: Queue): Any? { + when (buf.get().toInt()) { + NULL -> return null + INT -> return buf.int + FLOAT -> return buf.float + + STRING -> { + val offset = buf.int + return strings[offset]!! + } + + ARRAY -> { + val arraySize = buf.int + val array = mutableListOf() + // We will populate the array with arraySize objects from the object list later, + // when we take the ListContainer from the queue. + q.add(ListContainer(arraySize, array)) + return array + } + + BOOLEAN -> return buf.get() > 0 + + MAP -> { + val mapSize = buf.int + val map = mutableMapOf() + q.add(MapContainer(mapSize, map)) + return map + } + + LONG -> return buf.long + DOUBLE -> return buf.double + CHAR -> return Char(buf.int) + else -> return null + } + } +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt new file mode 100644 index 0000000000..01712a8da6 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt @@ -0,0 +1,182 @@ +package app.aaps.plugins.sync.garmin + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import com.garmin.android.connectiq.IQApp +import com.garmin.android.connectiq.IQApp.IQAppStatus +import io.reactivex.rxjava3.disposables.Disposable +import org.jetbrains.annotations.VisibleForTesting +import java.io.InputStream +import java.net.Inet4Address +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.time.Duration +import java.time.Instant +import java.util.Collections +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** [GarminClient] that talks to the ConnectIQ simulator via HTTP. + * + * This is needed for Garmin device app development. */ +class GarminSimulatorClient( + private val aapsLogger: AAPSLogger, + private val receiver: GarminReceiver, + var port: Int = 7381 +): Disposable, GarminClient { + + override val name = "Sim" + private val executor: ExecutorService = Executors.newCachedThreadPool() + private val serverSocket = ServerSocket() + private val connections: MutableList = Collections.synchronizedList(mutableListOf()) + private var nextDeviceId = AtomicLong(1) + @VisibleForTesting + val iqApp = IQApp("SimApp", IQAppStatus.INSTALLED, "Simulator", 1) + private val readyLock = ReentrantLock() + private val readyCond = readyLock.newCondition() + override val connectedDevices: List get() = connections.map { c -> c.device } + + override fun registerForMessages(app: GarminApplication) { + } + + private inner class Connection(private val socket: Socket): Disposable { + val device = GarminDevice( + this@GarminSimulatorClient, + nextDeviceId.getAndAdd(1L), + "Sim@${socket.remoteSocketAddress}") + + fun start() { + executor.execute { + try { + run() + } catch (e: Throwable) { + aapsLogger.error(LTag.GARMIN, "$device failed", e) + } + } + } + + fun send(data: ByteArray) { + if (socket.isConnected && !socket.isOutputShutdown) { + aapsLogger.info(LTag.GARMIN, "sending ${data.size} bytes to $device") + socket.outputStream.write(data) + socket.outputStream.flush() + } else { + aapsLogger.warn(LTag.GARMIN, "socket closed, cannot send $device") + } + } + + private fun run() { + socket.soTimeout = 0 + socket.isInputShutdown + while (!socket.isClosed && socket.isConnected) { + try { + val data = readAvailable(socket.inputStream) ?: break + if (data.isNotEmpty()) { + kotlin.runCatching { + receiver.onReceiveMessage(this@GarminSimulatorClient, device.id, iqApp.applicationId, data) + } + } + } catch (e: SocketException) { + aapsLogger.warn(LTag.GARMIN, "socket read failed ${e.message}") + break + } + } + aapsLogger.info(LTag.GARMIN, "disconnect ${device.name}" ) + connections.remove(this) + } + + private fun readAvailable(input: InputStream): ByteArray? { + val buffer = ByteArray(1 shl 14) + aapsLogger.info(LTag.GARMIN, "$device reading") + val len = input.read(buffer) + aapsLogger.info(LTag.GARMIN, "$device read $len bytes") + if (len < 0) { + return null + } + val data = ByteArray(len) + System.arraycopy(buffer, 0, data, 0, data.size) + return data + } + + override fun dispose() { + aapsLogger.info(LTag.GARMIN, "close $device") + + @Suppress("EmptyCatchBlock") + try { + socket.close() + } catch (e: SocketException) { + aapsLogger.warn(LTag.GARMIN, "closing socket failed ${e.message}") + } + } + + override fun isDisposed() = socket.isClosed + } + + init { + executor.execute { + runCatching(::listen).exceptionOrNull()?.let { e-> + aapsLogger.error(LTag.GARMIN, "listen failed", e) + } + } + } + + private fun listen() { + val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1)) + aapsLogger.info(LTag.GARMIN, "bind to $ip:$port") + serverSocket.bind(InetSocketAddress(ip, port)) + port = serverSocket.localPort + receiver.onConnect(this@GarminSimulatorClient) + while (!serverSocket.isClosed) { + val s = serverSocket.accept() + aapsLogger.info(LTag.GARMIN, "accept " + s.remoteSocketAddress) + connections.add(Connection(s)) + connections.last().start() + } + receiver.onDisconnect(this@GarminSimulatorClient) + } + + /** Wait for the server to start listing to requests. */ + fun awaitReady(wait: Duration): Boolean { + val waitUntil = Instant.now() + wait + readyLock.withLock { + while (!serverSocket.isBound && Instant.now() < waitUntil) { + readyCond.await(20, TimeUnit.MILLISECONDS) + } + } + return serverSocket.isBound + } + + override fun dispose() { + connections.forEach { c -> c.dispose() } + connections.clear() + serverSocket.close() + executor.awaitTermination(10, TimeUnit.SECONDS) + } + + override fun isDisposed() = serverSocket.isClosed + + private fun getConnection(device: GarminDevice): Connection? { + return connections.firstOrNull { c -> c.device.id == device.id } + } + + override fun sendMessage(app: GarminApplication, data: ByteArray) { + val c = getConnection(app.device) ?: return + try { + c.send(data) + receiver.onSendMessage(this, app.device.id, app.id, null) + } catch (e: SocketException) { + val errorMessage = "sending failed '${e.message}'" + receiver.onSendMessage(this, app.device.id, app.id, errorMessage) + c.dispose() + connections.remove(c) + } + } + + override fun toString() = name +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/HttpServer.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/HttpServer.kt index c349e3cb3d..82f2e0806d 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/HttpServer.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/HttpServer.kt @@ -34,7 +34,7 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po private val serverThread: Thread private val workerExecutor: Executor = Executors.newCachedThreadPool() - private val endpoints: MutableMap CharSequence> = + private val endpoints: MutableMap Pair> = ConcurrentHashMap() private var serverSocket: ServerSocket? = null private val readyLock = ReentrantLock() @@ -76,7 +76,7 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po } /** Register an endpoint (path) to handle requests. */ - fun registerEndpoint(path: String, endpoint: (SocketAddress, URI, String?) -> CharSequence) { + fun registerEndpoint(path: String, endpoint: (SocketAddress, URI, String?) -> Pair) { aapsLogger.info(LTag.GARMIN, "Register: '$path'") endpoints[path] = endpoint } @@ -127,8 +127,8 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po respond(HttpURLConnection.HTTP_NOT_FOUND, out) } else { try { - val body = endpoint(s.remoteSocketAddress, uri, reqBody) - respond(HttpURLConnection.HTTP_OK, body, "application/json", out) + val (code, body) = endpoint(s.remoteSocketAddress, uri, reqBody) + respond(code, body, "application/json", out) } catch (e: Exception) { aapsLogger.error(LTag.GARMIN, "endpoint " + uri.path + " failed", e) respond(HttpURLConnection.HTTP_INTERNAL_ERROR, out) diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHub.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHub.kt index 4b420d21f5..ebd8ac209b 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHub.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHub.kt @@ -20,6 +20,12 @@ interface LoopHub { /** Returns the remaining bolus insulin on board. */ val insulinOnboard: Double + /** Returns the basal insulin on board. */ + val insulinBasalOnboard: Double + + /** Returns the remaining carbs on board. */ + val carbsOnboard: Double? + /** Returns true if the pump is connected. */ val isConnected: Boolean @@ -48,4 +54,4 @@ interface LoopHub { avgHeartRate: Int, device: String? ) -} \ No newline at end of file +} diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHubImpl.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHubImpl.kt index dfcf9dab37..1e9f19ae7f 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHubImpl.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/LoopHubImpl.kt @@ -13,6 +13,7 @@ import app.aaps.core.interfaces.profile.ProfileFunction import app.aaps.core.interfaces.pump.DetailedBolusInfo import app.aaps.core.interfaces.queue.CommandQueue import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.main.graph.OverviewData import app.aaps.database.ValueWrapper import app.aaps.database.entities.EffectiveProfileSwitch import app.aaps.database.entities.GlucoseValue @@ -42,6 +43,7 @@ class LoopHubImpl @Inject constructor( private val repo: AppRepository, private val userEntryLogger: UserEntryLogger, private val sp: SP, + private val overviewData: OverviewData, ) : LoopHub { @VisibleForTesting @@ -64,6 +66,14 @@ class LoopHubImpl @Inject constructor( override val insulinOnboard: Double get() = iobCobCalculator.calculateIobFromBolus().iob + /** Returns the remaining bolus and basal insulin on board. */ + override val insulinBasalOnboard :Double + get() = iobCobCalculator.calculateIobFromTempBasalsIncludingConvertedExtended().basaliob + + /** Returns the remaining carbs on board. */ + override val carbsOnboard: Double? + get() = overviewData.cobInfo(iobCobCalculator).displayCob + /** Returns true if the pump is connected. */ override val isConnected: Boolean get() = !loop.isDisconnected @@ -142,4 +152,4 @@ class LoopHubImpl @Inject constructor( ) repo.runTransaction(InsertOrUpdateHeartRateTransaction(hr)).blockingAwait() } -} \ No newline at end of file +} diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nsclientV3/services/NSClientV3Service.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nsclientV3/services/NSClientV3Service.kt index 27a8bb7366..cf0404812a 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nsclientV3/services/NSClientV3Service.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nsclientV3/services/NSClientV3Service.kt @@ -167,7 +167,8 @@ class NSClientV3Service : DaggerService() { rxBus.send(EventNSClientNewLog("◄ WS", "Subscribed for: ${response.optString("collections")}")) // during disconnection updated data is not received // thus run non WS load to get missing data - nsClientV3Plugin.executeLoop("WS_CONNECT", forceNew = false) + nsClientV3Plugin.initialLoadFinished = false + nsClientV3Plugin.executeLoop("WS_CONNECT", forceNew = true) true } else { rxBus.send(EventNSClientNewLog("◄ WS", "Auth failed")) diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/dataBroadcaster/DataBroadcastPlugin.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/tizen/TizenPlugin.kt similarity index 97% rename from plugins/sync/src/main/kotlin/app/aaps/plugins/sync/dataBroadcaster/DataBroadcastPlugin.kt rename to plugins/sync/src/main/kotlin/app/aaps/plugins/sync/tizen/TizenPlugin.kt index 6ee7128b16..3f90c172c9 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/dataBroadcaster/DataBroadcastPlugin.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/tizen/TizenPlugin.kt @@ -1,4 +1,4 @@ -package app.aaps.plugins.sync.dataBroadcaster +package app.aaps.plugins.sync.tizen import android.content.Context import android.content.Intent @@ -41,7 +41,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class DataBroadcastPlugin @Inject constructor( +class TizenPlugin @Inject constructor( injector: HasAndroidInjector, aapsLogger: AAPSLogger, rh: ResourceHelper, @@ -64,9 +64,9 @@ class DataBroadcastPlugin @Inject constructor( PluginDescription() .mainType(PluginType.SYNC) .pluginIcon(app.aaps.core.main.R.drawable.ic_watch) - .pluginName(R.string.data_broadcaster) - .shortName(R.string.data_broadcaster_short) - .description(R.string.data_broadcaster_description), + .pluginName(R.string.tizen) + .shortName(R.string.tizen_short) + .description(R.string.tizen_description), aapsLogger, rh, injector ) { diff --git a/plugins/sync/src/main/res/values-cs-rCZ/strings.xml b/plugins/sync/src/main/res/values-cs-rCZ/strings.xml index 870a05b4af..e557bea998 100644 --- a/plugins/sync/src/main/res/values-cs-rCZ/strings.xml +++ b/plugins/sync/src/main/res/values-cs-rCZ/strings.xml @@ -117,8 +117,9 @@ Poslat data o glykémii a ošetření do xDrip+. Musí být vybrán zdroj dat \"xDrip+ Sync Follower\" a přijímání dat musí být povoleno v Nastavení - Nastavení komunikace mezi aplikacemi - Přijímat Glykémie/Ošetření Povolit odesílání do xDrip+. - DBRO - Odesílání dat do Garmin aplikace G-Watch Wear App + Samsung Tizen + TIZ + Odesílání dat do Samsung aplikace G-Watch Wear App (Tizen OS) Garmin Připojení k zařízení Garmin (Fenix, Edge, …) diff --git a/plugins/sync/src/main/res/values-es-rES/strings.xml b/plugins/sync/src/main/res/values-es-rES/strings.xml index 05ce3e3902..4cc1e18c35 100644 --- a/plugins/sync/src/main/res/values-es-rES/strings.xml +++ b/plugins/sync/src/main/res/values-es-rES/strings.xml @@ -117,8 +117,6 @@ Enviar datos de glucosa y tratamientos a xDrip+. La fuente de datos \"xDrip+ Sync Follower\" debe estar seleccionada y la aceptación de datos debe estar activada en Ajustes - Ajustes entre aplicaciones - Aceptar glucosa/tratamientos Activar las transmisiones a xDrip+ - DBRO - Transmitir datos a la aplicación G-Watch Wear de Garmin Garmin Conexión al dispositivo Garmin (Fénix, Edge, …) @@ -128,7 +126,7 @@ Supervisar y controlar AAPS usando un reloj WearOS (Ningún reloj conectado) Estado de la bomba de insulina - Estado del lazo + Estado del bucle Calc. Asistente:\nInsulina: %1$.2fU\nCarbohidratos: %2$dg El asistente rápido seleccionado ya no está disponible, por favor actualice su tarjeta Asistente Rápido: %1$s\nInsulina: %2$.2fU\nCarbohidratos: %3$dg diff --git a/plugins/sync/src/main/res/values-fr-rFR/strings.xml b/plugins/sync/src/main/res/values-fr-rFR/strings.xml index 2af445a728..9607d003ad 100644 --- a/plugins/sync/src/main/res/values-fr-rFR/strings.xml +++ b/plugins/sync/src/main/res/values-fr-rFR/strings.xml @@ -17,6 +17,7 @@ Synchronise vos données avec Nightscout en utilisant l\'API v3 Bloqué par les options de recharge Bloqué par les options de connectivité + Aucune connectivité Version de Nightscout non supportée OAPS UPLD @@ -116,9 +117,6 @@ Envoyer les glycémies et les traitements à xDrip+. La source de données \"xDrip+ Sync Follower\" doit être sélectionnée et l\'acceptation des données doit être activée dans Paramètres - Paramètres Inter-app - Accepter Glycémies/Traitements Activer les diffusions vers xDrip+. - Diffuseur de données - DD - Diffuser des données sur l\'application G-Watch Wear de Garmin Garmin Connexion au périphérique Garmin (Fenix, Edge, …) diff --git a/plugins/sync/src/main/res/values-it-rIT/strings.xml b/plugins/sync/src/main/res/values-it-rIT/strings.xml index 1e821f5299..91bec16496 100644 --- a/plugins/sync/src/main/res/values-it-rIT/strings.xml +++ b/plugins/sync/src/main/res/values-it-rIT/strings.xml @@ -117,8 +117,9 @@ Invia dati glicemia e trattamenti a xDrip+. La sorgente dati \"xDrip+ Sync Follower\" deve essere selezionata e l\'accettazione dei dati deve essere abilitata in: Settings - Inter-app settings - Accept Glucose/Treatments Abilita trasmissioni a xDrip+. - DBRO - Trasmetti i dati all\'app G-Watch Wear di Garmin + Samsung Tizen + TIZ + Trasmetti i dati all\'app G-Watch Wear di Samsung (Tizen OS) Garmin Connessione al dispositivo Garmin (Fenix, Edge, …) diff --git a/plugins/sync/src/main/res/values-lt-rLT/strings.xml b/plugins/sync/src/main/res/values-lt-rLT/strings.xml index fd27843caf..c06aae11d4 100644 --- a/plugins/sync/src/main/res/values-lt-rLT/strings.xml +++ b/plugins/sync/src/main/res/values-lt-rLT/strings.xml @@ -117,7 +117,6 @@ Siųsti glikemijos ir terapijos duomenis į xDrip+. Turi būti pasirinktas duomenų šaltinis „xDrip+ Sync Follower“, o duomenų priėmimas turi būti įjungtas skiltyje Nustatymai – Programų sąveikos nustatymai – Priimti glikemiją/terapiją Įjungti duomenų perdavimą į xDrip+. - Data Broadcaster Garmin Susiejimas su Garmin (Fenix, Edge, …) diff --git a/plugins/sync/src/main/res/values-nb-rNO/strings.xml b/plugins/sync/src/main/res/values-nb-rNO/strings.xml index 0e99568379..7af2272a92 100644 --- a/plugins/sync/src/main/res/values-nb-rNO/strings.xml +++ b/plugins/sync/src/main/res/values-nb-rNO/strings.xml @@ -117,8 +117,9 @@ Send data om glukose og behandling til xDrip+. Velg datakilde \"xDrip+ Sync Følger\" og aktiver mottak av data under Innstillinger - Inter-app innstillinger - Aksepter glukose/behandlinger Aktiver sending til xDrip+. - DBRO - Kringkast data til Garmin\'s G-Watch Wear app + Samsung Tizen + TIZ + Kringkast data til Samsung\'s G-Watch Wear app (Tizen OS) Garmin Tilkobling til Garmin-enheter (Fenix, Edge,…) diff --git a/plugins/sync/src/main/res/values-nl-rNL/strings.xml b/plugins/sync/src/main/res/values-nl-rNL/strings.xml index 477feec8a6..fff5b22634 100644 --- a/plugins/sync/src/main/res/values-nl-rNL/strings.xml +++ b/plugins/sync/src/main/res/values-nl-rNL/strings.xml @@ -117,8 +117,6 @@ Verzend glucose en behandelingsgegevens naar xDrip+. Gegevensbron \"xDrip+ Sync Follower\" moet worden geselecteerd en het accepteren van gegevens moet worden ingeschakeld in Instellingen - Inter-app instellingen - Accepteer Glucose/Behandelingen Activeer uitzendingen naar xDrip+. - DBRO - Verzend gegevens naar Garmin\'s G-Watch Wear App Garmin Verbinding met Garmin apparaat (Fenix, Edge, …) diff --git a/plugins/sync/src/main/res/values-pl-rPL/strings.xml b/plugins/sync/src/main/res/values-pl-rPL/strings.xml index d444294137..22043712ab 100644 --- a/plugins/sync/src/main/res/values-pl-rPL/strings.xml +++ b/plugins/sync/src/main/res/values-pl-rPL/strings.xml @@ -117,8 +117,6 @@ Wyślij dane dotyczące glikemii i leczenia do xDrip+. W ustawieniach xDrip+ należy ustawić \"Sprzętowe źródło danych\" na \"xDrip+ Sync Follower\" oraz włączyć akceptowanie danych: \"Ustawienia innych aplikacji\" - \"Akceptuj Glukozę/Akceptuj zabiegi\" Włącz nadawanie do xDrip+. - DBRO - Transmisja danych do aplikacji G-Watch Garmin Garmin Połączenie z urządzeniem Garmin (Fenix, Edge, …) diff --git a/plugins/sync/src/main/res/values-ro-rRO/strings.xml b/plugins/sync/src/main/res/values-ro-rRO/strings.xml index 712f43398e..841bfeff1e 100644 --- a/plugins/sync/src/main/res/values-ro-rRO/strings.xml +++ b/plugins/sync/src/main/res/values-ro-rRO/strings.xml @@ -117,8 +117,9 @@ Trimite date despre glucoză și tratamente către xDrip+. Trebuie să fie selectată sursa de date \"Sincronizare xDrip+ Urmăritor\" și acceptarea datelor trebuie să fie activată în „Setări - Setări între aplicații - Acceptă Glucoză/Tratamente” Activează transmisiuni spre xDrip+. - DBRO - Transmitere date către aplicaţia G-Watch Wear pe Garmin + Samsung Tizen + TIZ + Transmitere de date către aplicaţia Samsung G-Watch Wear (TizenOS) Garmin Conexiune la dispozitivul Garmin (Fenix, Edge, …) diff --git a/plugins/sync/src/main/res/values-ru-rRU/oh_strings.xml b/plugins/sync/src/main/res/values-ru-rRU/oh_strings.xml index 03f89ca280..b497eb4703 100644 --- a/plugins/sync/src/main/res/values-ru-rRU/oh_strings.xml +++ b/plugins/sync/src/main/res/values-ru-rRU/oh_strings.xml @@ -22,7 +22,7 @@ Условия пользования Внимательно ознакомьтесь со следующей информацией и примите условия использования. Это проект с открытым исходным кодом, который будет копировать ваши данные на Open Humans. Мы не будем обмениваться вашими данными с третьими лицами без вашего явного разрешения. Данные, отправляемые на проект и приложение, идентифицируются с помощью случайного идентификатора и будут безопасно передаваться на учетную запись Open Humans при вашем одобрении этого процесса. Вы можете остановить загрузку и удалить загруженные данные в любое время здесь: www.openhumans.org. - Данные загружены + Загружаемые данные Гликемия Болюсы Пролонгированные болюсы @@ -37,7 +37,7 @@ Модель устройства Размеры экрана Данные отладки алгоритма - Данные НЕ загружены + НЕзагружаемые данные Пароли URL-адрес Nightscout Секретный ключ Nightscout API diff --git a/plugins/sync/src/main/res/values-ru-rRU/strings.xml b/plugins/sync/src/main/res/values-ru-rRU/strings.xml index a2498c8e08..b3daffd9c0 100644 --- a/plugins/sync/src/main/res/values-ru-rRU/strings.xml +++ b/plugins/sync/src/main/res/values-ru-rRU/strings.xml @@ -117,8 +117,9 @@ Отправлять данные о глюкозе и терапии на xDrip+. Источником данных должен быть выбран \"xDrip+ Sync Follower\" а в настройках между приложениями надо включить - Принимать глюкозу/терапию Включить трансляции для xDrip+. - DBRO - Передавать данные приложению Garmin\'s G-Watch Wear + Samsung Tizen + TIZ + Передавать данные приложению Samsung G-Watch Wear (OS Tizen) Garmin Подключение к устройству Garmin (Fenix, Edge, …) diff --git a/plugins/sync/src/main/res/values-sk-rSK/strings.xml b/plugins/sync/src/main/res/values-sk-rSK/strings.xml index b4a49e688e..798a6d17f1 100644 --- a/plugins/sync/src/main/res/values-sk-rSK/strings.xml +++ b/plugins/sync/src/main/res/values-sk-rSK/strings.xml @@ -17,6 +17,7 @@ Synchronizuje vaše dáta s Nightscoutom pomocou v3 API Zablokované možnosti nabíjania Zablokované možnosti pripojenia + Žiadne pripojenie Nepodporovaná verzia Nighscoutu OAPS UPLD @@ -116,8 +117,9 @@ Poslať dáta o glykémii a ošetrení do xDrip+. Musí byť vybraný zdroj dát \"xDrip+ Sync Follower\" a prijímanie dát musí byť povolené v Nastavenia - Nastavenie komunikácie medzi aplikáciami - Prijímať Glykémie/Ošetrenia Povoliť odosielanie do xDrip+. - DBRO - Odosielanie dát do Garmin aplikácie G-Watch Wear + Samsung Tizen + TIZ + Odosielanie dát do Samsung aplikácie G-Watch Wear App (Tizen OS) Garmin Pripájanie k zariadeniu Garmin (Fénix, Edge, ...) diff --git a/plugins/sync/src/main/res/values-tr-rTR/strings.xml b/plugins/sync/src/main/res/values-tr-rTR/strings.xml index c90441313e..8f02ae2630 100644 --- a/plugins/sync/src/main/res/values-tr-rTR/strings.xml +++ b/plugins/sync/src/main/res/values-tr-rTR/strings.xml @@ -117,8 +117,9 @@ KŞ ve tedavi verilerini xDrip+\'a gönderin. Veri Kaynağı \"xDrip+ Sync Follower\" seçilmeli ve Ayarlar - Uygulamalar arası ayarlar - KŞ/Tedavileri Kabul Et bölümünde verilerin kabul edilmesi etkinleştirilmelidir. xDrip+ \'a yayınları etkinleştirin. - DBRO - Verileri Garmin\'in G-Watch Wear Uygulamasına yayınlayın + Samsung Tizen + TIZ + Verileri Samsung\'un G-Watch Wear Uygulamasına (Tizen OS) yayınlayın Garmin Garmin cihazına bağlantı (Fenix, Edge,…) diff --git a/plugins/sync/src/main/res/values/strings.xml b/plugins/sync/src/main/res/values/strings.xml index 9a85f46131..266db55530 100644 --- a/plugins/sync/src/main/res/values/strings.xml +++ b/plugins/sync/src/main/res/values/strings.xml @@ -180,9 +180,9 @@ - Data Broadcaster - DBRO - Broadcast data to Garmin\'s G-Watch Wear App + Samsung Tizen + TIZ + Broadcast data to Samsung\'s G-Watch Wear App (Tizen OS) Garmin diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminMessengerTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminMessengerTest.kt new file mode 100644 index 0000000000..3497e4c900 --- /dev/null +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminMessengerTest.kt @@ -0,0 +1,110 @@ +package app.aaps.plugins.sync.garmin + +import android.content.Context +import app.aaps.shared.tests.TestBase +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.mockito.kotlin.verify + +class GarminMessengerTest: TestBase() { + private val context = mock() + + private var appId1 = "appId1" + private val appId2 = "appId2" + + private val apps = mapOf(appId1 to "$appId1-name", appId2 to "$appId2-name") + private val outMessages = mutableListOf>() + private val inMessages = mutableListOf>() + private var messenger = GarminMessenger( + aapsLogger, context, apps, { app, msg -> inMessages.add(app to msg) }, + enableConnectIq = false, enableSimulator = false) + private val client1 = mock() { + on { name } doReturn "Mock1" + on { sendMessage(any(), any()) } doAnswer { a -> + outMessages.add(a.getArgument(0) to a.getArgument(1)) + Unit + } + } + private val client2 = mock() { + on { name } doReturn "Mock2" + on { sendMessage(any(), any()) } doAnswer { a -> + outMessages.add(a.getArgument(0) to a.getArgument(1)) + Unit + } + } + private val device1 = GarminDevice(client1, 11L, "dev1-name") + private val device2 = GarminDevice(client2, 12L, "dev2-name") + + @BeforeEach + fun setup() { + messenger.onConnect(client1) + messenger.onConnect(client2) + client1.stub { + on { connectedDevices } doReturn listOf(device1) + } + client2.stub { + on { connectedDevices } doReturn listOf(device2) + } + } + @AfterEach + fun cleanup() { + messenger.dispose() + verify(client1).dispose() + verify(client2).dispose() + assertTrue(messenger.isDisposed) + } + + @Test + fun onDisconnect() { + messenger.onDisconnect(client1) + val msg = "foo" + messenger.sendMessage(msg) + outMessages.forEach { (app, payload) -> + assertEquals(client2, app.device.client) + assertEquals(msg, GarminSerializer.deserialize(payload)) + } + } + + @Test + fun onReceiveMessage() { + val data = GarminSerializer.serialize("foo") + messenger.onReceiveMessage(client1, device1.id, appId1, data) + val (app, payload) = inMessages.removeAt(0) + assertEquals(appId1, app.id) + assertEquals("foo", payload) + } + + @Test + fun sendMessageDevice() { + messenger.sendMessage(device1, "foo") + assertEquals(2, outMessages.size) + val msg1 = outMessages.first { (app, _) -> app.id == appId1 }.second + val msg2 = outMessages.first { (app, _) -> app.id == appId2 }.second + assertEquals("foo", GarminSerializer.deserialize(msg1)) + assertEquals("foo", GarminSerializer.deserialize(msg2)) + messenger.onSendMessage(client1, device1.id, appId1, null) + } + + @Test + fun onSendMessageAll() { + messenger.sendMessage(listOf("foo")) + assertEquals(4, outMessages.size) + val msg11 = outMessages.first { (app, _) -> app.device == device1 && app.id == appId1 }.second + val msg12 = outMessages.first { (app, _) -> app.device == device1 && app.id == appId2 }.second + val msg21 = outMessages.first { (app, _) -> app.device == device2 && app.id == appId1 }.second + val msg22 = outMessages.first { (app, _) -> app.device == device2 && app.id == appId2 }.second + assertEquals(listOf("foo"), GarminSerializer.deserialize(msg11)) + assertEquals(listOf("foo"), GarminSerializer.deserialize(msg12)) + assertEquals(listOf("foo"), GarminSerializer.deserialize(msg21)) + assertEquals(listOf("foo"), GarminSerializer.deserialize(msg22)) + messenger.onSendMessage(client1, device1.id, appId1, null) + } +} \ No newline at end of file diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt index 18c772877e..e1e9fa89a9 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt @@ -1,5 +1,6 @@ package app.aaps.plugins.sync.garmin +import android.content.Context import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.resources.ResourceHelper import app.aaps.core.interfaces.rx.events.EventNewBG @@ -11,9 +12,14 @@ import dagger.android.HasAndroidInjector import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyInt import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.anyString import org.mockito.Mock import org.mockito.Mockito.atMost import org.mockito.Mockito.mock @@ -21,19 +27,28 @@ import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.Mockito.`when` +import org.mockito.kotlin.any +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import java.net.ConnectException +import java.net.HttpURLConnection import java.net.SocketAddress import java.net.URI import java.time.Clock +import java.time.Duration import java.time.Instant import java.time.ZoneId import java.time.temporal.ChronoUnit import java.util.concurrent.locks.Condition +import kotlin.ranges.LongProgression.Companion.fromClosedRange class GarminPluginTest: TestBase() { private lateinit var gp: GarminPlugin @Mock private lateinit var rh: ResourceHelper @Mock private lateinit var sp: SP + @Mock private lateinit var context: Context @Mock private lateinit var loopHub: LoopHub private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC")) @@ -44,14 +59,23 @@ class GarminPluginTest: TestBase() { @BeforeEach fun setup() { - gp = GarminPlugin(injector, aapsLogger, rh, loopHub, rxBus, sp) + gp = GarminPlugin(injector, aapsLogger, rh, context, loopHub, rxBus, sp) gp.clock = clock `when`(loopHub.currentProfileName).thenReturn("Default") + `when`(sp.getBoolean(anyString(), anyBoolean())).thenAnswer { i -> i.arguments[1] } + `when`(sp.getString(anyString(), anyString())).thenAnswer { i -> i.arguments[1] } + `when`(sp.getInt(anyString(), anyInt())).thenAnswer { i -> i.arguments[1] } + `when`(sp.getInt(eq("communication_http_port"), anyInt())) + .thenReturn(28890) } @AfterEach fun verifyNoFurtherInteractions() { verify(loopHub, atMost(2)).currentProfileName + verify(loopHub, atMost(3)).insulinOnboard + verify(loopHub, atMost(3)).insulinBasalOnboard + verify(loopHub, atMost(3)).temporaryBasal + verify(loopHub, atMost(3)).carbsOnboard verifyNoMoreInteractions(loopHub) } @@ -71,11 +95,23 @@ class GarminPluginTest: TestBase() { "device" to "Test_Device") private fun createGlucoseValue(timestamp: Instant, value: Double = 93.0) = GlucoseValue( + id = 10 * timestamp.toEpochMilli(), timestamp = timestamp.toEpochMilli(), raw = 90.0, value = value, - trendArrow = GlucoseValue.TrendArrow.FLAT, noise = null, + trendArrow = GlucoseValue.TrendArrow.FLAT, noise = 4.5, sourceSensor = GlucoseValue.SourceSensor.RANDOM ) + @Test + fun testReceiveHeartRateMap() { + val hr = createHeartRate(80) + gp.receiveHeartRate(hr, false) + verify(loopHub).storeHeartRate( + Instant.ofEpochSecond(hr["hrStart"] as Long), + Instant.ofEpochSecond(hr["hrEnd"] as Long), + 80, + hr["device"] as String) + } + @Test fun testReceiveHeartRateUri() { val hr = createHeartRate(99) @@ -119,6 +155,132 @@ class GarminPluginTest: TestBase() { verify(loopHub).getGlucoseValues(from, true) } + @Test + fun setupHttpServer_enabled() { + `when`(sp.getBoolean("communication_http", false)).thenReturn(true) + `when`(sp.getInt("communication_http_port", 28891)).thenReturn(28892) + gp.setupHttpServer(Duration.ofSeconds(10)) + val reqUri = URI("http://127.0.0.1:28892/get") + val resp = reqUri.toURL().openConnection() as HttpURLConnection + assertEquals(200, resp.responseCode) + + // Change port + `when`(sp.getInt("communication_http_port", 28891)).thenReturn(28893) + gp.setupHttpServer(Duration.ofSeconds(10)) + val reqUri2 = URI("http://127.0.0.1:28893/get") + val resp2 = reqUri2.toURL().openConnection() as HttpURLConnection + assertEquals(200, resp2.responseCode) + + `when`(sp.getBoolean("communication_http", false)).thenReturn(false) + gp.setupHttpServer(Duration.ofSeconds(10)) + assertThrows(ConnectException::class.java) { + (reqUri2.toURL().openConnection() as HttpURLConnection).responseCode + } + gp.onStop() + + verify(loopHub, times(2)).getGlucoseValues(anyObject(), eq(true)) + verify(loopHub, times(2)).insulinOnboard + verify(loopHub, times(2)).temporaryBasal + verify(loopHub, times(2)).isConnected + verify(loopHub, times(2)).glucoseUnit + } + + @Test + fun setupHttpServer_disabled() { + gp.setupHttpServer(Duration.ofSeconds(10)) + val reqUri = URI("http://127.0.0.1:28890/get") + assertThrows(ConnectException::class.java) { + (reqUri.toURL().openConnection() as HttpURLConnection).responseCode + } + } + + @Test + fun requestHandler_NoKey() { + val uri = createUri(emptyMap()) + val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" } + assertEquals( + HttpURLConnection.HTTP_OK to "OK", + handler(mock(SocketAddress::class.java), uri, null)) + } + + @Test + fun requestHandler_KeyProvided() { + val uri = createUri(mapOf("key" to "foo")) + val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" } + assertEquals( + HttpURLConnection.HTTP_OK to "OK", + handler(mock(SocketAddress::class.java), uri, null)) + } + + @Test + fun requestHandler_KeyRequiredAndProvided() { + `when`(sp.getString("garmin_aaps_key", "")).thenReturn("foo") + val uri = createUri(mapOf("key" to "foo")) + val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" } + assertEquals( + HttpURLConnection.HTTP_OK to "OK", + handler(mock(SocketAddress::class.java), uri, null)) + + } + + @Test + fun requestHandler_KeyRequired() { + gp.garminMessenger = mock(GarminMessenger::class.java) + + `when`(sp.getString("garmin_aaps_key", "")).thenReturn("foo") + val uri = createUri(emptyMap()) + val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" } + assertEquals( + HttpURLConnection.HTTP_UNAUTHORIZED to "{}", + handler(mock(SocketAddress::class.java), uri, null)) + + val captor = ArgumentCaptor.forClass(Any::class.java) + verify(gp.garminMessenger)!!.sendMessage(captor.capture() ?: "") + @Suppress("UNCHECKED_CAST") + val r = captor.value as Map + assertEquals("foo", r["key"]) + assertEquals("glucose", r["command"]) + assertEquals("D", r["profile"]) + assertEquals("", r["encodedGlucose"]) + assertEquals(0.0, r["remainingInsulin"]) + assertEquals("mmoll", r["glucoseUnit"]) + assertEquals(0.0, r["temporaryBasalRate"]) + assertEquals(false, r["connected"]) + assertEquals(clock.instant().epochSecond, r["timestamp"]) + verify(loopHub).getGlucoseValues(getGlucoseValuesFrom, true) + verify(loopHub).insulinOnboard + verify(loopHub).temporaryBasal + verify(loopHub).isConnected + verify(loopHub).glucoseUnit + } + + @Test + fun onConnectDevice() { + gp.garminMessenger = mock(GarminMessenger::class.java) + `when`(sp.getString("garmin_aaps_key", "")).thenReturn("foo") + val device = GarminDevice(mock(),1, "Edge") + gp.onConnectDevice(device) + + val captor = ArgumentCaptor.forClass(Any::class.java) + verify(gp.garminMessenger)!!.sendMessage(eq(device), captor.capture() ?: "") + @Suppress("UNCHECKED_CAST") + val r = captor.value as Map + assertEquals("foo", r["key"]) + assertEquals("glucose", r["command"]) + assertEquals("D", r["profile"]) + assertEquals("", r["encodedGlucose"]) + assertEquals(0.0, r["remainingInsulin"]) + assertEquals("mmoll", r["glucoseUnit"]) + assertEquals(0.0, r["temporaryBasalRate"]) + assertEquals(false, r["connected"]) + assertEquals(clock.instant().epochSecond, r["timestamp"]) + verify(loopHub).getGlucoseValues(getGlucoseValuesFrom, true) + verify(loopHub).insulinOnboard + verify(loopHub).temporaryBasal + verify(loopHub).isConnected + verify(loopHub).glucoseUnit + } + @Test fun testOnGetBloodGlucose() { `when`(loopHub.isConnected).thenReturn(true) @@ -129,7 +291,7 @@ class GarminPluginTest: TestBase() { listOf(createGlucoseValue(Instant.ofEpochSecond(1_000)))) val hr = createHeartRate(99) val uri = createUri(hr) - val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null) + val result = gp.onGetBloodGlucose(uri) assertEquals( "{\"encodedGlucose\":\"0A+6AQ==\"," + "\"remainingInsulin\":3.14," + @@ -161,7 +323,7 @@ class GarminPluginTest: TestBase() { params["wait"] = 10 val uri = createUri(params) gp.newValue = mock(Condition::class.java) - val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null) + val result = gp.onGetBloodGlucose(uri) assertEquals( "{\"encodedGlucose\":\"/wS6AQ==\"," + "\"remainingInsulin\":3.14," + @@ -184,7 +346,7 @@ class GarminPluginTest: TestBase() { @Test fun testOnPostCarbs() { val uri = createUri(mapOf("carbs" to "12")) - assertEquals("", gp.onPostCarbs(mock(SocketAddress::class.java), uri, null)) + assertEquals("", gp.onPostCarbs(uri)) verify(loopHub).postCarbs(12) } @@ -192,7 +354,7 @@ class GarminPluginTest: TestBase() { fun testOnConnectPump_Disconnect() { val uri = createUri(mapOf("disconnectMinutes" to "20")) `when`(loopHub.isConnected).thenReturn(false) - assertEquals("{\"connected\":false}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null)) + assertEquals("{\"connected\":false}", gp.onConnectPump(uri)) verify(loopHub).disconnectPump(20) verify(loopHub).isConnected } @@ -201,8 +363,69 @@ class GarminPluginTest: TestBase() { fun testOnConnectPump_Connect() { val uri = createUri(mapOf("disconnectMinutes" to "0")) `when`(loopHub.isConnected).thenReturn(true) - assertEquals("{\"connected\":true}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null)) + assertEquals("{\"connected\":true}", gp.onConnectPump(uri)) verify(loopHub).connectPump() verify(loopHub).isConnected } + + @Test + fun onSgv_NoGlucose() { + whenever(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL) + whenever(loopHub.getGlucoseValues(any(), eq(false))).thenReturn(emptyList()) + assertEquals("[]", gp.onSgv(createUri(mapOf()))) + verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false) + } + + @Test +fun onSgv_NoDelta() { + whenever(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL) + whenever(loopHub.insulinOnboard).thenReturn(2.7) + whenever(loopHub.insulinBasalOnboard).thenReturn(2.5) + whenever(loopHub.temporaryBasal).thenReturn(0.8) + whenever(loopHub.carbsOnboard).thenReturn(10.7) + whenever(loopHub.getGlucoseValues(any(), eq(false))).thenReturn( + listOf(createGlucoseValue( + clock.instant().minusSeconds(100L), 99.3))) + assertEquals( + """[{"_id":"-900000","device":"RANDOM","deviceString":"1969-12-31T23:58:30Z","sysTime":"1969-12-31T23:58:30Z","unfiltered":90.0,"date":-90000,"sgv":99,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7}]""", + gp.onSgv(createUri(mapOf()))) + verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false) + verify(loopHub).glucoseUnit + } + + @Test + fun onSgv() { + whenever(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL) + whenever(loopHub.insulinOnboard).thenReturn(2.7) + whenever(loopHub.insulinBasalOnboard).thenReturn(2.5) + whenever(loopHub.temporaryBasal).thenReturn(0.8) + whenever(loopHub.carbsOnboard).thenReturn(10.7) + whenever(loopHub.getGlucoseValues(any(), eq(false))).thenAnswer { i -> + val from = i.getArgument(0) + fromClosedRange(from.toEpochMilli(), clock.instant().toEpochMilli(), 300_000L) + .map(Instant::ofEpochMilli) + .mapIndexed { idx, ts -> createGlucoseValue(ts, 100.0+(10 * idx)) }.reversed()} + assertEquals( + """[{"_id":"100000","device":"RANDOM","deviceString":"1970-01-01T00:00:10Z","sysTime":"1970-01-01T00:00:10Z","unfiltered":90.0,"date":10000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7}]""", + gp.onSgv(createUri(mapOf("count" to "1")))) + verify(loopHub).getGlucoseValues( + clock.instant().minusSeconds(600L), false) + + + assertEquals( + """[{"_id":"100000","device":"RANDOM","deviceString":"1970-01-01T00:00:10Z","sysTime":"1970-01-01T00:00:10Z","unfiltered":90.0,"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7},""" + + """{"_id":"-2900000","device":"RANDOM","deviceString":"1969-12-31T23:55:10Z","sysTime":"1969-12-31T23:55:10Z","unfiltered":90.0,"date":-290000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5}]""", + gp.onSgv(createUri(mapOf("count" to "2")))) + verify(loopHub).getGlucoseValues( + clock.instant().minusSeconds(900L), false) + + assertEquals( + """[{"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7},""" + + """{"date":-290000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5}]""", + gp.onSgv(createUri(mapOf("count" to "2", "brief_mode" to "true")))) + verify(loopHub, times(2)).getGlucoseValues( + clock.instant().minusSeconds(900L), false) + + verify(loopHub, atLeastOnce()).glucoseUnit + } } diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSerializerTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSerializerTest.kt new file mode 100644 index 0000000000..fed7e32a28 --- /dev/null +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSerializerTest.kt @@ -0,0 +1,92 @@ +package app.aaps.plugins.sync.garmin + + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import kotlin.test.assertContentEquals + +class GarminSerializerTest { + + @Test fun testSerializeDeserializeString() { + val o = "Hello, world!" + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf( + -85, -51, -85, -51, 0, 0, 0, 16, 0, 14, 72, 101, 108, 108, 111, 44, 32, 119, 111, + 114, 108, 100, 33, 0, -38, 122, -38, 122, 0, 0, 0, 5, 3, 0,0, 0, 0), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + } + + @Test fun testSerializeDeserializeInteger() { + val o = 3 + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf(-38, 122, -38, 122, 0, 0, 0, 5, 1, 0, 0, 0, 3), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + } + + @Test fun tesSerializeDeserializeArray() { + val o = listOf("a", "b", true, 3, 3.4F, listOf(5L, 9), 42) + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf( + -85, -51, -85, -51, 0, 0, 0, 8, 0, 2, 97, 0, 0, 2, 98, 0, -38, 122, -38, 122, 0, 0, + 0, 55, 5, 0, 0, 0, 7, 3, 0, 0, 0, 0, 3, 0, 0, 0, 4, 9, 1, 1, 0, 0, 0, 3, 2, 64, 89, + -103, -102, 5, 0, 0, 0, 2, 1, 0, 0, 0, 42, 14, 0, 0, 0, 0, 0, 0, 0, 5, 14, 0, 0, 0, + 0, 0, 0, 0, 9), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + } + + @Test + fun testSerializeDeserializeMap() { + val o = mapOf("a" to "abc", "c" to 3, "d" to listOf(4, 9, "abc"), true to null) + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf( + -85, -51, -85, -51, 0, 0, 0, 18, 0, 2, 97, 0, 0, 4, 97, 98, 99, 0, 0, 2, 99, 0, 0, + 2, 100, 0, -38, 122, -38, 122, 0, 0, 0, 53, 11, 0, 0, 0, 4, 3, 0, 0, 0, 0, 3, 0, 0, + 0, 4, 3, 0, 0, 0, 10, 1, 0, 0, 0, 3, 3, 0, 0, 0, 14, 5, 0, 0, 0, 3, 9, 1, 0, 1, 0, 0, + 0, 4, 1, 0, 0, 0, 9, 3, 0, 0, 0, 4), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + } + + @Test fun testSerializeDeserializeNull() { + val o = null + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf(-38, 122, -38, 122, 0, 0, 0, 1, 0), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + assertEquals(o, GarminSerializer.deserialize(data)) + } + + @Test fun testSerializeDeserializeAllPrimitiveTypes() { + val o = listOf(1, 1.2F, 1.3, "A", true, 2L, 'X', null) + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf( + -85, -51, -85, -51, 0, 0, 0, 4, 0, 2, 65, 0, -38, 122, -38, 122, 0, 0, 0, 46, 5, 0, + 0, 0, 8, 1, 0, 0, 0, 1, 2, 63, -103, -103, -102, 15, 63, -12, -52, -52, -52, -52, + -52, -51, 3, 0, 0, 0, 0, 9, 1, 14, 0, 0, 0, 0, 0, 0, 0, 2, 19, 0, 0, 0, 88, 0), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + assertEquals(o, GarminSerializer.deserialize(data)) + } + + @Test fun testSerializeDeserializeMapNested() { + val o = mapOf("a" to "abc", "c" to 3, "d" to listOf(4, 9, "abc")) + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf( + -85, -51, -85, -51, 0, 0, 0, 18, 0, 2, 97, 0, 0, 4, 97, 98, 99, 0, 0, 2, 99, 0, 0, + 2, 100, 0, -38, 122, -38, 122, 0, 0, 0, 50, 11, 0, 0, 0, 3, 3, 0, 0, 0, 0, 3, 0, 0, + 0, 4, 3, 0, 0, 0, 10, 1, 0, 0, 0, 3, 3, 0, 0, 0, 14, 5, 0, 0, 0, 3, 1, 0, 0, 0, 4, + 1, 0, 0, 0, 9, 3, 0, 0, 0, 4), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + } +} \ No newline at end of file diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt new file mode 100644 index 0000000000..9243260d07 --- /dev/null +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt @@ -0,0 +1,68 @@ +package app.aaps.plugins.sync.garmin + +import app.aaps.shared.tests.TestBase +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.timeout +import org.mockito.kotlin.verify +import java.net.Inet4Address +import java.net.Socket +import java.time.Duration + +class GarminSimulatorClientTest: TestBase() { + + private lateinit var client: GarminSimulatorClient + private val receiver: GarminReceiver = mock() + + private fun waitForOrFail(c: ()->T?): T { + for (i in 0 until 10) { + c()?.let { return it } + Thread.sleep(1) + } + throw AssertionError("wait timed out") + } + + @BeforeEach + fun setup() { + client = GarminSimulatorClient(aapsLogger, receiver, 0) + } + + @Test + fun receiveMessage() { + val payload = "foo".toByteArray() + assertTrue(client.awaitReady(Duration.ofSeconds(10))) + verify(receiver, timeout(100)).onConnect(client) + val port = client.port + val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1)) + Socket(ip, port).use { socket -> + assertTrue(socket.isConnected) + socket.getOutputStream().write(payload) + socket.getOutputStream().flush() + val device = waitForOrFail { client.connectedDevices.firstOrNull() } + verify(receiver, timeout(1_000)) + .onReceiveMessage(eq(client), eq(device.id), eq("SIMAPP"), eq(payload)) + } + } + + @Test + fun sendMessage() { + val payload = "foo".toByteArray() + assertTrue(client.awaitReady(Duration.ofSeconds(10))) + verify(receiver, timeout(100)).onConnect(client) + val port = client.port + val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1)) + val device: GarminDevice + val app: GarminApplication + Socket(ip, port).use { socket -> + assertTrue(socket.isConnected) + device = waitForOrFail { client.connectedDevices.firstOrNull() } + app = GarminApplication(device, "SIMAPP", "T") + client.sendMessage(app, payload) + } + verify(receiver, timeout(1_000)).onSendMessage(eq(client), eq(device.id), eq(app.id), isNull()) + } +} \ No newline at end of file diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/HttpServerTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/HttpServerTest.kt index d89dfa9156..cfaa34b1d6 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/HttpServerTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/HttpServerTest.kt @@ -77,7 +77,7 @@ internal class HttpServerTest: TestBase() { HttpServer(aapsLogger, port).use { server -> server.registerEndpoint("/foo") { _: SocketAddress, uri: URI, _: String? -> assertEquals(URI("/foo"), uri) - "test" + HttpURLConnection.HTTP_OK to "test" } assertTrue(server.awaitReady(Duration.ofSeconds(10))) val resp = reqUri.toURL().openConnection() as HttpURLConnection diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/LoopHubTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/LoopHubTest.kt index a88facfa05..c96bcaee4b 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/LoopHubTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/LoopHubTest.kt @@ -5,6 +5,7 @@ import app.aaps.core.interfaces.aps.Loop import app.aaps.core.interfaces.constraints.Constraint import app.aaps.core.interfaces.constraints.ConstraintsChecker import app.aaps.core.interfaces.db.GlucoseUnit +import app.aaps.core.interfaces.iob.CobInfo import app.aaps.core.interfaces.iob.IobCobCalculator import app.aaps.core.interfaces.iob.IobTotal import app.aaps.core.interfaces.logging.UserEntryLogger @@ -13,6 +14,7 @@ import app.aaps.core.interfaces.profile.ProfileFunction import app.aaps.core.interfaces.pump.DetailedBolusInfo import app.aaps.core.interfaces.queue.CommandQueue import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.main.graph.OverviewData import app.aaps.database.ValueWrapper import app.aaps.database.entities.EffectiveProfileSwitch import app.aaps.database.entities.GlucoseValue @@ -54,6 +56,7 @@ class LoopHubTest: TestBase() { @Mock lateinit var repo: AppRepository @Mock lateinit var userEntryLogger: UserEntryLogger @Mock lateinit var sp: SP + @Mock lateinit var overviewData: OverviewData private lateinit var loopHub: LoopHubImpl private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC")) @@ -62,7 +65,7 @@ class LoopHubTest: TestBase() { fun setup() { loopHub = LoopHubImpl( aapsLogger, commandQueue, constraints, iobCobCalculator, loop, - profileFunction, repo, userEntryLogger, sp + profileFunction, repo, userEntryLogger, sp, overviewData ) loopHub.clock = clock } @@ -76,9 +79,10 @@ class LoopHubTest: TestBase() { verifyNoMoreInteractions(profileFunction) verifyNoMoreInteractions(repo) verifyNoMoreInteractions(userEntryLogger) + verifyNoMoreInteractions(overviewData) } - @Test +@Test fun testCurrentProfile() { val profile = mock(Profile::class.java) `when`(profileFunction.getProfile()).thenReturn(profile) @@ -109,6 +113,22 @@ class LoopHubTest: TestBase() { verify(iobCobCalculator, times(1)).calculateIobFromBolus() } + @Test + fun testBasalOnBoard() { + val iobBasal = IobTotal(time = 0).apply { basaliob = 23.9 } + `when`(iobCobCalculator.calculateIobFromTempBasalsIncludingConvertedExtended()).thenReturn(iobBasal) + assertEquals(23.9, loopHub.insulinBasalOnboard, 1e-10) + verify(iobCobCalculator, times(1)).calculateIobFromTempBasalsIncludingConvertedExtended() + } + + @Test + fun testCarbsOnBoard() { + val cobInfo = CobInfo(0, 12.0, 0.0) + `when`(overviewData.cobInfo(iobCobCalculator)).thenReturn(cobInfo) + assertEquals(12.0, loopHub.carbsOnboard) + verify(overviewData, times(1)).cobInfo(iobCobCalculator) + } + @Test fun testIsConnected() { `when`(loop.isDisconnected).thenReturn(false) @@ -247,4 +267,4 @@ class LoopHubTest: TestBase() { samplingStart, samplingEnd, 101, "Test Device") verify(repo).runTransaction(InsertOrUpdateHeartRateTransaction(hr)) } -} \ No newline at end of file +} diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/dataBroadcaster/DataBroadcastPluginTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/tizen/TizenPluginTest.kt similarity index 97% rename from plugins/sync/src/test/kotlin/app/aaps/plugins/sync/dataBroadcaster/DataBroadcastPluginTest.kt rename to plugins/sync/src/test/kotlin/app/aaps/plugins/sync/tizen/TizenPluginTest.kt index 650eaa7bb5..fc42eba307 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/dataBroadcaster/DataBroadcastPluginTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/tizen/TizenPluginTest.kt @@ -1,4 +1,4 @@ -package app.aaps.plugins.sync.dataBroadcaster +package app.aaps.plugins.sync.tizen import app.aaps.core.interfaces.aps.AutosensDataStore import app.aaps.core.interfaces.aps.Loop @@ -27,7 +27,7 @@ import org.mockito.ArgumentMatchers.anyLong import org.mockito.Mock import org.mockito.Mockito -internal class DataBroadcastPluginTest : TestBaseWithProfile() { +internal class TizenPluginTest : TestBaseWithProfile() { @Mock lateinit var defaultValueHelper: DefaultValueHelper @Mock lateinit var loop: Loop @@ -36,13 +36,13 @@ internal class DataBroadcastPluginTest : TestBaseWithProfile() { @Mock lateinit var autosensDataStore: AutosensDataStore @Mock lateinit var processedDeviceStatusData: ProcessedDeviceStatusData - private lateinit var sut: DataBroadcastPlugin + private lateinit var sut: TizenPlugin private val injector = HasAndroidInjector { AndroidInjector { } } @BeforeEach fun setUp() { - sut = DataBroadcastPlugin( + sut = TizenPlugin( injector, aapsLogger, rh, aapsSchedulers, context, dateUtil, fabricPrivacy, rxBus, iobCobCalculator, profileFunction, defaultValueHelper, processedDeviceStatusData, loop, activePlugin, receiverStatusStore, config, glucoseStatusProvider, decimalFormatter ) diff --git a/pump/combo/src/main/res/values-ru-rRU/strings.xml b/pump/combo/src/main/res/values-ru-rRU/strings.xml index a0e9709c2a..77f6a643c0 100644 --- a/pump/combo/src/main/res/values-ru-rRU/strings.xml +++ b/pump/combo/src/main/res/values-ru-rRU/strings.xml @@ -28,23 +28,23 @@ Нормальный Необходимо обновить часы помпы Предупреждение об отмене скорости временного базала подтверждено - Не удалось подключиться к помпе. Болюс не подан - Подача болюса не состоялась. Чтобы удостовериться, проверьте помпу во избежание двойного болюса и повторите подачу. Для защиты от ложных срабатываний болюсы не повторяются автоматически. - Подано только %1$.2f ед. из запрошенного болюса %2$.2f ед. из-за ошибки. Пожалуйста, проверьте помпу, чтобы удостовериться в этом и принять соответствующие меры. - Подача болюса и проверка истории помпы не состоялись, пожалуйста проверьте помпу. Если болюс был подан, он будет добавлен в назначения во время следующего соединения с помпой. + Не удалось подключиться к помпе. Болюс не введен + Болюс не введен. Чтобы удостовериться, проверьте помпу во избежание двойного болюса и повторите подачу. Для защиты от ложных срабатываний болюсы не повторяются автоматически. + Введено только %1$.2f ед. из запрошенного болюса %2$.2f ед. из-за ошибки. Пожалуйста, проверьте помпу, чтобы удостовериться в этом и принять соответствующие меры. + Ввод болюса и проверка истории помпы не состоялись, пожалуйста проверьте помпу. Если болюс был введен, он будет добавлен в назначения во время следующего соединения с помпой. В резервуаре недостаточно инсулина для болюса Недопустимые установки помпы, проверьте документацию и убедитесь, что меню Quick Info называется QUICK INFO, используя приложение 360 для конфигурации помпы. Чтение базального профиля - История событий помпы изменилась с момента вычисления болюса. Болюс не подан. Пожалуйста пересчитайте потребность в болюсе. - Болюс подан успешно, но запись о назначении не сделана. Это может быть вызвано тем, что за последние две минуты назначено два болюса одного объема. Пожалуйста проверьте историю событий помпы и записи о назначениях и добавьте пропущенные записи из Портала назначений. Не добавляйте записи с одним и тем же временем и одинаковым объемом. + История событий помпы изменилась с момента вычисления болюса. Болюс не введен. Пожалуйста пересчитайте потребность в болюсе. + Болюс введен успешно, но запись о назначении не сделана. Это может быть вызвано тем, что за последние две минуты назначено два болюса одного объема. Пожалуйста проверьте историю событий помпы и записи о назначениях и добавьте пропущенные записи из Портала назначений. Не добавляйте записи с одним и тем же временем и одинаковым объемом. Временная верхняя цель отклонена т. к. калькуляция не учитывала недавние изменения в истории событий помпы Обновление статуса помпы Скорость базала на помпе изменилась и вскоре будет обновлена Скорость базала на помпе изменилась, но учесть ее не удалось Проверка изменений в истории событий помпы Только что импортировано несколько болюсов с одинаковым количеством инсулина в течение одной минуты. В лог лечения может быть добавлена только одна запись. Пожалуйста проверьте помпу и вручную введите запись о болюсе через вкладку портала назначений. Убедитесь, что данному времени соответствует только одна запись о болюсе. - Новый болюс старше 24 часов или запись относится к будущему. Пожалуйста убедитесь что дата на помпе установлена правильно. - Время/дата поданного болюса неверны. Вероятно, кол-во активного инсулина IOB также неверно. Проверьте время/дату помпы. + Новый болюс старше 24 часов или запись относится к будущему. Убедитесь что дата на помпе установлена правильно. + Время/дата болюса неверны. Вероятно, кол-во активного инсулина IOB также неверно. Проверьте время/дату помпы. Отсчет болюса Отсчет временного базала TBR Болюс остановлен diff --git a/pump/medtronic/src/main/java/info/nightscout/androidaps/plugins/pump/medtronic/comm/history/pump/MedtronicPumpHistoryDecoder.kt b/pump/medtronic/src/main/java/info/nightscout/androidaps/plugins/pump/medtronic/comm/history/pump/MedtronicPumpHistoryDecoder.kt index 7ea2c36e19..606d1058e3 100644 --- a/pump/medtronic/src/main/java/info/nightscout/androidaps/plugins/pump/medtronic/comm/history/pump/MedtronicPumpHistoryDecoder.kt +++ b/pump/medtronic/src/main/java/info/nightscout/androidaps/plugins/pump/medtronic/comm/history/pump/MedtronicPumpHistoryDecoder.kt @@ -153,7 +153,6 @@ class MedtronicPumpHistoryDecoder @Inject constructor( PumpHistoryEntryType.ClearAlarm, PumpHistoryEntryType.ChangeAlarmNotifyMode, PumpHistoryEntryType.EnableDisableRemote, - PumpHistoryEntryType.BGReceived, PumpHistoryEntryType.SensorAlert, PumpHistoryEntryType.ChangeTimeFormat, PumpHistoryEntryType.ChangeReservoirWarningTime, @@ -188,7 +187,6 @@ class MedtronicPumpHistoryDecoder @Inject constructor( PumpHistoryEntryType.ChangeWatchdogEnable, PumpHistoryEntryType.ChangeOtherDeviceID, PumpHistoryEntryType.ReadOtherDevicesIDs, - PumpHistoryEntryType.BGReceived512, PumpHistoryEntryType.SensorStatus, PumpHistoryEntryType.ReadCaptureEventEnabled, PumpHistoryEntryType.ChangeCaptureEventEnable, @@ -206,6 +204,12 @@ class MedtronicPumpHistoryDecoder @Inject constructor( PumpHistoryEntryType.UnabsorbedInsulin, PumpHistoryEntryType.UnabsorbedInsulin512 -> RecordDecodeStatus.Ignored + PumpHistoryEntryType.BGReceived, + PumpHistoryEntryType.BGReceived512 -> { + decodeBgReceived(entry) + RecordDecodeStatus.OK + } + PumpHistoryEntryType.DailyTotals522, PumpHistoryEntryType.DailyTotals523, PumpHistoryEntryType.DailyTotals515, @@ -297,7 +301,9 @@ class MedtronicPumpHistoryDecoder @Inject constructor( } private fun decodeBatteryActivity(entry: PumpHistoryEntry) { - entry.displayableValue = if (entry.head[0] == 0.toByte()) "Battery Removed" else "Battery Replaced" + val isRemoved = entry.head[0] == 0.toByte() + entry.addDecodedData("isRemoved", isRemoved) + entry.displayableValue = if (isRemoved) "Battery Removed" else "Battery Replaced" } private fun decodeBasalProfileStart(entry: PumpHistoryEntry): RecordDecodeStatus { @@ -407,8 +413,11 @@ class MedtronicPumpHistoryDecoder @Inject constructor( } private fun decodeBgReceived(entry: PumpHistoryEntry) { - entry.addDecodedData("amount", (ByteUtil.asUINT8(entry.getRawDataByIndex(0)) shl 3) + (ByteUtil.asUINT8(entry.getRawDataByIndex(3)) shr 5)) - entry.addDecodedData("meter", ByteUtil.substring(entry.rawData, 6, 3)) // index moved from 1 -> 0 + val glucoseMgdl = (ByteUtil.asUINT8(entry.head[0]) shl 3) + (ByteUtil.asUINT8(entry.datetime[2]) shr 5) + val meterSerial = ByteUtil.shortHexStringWithoutSpaces(entry.body) + entry.addDecodedData("GlucoseMgdl", glucoseMgdl) + entry.addDecodedData("MeterSerial", meterSerial) + entry.displayableValue = String.format("Glucose: %d mg/dl, Meter Serial: %s", glucoseMgdl, meterSerial) } private fun decodeCalBGForPH(entry: PumpHistoryEntry) { diff --git a/pump/medtronic/src/main/java/info/nightscout/androidaps/plugins/pump/medtronic/data/MedtronicHistoryData.kt b/pump/medtronic/src/main/java/info/nightscout/androidaps/plugins/pump/medtronic/data/MedtronicHistoryData.kt index 27d052bcca..03bf6c49c3 100644 --- a/pump/medtronic/src/main/java/info/nightscout/androidaps/plugins/pump/medtronic/data/MedtronicHistoryData.kt +++ b/pump/medtronic/src/main/java/info/nightscout/androidaps/plugins/pump/medtronic/data/MedtronicHistoryData.kt @@ -4,6 +4,7 @@ import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag import app.aaps.core.interfaces.notifications.Notification import app.aaps.core.interfaces.plugin.ActivePlugin +import app.aaps.core.interfaces.profile.ProfileUtil import app.aaps.core.interfaces.pump.DetailedBolusInfo import app.aaps.core.interfaces.pump.PumpSync import app.aaps.core.interfaces.pump.defs.PumpType @@ -67,7 +68,8 @@ class MedtronicHistoryData @Inject constructor( val medtronicPumpStatus: MedtronicPumpStatus, private val pumpSync: PumpSync, private val pumpSyncStorage: PumpSyncStorage, - private val uiInteraction: UiInteraction + private val uiInteraction: UiInteraction, + private val profileUtil: ProfileUtil ) { val allHistory: MutableList = mutableListOf() @@ -322,6 +324,17 @@ class MedtronicHistoryData @Inject constructor( * Process History Data: Boluses(Treatments), TDD, TBRs, Suspend-Resume (or other pump stops: battery, prime) */ fun processNewHistoryData() { + // Finger BG (for adding entry to careportal) + val bgRecords: MutableList = getFilteredItems(setOf(PumpHistoryEntryType.BGReceived, PumpHistoryEntryType.BGReceived512)) + aapsLogger.debug(LTag.PUMP, String.format(Locale.ENGLISH, "ProcessHistoryData: BGReceived [count=%d, items=%s]", bgRecords.size, gson.toJson(bgRecords))) + if (isCollectionNotEmpty(bgRecords)) { + try { + processBgReceived(bgRecords) + } catch (ex: Exception) { + aapsLogger.error(LTag.PUMP, "ProcessHistoryData: Error processing BGReceived entries: " + ex.message, ex) + throw ex + } + } // Prime (for resetting autosense) val primeRecords: MutableList = getFilteredItems(PumpHistoryEntryType.Prime) @@ -347,6 +360,18 @@ class MedtronicHistoryData @Inject constructor( } } + // BatteryChange + val batteryChangeRecords: MutableList = getFilteredItems(PumpHistoryEntryType.BatteryChange) + aapsLogger.debug(LTag.PUMP, String.format(Locale.ENGLISH, "ProcessHistoryData: BatteryChange [count=%d, items=%s]", batteryChangeRecords.size, gson.toJson(batteryChangeRecords))) + if (isCollectionNotEmpty(batteryChangeRecords)) { + try { + processBatteryChange(batteryChangeRecords) + } catch (ex: Exception) { + aapsLogger.error(LTag.PUMP, "ProcessHistoryData: Error processing BatteryChange entries: " + ex.message, ex) + throw ex + } + } + // TDD val tdds: MutableList = getFilteredItems(setOf(PumpHistoryEntryType.EndResultTotals, getTDDType())) aapsLogger.debug(LTag.PUMP, String.format(Locale.ENGLISH, "ProcessHistoryData: TDD [count=%d, items=%s]", tdds.size, gson.toJson(tdds))) @@ -407,6 +432,34 @@ class MedtronicHistoryData @Inject constructor( } } + fun processBgReceived(bgRecords: List) { + for (bgRecord in bgRecords) { + val glucoseMgdl = bgRecord.getDecodedDataEntry("GlucoseMgdl") + if (glucoseMgdl == null || glucoseMgdl as Int == 0) { + continue + } + + val glucose = profileUtil.fromMgdlToUnits(glucoseMgdl.toDouble()) + val glucoseUnit = profileUtil.units + + val result = pumpSync.insertFingerBgIfNewWithTimestamp( + DateTimeUtil.toMillisFromATD(bgRecord.atechDateTime), + glucose, glucoseUnit, null, + bgRecord.pumpId, + medtronicPumpStatus.pumpType, + medtronicPumpStatus.serialNumber + ) + + aapsLogger.debug( + LTag.PUMP, String.format( + Locale.ROOT, "insertFingerBgIfNewWithTimestamp [date=%d, glucose=%f, glucoseUnit=%s, pumpId=%d, pumpSerial=%s] - Result: %b", + bgRecord.atechDateTime, glucose, glucoseUnit, bgRecord.pumpId, + medtronicPumpStatus.serialNumber, result + ) + ) + } + } + private fun processPrime(primeRecords: List) { val maxAllowedTimeInPast = DateTimeUtil.getATDWithAddedMinutes(GregorianCalendar(), -30) var lastPrimeRecordTime = 0L @@ -456,6 +509,35 @@ class MedtronicHistoryData @Inject constructor( } } + private fun processBatteryChange(batteryChangeRecords: List) { + val maxAllowedTimeInPast = DateTimeUtil.getATDWithAddedMinutes(GregorianCalendar(), -120) + var lastBatteryChangeRecordTime = 0L + var lastBatteryChangeRecord: PumpHistoryEntry? = null + for (batteryChangeRecord in batteryChangeRecords) { + val isRemoved = batteryChangeRecord.getDecodedDataEntry("isRemoved") + + if (isRemoved != null && isRemoved as Boolean) + { + // we're interested in battery replacements, not battery removals + continue + } + + if (batteryChangeRecord.atechDateTime > maxAllowedTimeInPast) { + if (lastBatteryChangeRecordTime < batteryChangeRecord.atechDateTime) { + lastBatteryChangeRecordTime = batteryChangeRecord.atechDateTime + lastBatteryChangeRecord = batteryChangeRecord + } + } + } + if (lastBatteryChangeRecord != null) { + uploadCareportalEventIfFoundInHistory( + lastBatteryChangeRecord, + MedtronicConst.Statistics.LastBatteryChange, + DetailedBolusInfo.EventType.PUMP_BATTERY_CHANGE + ) + } + } + private fun uploadCareportalEventIfFoundInHistory(historyRecord: PumpHistoryEntry, eventSP: String, eventType: DetailedBolusInfo.EventType) { val lastPrimeFromAAPS = sp.getLong(eventSP, 0L) if (historyRecord.atechDateTime != lastPrimeFromAAPS) { diff --git a/pump/medtronic/src/main/java/info/nightscout/androidaps/plugins/pump/medtronic/util/MedtronicConst.kt b/pump/medtronic/src/main/java/info/nightscout/androidaps/plugins/pump/medtronic/util/MedtronicConst.kt index 8d86bce696..03bc600aa6 100644 --- a/pump/medtronic/src/main/java/info/nightscout/androidaps/plugins/pump/medtronic/util/MedtronicConst.kt +++ b/pump/medtronic/src/main/java/info/nightscout/androidaps/plugins/pump/medtronic/util/MedtronicConst.kt @@ -30,5 +30,6 @@ object MedtronicConst { const val LastPumpHistoryEntry = StatsPrefix + "pump_history_entry" const val LastPrime = StatsPrefix + "last_sent_prime" const val LastRewind = StatsPrefix + "last_sent_rewind" + const val LastBatteryChange = StatsPrefix + "last_sent_battery_change" } } \ No newline at end of file diff --git a/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/MedtronicTestBase.kt b/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/MedtronicTestBase.kt index 03cfe9d431..0f25f4cd98 100644 --- a/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/MedtronicTestBase.kt +++ b/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/MedtronicTestBase.kt @@ -4,7 +4,7 @@ import app.aaps.core.interfaces.plugin.ActivePlugin import app.aaps.core.interfaces.pump.PumpSync import app.aaps.core.interfaces.resources.ResourceHelper import app.aaps.core.interfaces.sharedPreferences.SP -import app.aaps.shared.tests.TestBase +import app.aaps.shared.tests.TestBaseWithProfile import dagger.android.AndroidInjector import dagger.android.HasAndroidInjector import info.nightscout.androidaps.plugins.pump.common.hw.rileylink.RileyLinkUtil @@ -16,15 +16,13 @@ import info.nightscout.pump.common.sync.PumpSyncStorage import org.mockito.Answers import org.mockito.Mock -open class MedtronicTestBase : TestBase() { +open class MedtronicTestBase : TestBaseWithProfile() { var rileyLinkUtil = RileyLinkUtil() @Mock lateinit var pumpSync: PumpSync @Mock lateinit var pumpSyncStorage: PumpSyncStorage - @Mock(answer = Answers.RETURNS_DEEP_STUBS) lateinit var activePlugin: ActivePlugin - @Mock lateinit var sp: SP - @Mock lateinit var rh: ResourceHelper + @Mock(answer = Answers.RETURNS_DEEP_STUBS) override lateinit var activePlugin: ActivePlugin lateinit var medtronicUtil: MedtronicUtil lateinit var decoder: MedtronicPumpHistoryDecoder @@ -53,6 +51,24 @@ open class MedtronicTestBase : TestBase() { } + fun getPumpHistoryEntryFromData(vararg elements: Int): PumpHistoryEntry { + val data: MutableList = ArrayList() + for (item in elements) { + var b = if (item > 128) item - 256 else item + data.add(b.toByte()); + } + + val entryType = PumpHistoryEntryType.getByCode(data[0]) + + val phe = PumpHistoryEntry() + phe.setEntryType(medtronicUtil.medtronicPumpModel, entryType) + phe.setData(data, false) + + decoder.decodeRecord(phe) + + return phe + } + private fun preProcessTBRs(tbrsInput: MutableList): MutableList { val tbrs: MutableList = mutableListOf() val map: MutableMap = HashMap() diff --git a/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/comm/MedtronicHistoryDataUTest.kt b/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/comm/MedtronicHistoryDataUTest.kt index 70bcd924e4..13a53c78f7 100644 --- a/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/comm/MedtronicHistoryDataUTest.kt +++ b/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/comm/MedtronicHistoryDataUTest.kt @@ -40,7 +40,7 @@ import org.mockito.Mock decoder = MedtronicPumpHistoryDecoder(aapsLogger, medtronicUtil) medtronicHistoryData = MedtronicHistoryData( packetInjector, aapsLogger, sp, rh, rxBus, activePlugin, - medtronicUtil, decoder, medtronicPumpStatus, pumpSync, pumpSyncStorage, uiInteraction + medtronicUtil, decoder, medtronicPumpStatus, pumpSync, pumpSyncStorage, uiInteraction, profileUtil ) diff --git a/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/comm/history/pump/PumpHistoryEntryUTest.kt b/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/comm/history/pump/PumpHistoryEntryUTest.kt index 8b07c7dc2c..2743439726 100644 --- a/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/comm/history/pump/PumpHistoryEntryUTest.kt +++ b/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/comm/history/pump/PumpHistoryEntryUTest.kt @@ -1,8 +1,16 @@ package info.nightscout.androidaps.plugins.pump.medtronic.comm.history.pump +import app.aaps.core.interfaces.ui.UiInteraction import info.nightscout.androidaps.plugins.pump.medtronic.MedtronicTestBase +import info.nightscout.androidaps.plugins.pump.medtronic.comm.history.pump.MedtronicPumpHistoryDecoder +import info.nightscout.androidaps.plugins.pump.medtronic.defs.MedtronicDeviceType +import info.nightscout.androidaps.plugins.pump.medtronic.driver.MedtronicPumpStatus +import info.nightscout.androidaps.plugins.pump.medtronic.util.MedtronicUtil import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.mockito.Mock +import org.mockito.Mockito.`when` /** * Created by andy on 4/9/19. @@ -10,6 +18,16 @@ import org.junit.jupiter.api.Test */ class PumpHistoryEntryUTest : MedtronicTestBase() { + @Mock lateinit var medtronicPumpStatus: MedtronicPumpStatus + @Mock lateinit var uiInteraction: UiInteraction + + @BeforeEach + fun setUp() { + medtronicUtil = MedtronicUtil(aapsLogger, rxBus, rileyLinkUtil, medtronicPumpStatus, uiInteraction) + `when`(medtronicUtil.medtronicPumpModel).thenReturn(MedtronicDeviceType.Medtronic_723_Revel) + decoder = MedtronicPumpHistoryDecoder(aapsLogger, medtronicUtil) + } + @Test fun checkIsAfter() { val dateObject = 20191010000000L @@ -18,4 +36,21 @@ class PumpHistoryEntryUTest : MedtronicTestBase() { phe.atechDateTime = dateObject assertThat(phe.isAfter(queryObject)).isTrue() } + + @Test + fun decodeBgReceived() { + val bgRecord = getPumpHistoryEntryFromData( + // head + 0x39, 0x15, + // datetime (combined with glucose in mg/dl) + 0xC2, 0x25, 0xF3, 0x61, 0x17, + // serial number + 0x12, 0x34, 0x56 + ) + val expectedGlucoseMgdl = 175 + val expectedMeterSerial = "123456" + + assertThat(bgRecord.getDecodedDataEntry("GlucoseMgdl")).isEqualTo(expectedGlucoseMgdl) + assertThat(bgRecord.getDecodedDataEntry("MeterSerial")).isEqualTo(expectedMeterSerial) + } } diff --git a/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/data/MedtronicHistoryDataUTest.kt b/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/data/MedtronicHistoryDataUTest.kt index d36c40d1ba..2a91afaba4 100644 --- a/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/data/MedtronicHistoryDataUTest.kt +++ b/pump/medtronic/src/test/java/info/nightscout/androidaps/plugins/pump/medtronic/data/MedtronicHistoryDataUTest.kt @@ -1,18 +1,24 @@ package info.nightscout.androidaps.plugins.pump.medtronic.data +import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.ui.UiInteraction +import app.aaps.core.utils.DateTimeUtil import com.google.gson.Gson import com.google.gson.internal.LinkedTreeMap import com.google.gson.reflect.TypeToken import info.nightscout.androidaps.plugins.pump.medtronic.MedtronicTestBase import info.nightscout.androidaps.plugins.pump.medtronic.comm.history.pump.MedtronicPumpHistoryDecoder import info.nightscout.androidaps.plugins.pump.medtronic.comm.history.pump.PumpHistoryEntry +import info.nightscout.androidaps.plugins.pump.medtronic.comm.history.pump.PumpHistoryEntryType import info.nightscout.androidaps.plugins.pump.medtronic.data.dto.TempBasalPair +import info.nightscout.androidaps.plugins.pump.medtronic.defs.MedtronicDeviceType import info.nightscout.androidaps.plugins.pump.medtronic.driver.MedtronicPumpStatus import info.nightscout.androidaps.plugins.pump.medtronic.util.MedtronicUtil import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` import java.lang.reflect.Type @Suppress("UNCHECKED_CAST") @@ -24,6 +30,7 @@ class MedtronicHistoryDataUTest : MedtronicTestBase() { @BeforeEach fun setUp() { medtronicUtil = MedtronicUtil(aapsLogger, rxBus, rileyLinkUtil, medtronicPumpStatus, uiInteraction) + `when`(medtronicUtil.medtronicPumpModel).thenReturn(MedtronicDeviceType.Medtronic_723_Revel) decoder = MedtronicPumpHistoryDecoder(aapsLogger, medtronicUtil) } @@ -32,7 +39,7 @@ class MedtronicHistoryDataUTest : MedtronicTestBase() { val unitToTest = MedtronicHistoryData( packetInjector, aapsLogger, sp, rh, rxBus, activePlugin, - medtronicUtil, decoder, medtronicPumpStatus, pumpSync, pumpSyncStorage, uiInteraction + medtronicUtil, decoder, medtronicPumpStatus, pumpSync, pumpSyncStorage, uiInteraction, profileUtil ) val gson = Gson() @@ -75,7 +82,7 @@ class MedtronicHistoryDataUTest : MedtronicTestBase() { medtronicUtil, decoder, medtronicPumpStatus, pumpSync, - pumpSyncStorage, uiInteraction + pumpSyncStorage, uiInteraction, profileUtil ) val gson = Gson() @@ -110,4 +117,70 @@ class MedtronicHistoryDataUTest : MedtronicTestBase() { } + @Test + fun processBgReceived_WithMgdl() { + + val unitToTest = MedtronicHistoryData( + packetInjector, aapsLogger, sp, rh, rxBus, activePlugin, + medtronicUtil, decoder, + medtronicPumpStatus, + pumpSync, + pumpSyncStorage, uiInteraction, profileUtil + ) + + val glucoseMgdl = 175 + + `when`(sp.getString(app.aaps.core.utils.R.string.key_units, GlucoseUnit.MGDL.asText)).thenReturn(GlucoseUnit.MGDL.asText) + + val bgRecord = PumpHistoryEntry() + bgRecord.setEntryType(medtronicUtil.medtronicPumpModel, PumpHistoryEntryType.BGReceived) + bgRecord.addDecodedData("GlucoseMgdl", glucoseMgdl) + bgRecord.addDecodedData("MeterSerial", "123456") + + unitToTest.processBgReceived(listOf(bgRecord)) + + verify(pumpSync).insertFingerBgIfNewWithTimestamp( + DateTimeUtil.toMillisFromATD(bgRecord.atechDateTime), + glucoseMgdl.toDouble(), + GlucoseUnit.MGDL, null, + bgRecord.pumpId, + medtronicPumpStatus.pumpType, + medtronicPumpStatus.serialNumber + ) + + } + + @Test + fun processBgReceived_WithMmol() { + + val unitToTest = MedtronicHistoryData( + packetInjector, aapsLogger, sp, rh, rxBus, activePlugin, + medtronicUtil, decoder, + medtronicPumpStatus, + pumpSync, + pumpSyncStorage, uiInteraction, profileUtil + ) + val glucoseMgdl = 180 + val glucoseMmol = 10.0 + + `when`(sp.getString(app.aaps.core.utils.R.string.key_units, GlucoseUnit.MGDL.asText)).thenReturn(GlucoseUnit.MMOL.asText) + + val bgRecord = PumpHistoryEntry() + bgRecord.setEntryType(medtronicUtil.medtronicPumpModel, PumpHistoryEntryType.BGReceived) + bgRecord.addDecodedData("GlucoseMgdl", glucoseMgdl) + bgRecord.addDecodedData("MeterSerial", "123456") + + unitToTest.processBgReceived(listOf(bgRecord)) + + verify(pumpSync).insertFingerBgIfNewWithTimestamp( + DateTimeUtil.toMillisFromATD(bgRecord.atechDateTime), + glucoseMmol, + GlucoseUnit.MMOL, null, + bgRecord.pumpId, + medtronicPumpStatus.pumpType, + medtronicPumpStatus.serialNumber + ) + + } + } diff --git a/pump/omnipod-common/src/main/res/values-cs-rCZ/strings.xml b/pump/omnipod-common/src/main/res/values-cs-rCZ/strings.xml index 12261e0feb..056fffdff3 100644 --- a/pump/omnipod-common/src/main/res/values-cs-rCZ/strings.xml +++ b/pump/omnipod-common/src/main/res/values-cs-rCZ/strings.xml @@ -124,6 +124,7 @@ Probíhá nastavování (čeká se na aktivaci Podu) Probíhá nastavování (čeká se na aplikaci kanyly) Běží + Normální Pozastaveno Chyba Podu Byl překročen čas aktivace diff --git a/pump/omnipod-common/src/main/res/values-es-rES/strings.xml b/pump/omnipod-common/src/main/res/values-es-rES/strings.xml index 06aa635503..9ac89e5d5b 100644 --- a/pump/omnipod-common/src/main/res/values-es-rES/strings.xml +++ b/pump/omnipod-common/src/main/res/values-es-rES/strings.xml @@ -125,6 +125,7 @@ Configuración en curso (esperando para activar el Pod) Configuración en curso (en espera de inserción de cánula) Funcionando + Normal Suspendido Error del Pod Tiempo de activación excedido diff --git a/pump/omnipod-common/src/main/res/values-fr-rFR/strings.xml b/pump/omnipod-common/src/main/res/values-fr-rFR/strings.xml index 5582068512..de3cc9fd83 100644 --- a/pump/omnipod-common/src/main/res/values-fr-rFR/strings.xml +++ b/pump/omnipod-common/src/main/res/values-fr-rFR/strings.xml @@ -53,7 +53,7 @@ ID unique Numéro de lot Numéro de série - Pod Expiré + Pod expire le Dernière connexion Dernier bolus Débit de Basal Temp. diff --git a/pump/omnipod-common/src/main/res/values-iw-rIL/strings.xml b/pump/omnipod-common/src/main/res/values-iw-rIL/strings.xml index 6f0687373e..7f2172f527 100644 --- a/pump/omnipod-common/src/main/res/values-iw-rIL/strings.xml +++ b/pump/omnipod-common/src/main/res/values-iw-rIL/strings.xml @@ -124,6 +124,7 @@ התקנה בביצוע (ממתין להפעלת הפוד) התקנה בביצוע (ממתין להכנסת הפרפרית) פועל + נורמלי מושהה תקלה בפוד חריגת זמן הפעלה diff --git a/pump/omnipod-common/src/main/res/values-nb-rNO/strings.xml b/pump/omnipod-common/src/main/res/values-nb-rNO/strings.xml index 121dfd1331..72339fdab6 100644 --- a/pump/omnipod-common/src/main/res/values-nb-rNO/strings.xml +++ b/pump/omnipod-common/src/main/res/values-nb-rNO/strings.xml @@ -124,6 +124,7 @@ Oppsett pågår (venter på Pod-aktivering) Oppsett pågår (venter på innsetting av kanyle) Kjører + Normal Pauset Pod-feil Aktiveringstiden er overskredet diff --git a/pump/omnipod-common/src/main/res/values-nl-rNL/strings.xml b/pump/omnipod-common/src/main/res/values-nl-rNL/strings.xml index 8502534c17..580524dd47 100644 --- a/pump/omnipod-common/src/main/res/values-nl-rNL/strings.xml +++ b/pump/omnipod-common/src/main/res/values-nl-rNL/strings.xml @@ -124,6 +124,7 @@ Setup wordt uitgevoerd (in afwachting van Pod activering) Setup wordt uitgevoerd (in afwachting van het inbrengen van de canule) Actief + Normaal Onderbroken Pod fout Activatie tijd verlopen diff --git a/pump/omnipod-common/src/main/res/values-pl-rPL/strings.xml b/pump/omnipod-common/src/main/res/values-pl-rPL/strings.xml index 80523e2bdf..f8ac9ee127 100644 --- a/pump/omnipod-common/src/main/res/values-pl-rPL/strings.xml +++ b/pump/omnipod-common/src/main/res/values-pl-rPL/strings.xml @@ -124,6 +124,7 @@ Konfiguracja w toku (oczekiwanie na aktywację Poda) Konfiguracja w toku (oczekiwanie na wprowadzenie kaniuli) W działaniu + Normalny Wstrzymany Błąd Poda Przekroczono czas aktywacji diff --git a/pump/omnipod-common/src/main/res/values-ro-rRO/strings.xml b/pump/omnipod-common/src/main/res/values-ro-rRO/strings.xml index 08443c0d22..ad03df4912 100644 --- a/pump/omnipod-common/src/main/res/values-ro-rRO/strings.xml +++ b/pump/omnipod-common/src/main/res/values-ro-rRO/strings.xml @@ -125,6 +125,7 @@ Setare în desfășurare (se așteaptă activarea Pod-ului) Inițializarea este în curs (se așteaptă inserarea canulei) Rulează + Normal Suspendat Defecțiune Pod Timp de activare depăşit diff --git a/pump/omnipod-common/src/main/res/values-ru-rRU/strings.xml b/pump/omnipod-common/src/main/res/values-ru-rRU/strings.xml index efa7578782..a04fce5329 100644 --- a/pump/omnipod-common/src/main/res/values-ru-rRU/strings.xml +++ b/pump/omnipod-common/src/main/res/values-ru-rRU/strings.xml @@ -124,6 +124,7 @@ Выполняется настройка (ожидание активации Pod) Выполняется настройка (ожидание ввода катетера) Выполняется + Норма Приостановлено Ошибка Pod Превышено время активации diff --git a/pump/omnipod-common/src/main/res/values-sk-rSK/strings.xml b/pump/omnipod-common/src/main/res/values-sk-rSK/strings.xml index e5a0004542..cb26c7ee90 100644 --- a/pump/omnipod-common/src/main/res/values-sk-rSK/strings.xml +++ b/pump/omnipod-common/src/main/res/values-sk-rSK/strings.xml @@ -124,6 +124,7 @@ Prebieha inštalácia (čaká sa na aktiváciu Podu) Inštalácia prebieha (čaká sa na vloženie kanyly) V prevádzke + Normálny Pozastavené Chyba Podu Bol prekročený čas aktivácie diff --git a/pump/omnipod-common/src/main/res/values-tr-rTR/strings.xml b/pump/omnipod-common/src/main/res/values-tr-rTR/strings.xml index 637090f012..dd668038aa 100644 --- a/pump/omnipod-common/src/main/res/values-tr-rTR/strings.xml +++ b/pump/omnipod-common/src/main/res/values-tr-rTR/strings.xml @@ -124,6 +124,7 @@ Kurulum devam ediyor (Pod aktivasyonu bekleniyor) Kurulum devam ediyor (kanül yerleştirme bekleniyor) Çalışıyor + Normal Askıya Alındı Pod Hatası Aktivasyon süresi aşıldı diff --git a/pump/omnipod-common/src/main/res/values/strings.xml b/pump/omnipod-common/src/main/res/values/strings.xml index d2ecea3ce0..72eb74947a 100644 --- a/pump/omnipod-common/src/main/res/values/strings.xml +++ b/pump/omnipod-common/src/main/res/values/strings.xml @@ -153,6 +153,7 @@ Setup in progress (waiting for Pod activation) Setup in progress (waiting for cannula insertion) Running + Normal Suspended Pod Fault Activation time exceeded diff --git a/pump/omnipod-dash/src/main/java/info/nightscout/androidaps/plugins/pump/omnipod/dash/OmnipodDashPumpPlugin.kt b/pump/omnipod-dash/src/main/java/info/nightscout/androidaps/plugins/pump/omnipod/dash/OmnipodDashPumpPlugin.kt index 39cd00ef9a..a0acf389de 100644 --- a/pump/omnipod-dash/src/main/java/info/nightscout/androidaps/plugins/pump/omnipod/dash/OmnipodDashPumpPlugin.kt +++ b/pump/omnipod-dash/src/main/java/info/nightscout/androidaps/plugins/pump/omnipod/dash/OmnipodDashPumpPlugin.kt @@ -183,7 +183,7 @@ class OmnipodDashPumpPlugin @Inject constructor( if (!podStateManager.isPodRunning) { uiInteraction.addNotification( Notification.OMNIPOD_POD_NOT_ATTACHED, - "Pod not activated", + rh.gs(info.nightscout.androidaps.plugins.pump.omnipod.common.R.string.omnipod_common_pod_status_no_active_pod), Notification.NORMAL ) } else { @@ -991,9 +991,9 @@ class OmnipodDashPumpPlugin @Inject constructor( val extended = JSONObject() try { val podStatus = when { - podStateManager.isPodRunning && podStateManager.isSuspended -> "suspended" - podStateManager.isPodRunning -> "normal" - else -> "no active Pod" + podStateManager.isPodRunning && podStateManager.isSuspended -> rh.gs(info.nightscout.androidaps.plugins.pump.omnipod.common.R.string.omnipod_common_pod_status_suspended).lowercase() + podStateManager.isPodRunning -> rh.gs(info.nightscout.androidaps.plugins.pump.omnipod.common.R.string.omnipod_common_pod_status_normal).lowercase() + else -> rh.gs(info.nightscout.androidaps.plugins.pump.omnipod.common.R.string.omnipod_common_pod_status_no_active_pod).lowercase() } status.put("status", podStatus) status.put("timestamp", dateUtil.toISOString(podStateManager.lastUpdatedSystem)) diff --git a/shared/tests/src/main/kotlin/app/aaps/shared/tests/TestBaseWithProfile.kt b/shared/tests/src/main/kotlin/app/aaps/shared/tests/TestBaseWithProfile.kt index 3a170fb5f2..42997cc0ca 100644 --- a/shared/tests/src/main/kotlin/app/aaps/shared/tests/TestBaseWithProfile.kt +++ b/shared/tests/src/main/kotlin/app/aaps/shared/tests/TestBaseWithProfile.kt @@ -36,7 +36,7 @@ import org.mockito.invocation.InvocationOnMock @Suppress("SpellCheckingInspection") open class TestBaseWithProfile : TestBase() { - @Mock lateinit var activePlugin: ActivePlugin + @Mock open lateinit var activePlugin: ActivePlugin @Mock lateinit var rh: ResourceHelper @Mock lateinit var iobCobCalculator: IobCobCalculator @Mock lateinit var fabricPrivacy: FabricPrivacy diff --git a/ui/src/main/res/layout/dialog_wizard.xml b/ui/src/main/res/layout/dialog_wizard.xml index 885d3204f9..abf256d80c 100644 --- a/ui/src/main/res/layout/dialog_wizard.xml +++ b/ui/src/main/res/layout/dialog_wizard.xml @@ -284,31 +284,6 @@ - - - - - - - - + + + + + + + + diff --git a/ui/src/main/res/values-ru-rRU/strings.xml b/ui/src/main/res/values-ru-rRU/strings.xml index 00cb9201f0..f55731100c 100644 --- a/ui/src/main/res/values-ru-rRU/strings.xml +++ b/ui/src/main/res/values-ru-rRU/strings.xml @@ -42,7 +42,7 @@ Внести запись о смене места катетера помпы Внести запись о замене картриджа инсулина - Не подавать болюс, только внести запись + Не вводить болюс, только внести запись Повторно использовать %1$d%%%2$dч Сдвиг по времени diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index ad13b8e525..ce0b3e4539 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -86,6 +86,9 @@ android { versionName = Versions.appVersion + "-aapsclient2" } } + buildFeatures { + buildConfig = true + } } allprojects { diff --git a/wear/src/main/res/values-ru-rRU/strings.xml b/wear/src/main/res/values-ru-rRU/strings.xml index e91a4e0d36..c679e5131d 100644 --- a/wear/src/main/res/values-ru-rRU/strings.xml +++ b/wear/src/main/res/values-ru-rRU/strings.xml @@ -116,7 +116,7 @@ ПОДТВЕРДИТЬ сдвиг по времени Болюс - Подается болюс + Вводится болюс нажмите для отмены ОТМЕНИТЬ БОЛЮС Помпа @@ -161,10 +161,10 @@ Ваш цвет: Ваша цветовая насыщенность: Непрозрачность вашего цвета: - Подается болюс - Беззвучная подача болюса - Подача и отмена болюса - Подача и отмена болюса с меньшими вибрациями + Вводится болюс + Беззвучный ввод болюса + Ввод и отмена болюса + Введение и отмена болюса с меньшими вибрациями Выкл Во время зарядки Режим: всегда вкл @@ -192,7 +192,7 @@ 000г 00,0 0,00 ед - Оповещение о переходе AAPS в режим энергосбережения + Отложить оповещения AAPS Отправка команды отложить оповещения AAPS ч н