From 0614f59ff9a79caab574110dccbfc891e52c7edd Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Thu, 5 Feb 2026 19:37:50 -0500 Subject: [PATCH] feat: migrate battery reporting to Percent-Charged header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace complex user-level battery reporting (PATCH /api/devices/{id}) with simplified device-level Percent-Charged header sent with image fetch requests. Changes: - Add percentCharged parameter to TrmnlApiService.getNextDisplayData() - Update TrmnlDisplayRepository to send battery for BYOD devices only - Deprecate reportDeviceBatteryStatus() and getDeviceIdFromApi() methods - Remove battery reporting call from TrmnlImageRefreshWorker - Disable user API token UI in AppSettingsScreen (100+ lines) - Deprecate TrmnlUserApiService and TrmnlDeviceUpdateRequest - Deprecate userApiToken and deviceId in TrmnlDeviceConfig - Deprecate DataStore methods for user token and device ID - Add 4 new battery percentage header tests - Ignore 6 deprecated battery/device ID tests - Update existing RSSI tests to include percentCharged parameter Benefits: - Simpler: No separate API call needed - Secure: Uses device-level auth only (no user token required) - Consistent: Follows same pattern as RSSI header - BYOD-only: Battery reporting limited to BYOD devices as designed All changes maintain backward compatibility with deprecated code preserved. Verified with: - formatKotlin: ✅ (0 errors) - lintKotlin: ✅ (0 errors) - testDebugUnitTest: ✅ (203 passed, 9 skipped) - assembleDebug: ✅ (APK built successfully) --- .../data/TrmnlDeviceConfigDataStore.kt | 28 +++ .../android/data/TrmnlDisplayRepository.kt | 62 +++++- .../trmnl/android/model/TrmnlDeviceConfig.kt | 22 ++ .../trmnl/android/network/TrmnlApiService.kt | 2 + .../android/network/TrmnlUserApiService.kt | 10 + .../network/model/TrmnlDeviceUpdateRequest.kt | 9 + .../android/ui/settings/AppSettingsScreen.kt | 22 ++ .../android/work/TrmnlImageRefreshWorker.kt | 5 +- .../data/TrmnlDisplayRepositoryTest.kt | 200 +++++++++++++++++- .../trmnl-api/trmnl-openapi.yaml | 6 + 10 files changed, 350 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt index c11b629..cb11382 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -361,7 +361,14 @@ class TrmnlDeviceConfigDataStore /** * Saves the user-level API token (Account API key) + * + * **DEPRECATED:** User API token is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call. */ + @Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, + ) suspend fun saveUserApiToken(token: String) { Timber.tag(TAG).d("Saving user API token: ${token.obfuscated()}") context.deviceConfigStore.edit { preferences -> @@ -372,7 +379,14 @@ class TrmnlDeviceConfigDataStore /** * Gets the user-level API token + * + * **DEPRECATED:** User API token is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call. */ + @Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, + ) suspend fun getUserApiToken(): String? { val token = context.deviceConfigStore.data @@ -385,8 +399,15 @@ class TrmnlDeviceConfigDataStore /** * Saves the device ID (TRMNL device ID from /api/devices/me). * + * **DEPRECATED:** Device ID is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call. + * * **Note:** This is only applicable for BYOD device types. */ + @Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, + ) suspend fun saveDeviceId(deviceId: Int) { Timber.tag(TAG).d("Saving device ID: $deviceId") context.deviceConfigStore.edit { preferences -> @@ -398,8 +419,15 @@ class TrmnlDeviceConfigDataStore /** * Gets the device ID. * + * **DEPRECATED:** Device ID is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call. + * * **Note:** This is only applicable for BYOD device types. */ + @Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, + ) suspend fun getDeviceId(): Int? { val deviceId = context.deviceConfigStore.data diff --git a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt index 0b11321..c5c5995 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt @@ -15,11 +15,8 @@ import ink.trmnl.android.network.TrmnlApiService.Companion.CURRENT_PLAYLIST_SCRE import ink.trmnl.android.network.TrmnlApiService.Companion.MODELS_API_PATH import ink.trmnl.android.network.TrmnlApiService.Companion.NEXT_PLAYLIST_SCREEN_API_PATH import ink.trmnl.android.network.TrmnlUserApiService -import ink.trmnl.android.network.TrmnlUserApiService.Companion.DEVICE_API_PATH import ink.trmnl.android.network.TrmnlUserApiService.Companion.USER_INFO_API_PATH -import ink.trmnl.android.network.model.TrmnlDevice import ink.trmnl.android.network.model.TrmnlDeviceModel -import ink.trmnl.android.network.model.TrmnlDeviceUpdateRequest import ink.trmnl.android.network.model.TrmnlDisplayResponse import ink.trmnl.android.network.model.TrmnlUser import ink.trmnl.android.network.util.constructApiUrl @@ -82,6 +79,13 @@ class TrmnlDisplayRepository } else { null }, + percentCharged = + if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD) { + // Send battery percentage if available for BYOD devices only + androidDeviceInfoProvider.getBatteryLevel()?.toDouble() + } else { + null + }, ) when (result) { @@ -384,6 +388,12 @@ class TrmnlDisplayRepository /** * Fetches the device ID from the TRMNL API using the device API token. * + * **DEPRECATED:** Device ID fetching is no longer needed. Battery reporting now uses + * the Percent-Charged header in /api/display call, which only requires device-level + * authentication (Access-Token), not user-level authentication or device ID. + * + * This method will be removed in a future version. + * * This method calls the /api/devices/me endpoint with device-level authentication * to retrieve device information including the device ID, which is needed for * user-level API calls to /api/devices/{id}. @@ -394,7 +404,17 @@ class TrmnlDisplayRepository * @param config Device configuration containing the device API token * @return A Result containing the device ID on success or an exception on failure */ + @Deprecated("Device ID no longer needed for battery reporting. Use Percent-Charged header instead.") suspend fun getDeviceIdFromApi(config: TrmnlDeviceConfig): Result { + Timber.w("getDeviceIdFromApi is deprecated. Device ID is no longer needed for battery reporting.") + return Result.failure( + UnsupportedOperationException( + "Device ID fetching is deprecated. Battery reporting now uses Percent-Charged header " + + "in /api/display, which doesn't require device ID or user API token.", + ), + ) + + /* DISABLED - Device ID no longer needed for battery reporting Timber.i("Fetching device ID from API for device type: ${config.type}") // Always use mocked response since the endpoint doesn't exist yet @@ -438,20 +458,29 @@ class TrmnlDisplayRepository * } * } */ + */ } /** * Reports the device's battery status to the TRMNL API for BYOD devices. * - * This is a convenience method that checks if the device is a BYOD device with the necessary - * configuration (deviceId and userApiToken), retrieves the current battery level, - * and reports it to the server. + * **DEPRECATED:** Battery reporting now uses the Percent-Charged header in /api/display call. + * This method is disabled and will be removed in a future version. * - * This method should be called after successful image refresh operations. + * Battery percentage is now automatically sent via the Percent-Charged header parameter + * when calling getNextDisplayData() for BYOD devices, eliminating the need for a separate + * API call and user-level authentication. * * @param config Device configuration containing device type, device ID, and user API token + * @see getNextDisplayData */ + @Deprecated("Battery reporting now uses Percent-Charged header. This method is no longer needed.") suspend fun reportDeviceBatteryStatus(config: TrmnlDeviceConfig) { + // DEPRECATED: Battery reporting now happens via Percent-Charged header in /api/display + Timber.d("Battery reporting via separate API call is deprecated. Battery is now sent via Percent-Charged header.") + return + + /* DISABLED - Battery now reported via Percent-Charged header // Only report battery for BYOD devices with required configuration if (config.type != TrmnlDeviceType.BYOD) { Timber.d("Battery reporting skipped: not a BYOD device (type: ${config.type})") @@ -484,25 +513,35 @@ class TrmnlDisplayRepository } catch (e: Exception) { Timber.e(e, "Unexpected error during battery reporting") } + */ } /** * Reports the device's battery status to the TRMNL API. * - * This method sends a PATCH request to /api/devices/{id} using user-level authentication - * to update the device's battery percentage on the server. + * **DEPRECATED:** This method is no longer used. Battery reporting now uses the + * Percent-Charged header in /api/display call instead of PATCH /api/devices/{id}. * - * This suspend function performs network I/O and should be called from a background - * coroutine so it does not block or delay display updates. + * This method will be removed in a future version. * * @param config Device configuration containing the device ID and user API token * @param batteryPercent The current battery percentage (0-100) * @return A Result containing Unit on success or an exception on failure */ + @Deprecated("Battery reporting now uses Percent-Charged header. This method is no longer needed.") private suspend fun reportBatteryStatus( config: TrmnlDeviceConfig, batteryPercent: Int, ): Result { + // DEPRECATED: This method is no longer used + Timber.d("reportBatteryStatus is deprecated and disabled") + return Result.failure( + UnsupportedOperationException( + "Battery reporting via PATCH /api/devices/{id} is deprecated. Use Percent-Charged header instead.", + ), + ) + + /* DISABLED - Battery now reported via Percent-Charged header val deviceId = config.deviceId val userApiToken = config.userApiToken @@ -545,5 +584,6 @@ class TrmnlDisplayRepository Result.success(Unit) } } + */ } } diff --git a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt index 4c7f89e..5bdba97 100644 --- a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt +++ b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt @@ -35,13 +35,31 @@ data class TrmnlDeviceConfig constructor( val isMasterDevice: Boolean? = null, /** * User-level API token (Account API key) for user-level endpoints. + * + * **DEPRECATED:** This field is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call, + * which only requires device-level authentication ([apiAccessToken]). + * + * This field is kept for backward compatibility and may be removed in a future version. + * * Required for BYOD devices to access user-level API endpoints like /api/me and /api/devices. * * This is separate from [apiAccessToken] which is the device-level API key. */ + @Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, + ) val userApiToken: String? = null, /** * TRMNL device ID extracted from /api/devices/me endpoint. + * + * **DEPRECATED:** This field is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call, + * which doesn't require device ID or user-level authentication. + * + * This field is kept for backward compatibility and may be removed in a future version. + * * Used for making user-level API calls to /api/devices/{id}. * * This ID is fetched during BYOD device validation and is required for @@ -49,5 +67,9 @@ data class TrmnlDeviceConfig constructor( * * **Note:** This field is only applicable for BYOD device types. */ + @Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, + ) val deviceId: Int? = null, ) diff --git a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt index 7b815e3..8380409 100644 --- a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt +++ b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt @@ -94,6 +94,7 @@ interface TrmnlApiService { * @param deviceMacId The device's MAC address (optional) * @param useBase64 Whether to request Base64-encoded image data (optional) * @param rssi WiFi signal strength in dBm (optional, -100 to 0). See https://github.com/usetrmnl/trmnl-firmware/blob/main/src/api-client/display.cpp for additional references. + * @param percentCharged Battery percentage (optional, 0.0 to 100.0). Only sent for BYOD devices. * * @see getCurrentDisplayData */ @@ -104,6 +105,7 @@ interface TrmnlApiService { @Header("ID") deviceMacId: String? = null, @Header("BASE64") useBase64: Boolean? = null, @Header("RSSI") rssi: Int? = null, + @Header("Percent-Charged") percentCharged: Double? = null, ): ApiResult /** diff --git a/app/src/main/java/ink/trmnl/android/network/TrmnlUserApiService.kt b/app/src/main/java/ink/trmnl/android/network/TrmnlUserApiService.kt index dadba14..34f28ed 100644 --- a/app/src/main/java/ink/trmnl/android/network/TrmnlUserApiService.kt +++ b/app/src/main/java/ink/trmnl/android/network/TrmnlUserApiService.kt @@ -14,6 +14,12 @@ import retrofit2.http.Url /** * API service interface for TRMNL user-level (account) API endpoints. * + * **DEPRECATED:** This service is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call, + * which only requires device-level authentication (Access-Token). + * + * This interface is kept for backward compatibility and may be removed in a future version. + * * This interface defines endpoints that require user-level authentication via Bearer token * (Account API key), as opposed to device-level authentication. * @@ -21,6 +27,10 @@ import retrofit2.http.Url * - https://docs.trmnl.com/go * - https://trmnl.com/api-docs/index.html (OpenAPI documentation) */ +@Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, +) interface TrmnlUserApiService { companion object { /** diff --git a/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt index f9d5971..c9facc3 100644 --- a/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt +++ b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt @@ -6,6 +6,11 @@ import com.squareup.moshi.JsonClass /** * Data class representing a request to update a TRMNL device. * + * **DEPRECATED:** This model is no longer needed for battery reporting. + * Battery percentage is now sent via the Percent-Charged header in /api/display call. + * + * This class is kept for backward compatibility and may be removed in a future version. + * * All fields are optional - only include the fields you want to update. * * Sample JSON request: @@ -24,6 +29,10 @@ import com.squareup.moshi.JsonClass * @property percentCharged The battery percentage charged. * @see ink.trmnl.android.network.TrmnlApiService.updateDevice */ +@Deprecated( + message = "No longer needed for battery reporting. Battery is now sent via Percent-Charged header.", + level = DeprecationLevel.WARNING, +) @JsonClass(generateAdapter = true) data class TrmnlDeviceUpdateRequest( @Json(name = "sleep_mode_enabled") val sleepModeEnabled: Boolean? = null, diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt index ea0f862..dacfec9 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/AppSettingsScreen.kt @@ -391,6 +391,10 @@ class AppSettingsPresenter } AppSettingsScreen.Event.ValidateUserToken -> { + // DEPRECATED: User API token validation is no longer needed + // Battery reporting now uses Percent-Charged header instead of user-level API + Timber.d("User token validation skipped - no longer needed for battery reporting") + /* DISABLED - User token no longer needed for battery reporting scope.launch { focusManager.clearFocus() isLoading = true @@ -440,6 +444,7 @@ class AppSettingsPresenter isLoading = false } + */ } AppSettingsScreen.Event.ValidateToken -> { @@ -514,6 +519,10 @@ class AppSettingsPresenter response.refreshIntervalSeconds ?: DEFAULT_REFRESH_INTERVAL_SEC, ) + // DEPRECATED: Device ID fetching no longer needed + // Battery reporting now uses Percent-Charged header instead of user-level API + + /* DISABLED - Device ID no longer needed for battery reporting // For BYOD devices, also fetch and save the device ID if (deviceType == TrmnlDeviceType.BYOD) { val deviceIdResult = displayRepository.getDeviceIdFromApi(deviceConfig) @@ -530,6 +539,7 @@ class AppSettingsPresenter ) } } + */ } else { // No error but also no image URL val errorMessage = response.error ?: "" @@ -813,6 +823,17 @@ fun AppSettingsContent( deviceIdError = (state.validationResult as? ValidationResult.InvalidDeviceMacId)?.message, ) + // + // DEPRECATED: User API Token field is no longer needed + // + // Battery reporting now uses the Percent-Charged header in /api/display call, + // which only requires device-level authentication (Access-Token). + // User-level authentication is no longer needed for BYOD device battery reporting. + // + // This UI has been disabled but kept in code for reference. + // + + /* DISABLED - User API token no longer needed for battery reporting // User API Token field (only for BYOD) AnimatedVisibility( visible = state.deviceType == TrmnlDeviceType.BYOD, @@ -908,6 +929,7 @@ fun AppSettingsContent( } } } + */ Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt b/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt index df1d4a2..09460a4 100644 --- a/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt +++ b/app/src/main/java/ink/trmnl/android/work/TrmnlImageRefreshWorker.kt @@ -170,8 +170,9 @@ class TrmnlImageRefreshWorker( httpResponseMetadata = trmnlDisplayInfo.httpResponseMetadata, ) - // Report battery status for BYOD devices after successful image refresh - displayRepository.reportDeviceBatteryStatus(deviceConfig) + // DEPRECATED: Battery reporting now happens via Percent-Charged header in /api/display call + // Battery percentage is automatically sent when fetching the next image for BYOD devices + // displayRepository.reportDeviceBatteryStatus(deviceConfig) // NOTE: Image metadata caching is handled automatically by `TrmnlDisplayRepository` // when the API call succeeds, so we don't need to save it again here. diff --git a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt index 1080dde..9fb616c 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDisplayRepositoryTest.kt @@ -21,6 +21,7 @@ import okhttp3.Request import okhttp3.Response import org.junit.After import org.junit.Before +import org.junit.Ignore import org.junit.Test /** @@ -410,6 +411,8 @@ class TrmnlDisplayRepositoryTest { accessToken = byodDeviceConfig.apiAccessToken, useBase64 = any(), rssi = any(), + percentCharged = any(), + deviceMacId = any(), ) } returns ApiResult.success(successResponse) @@ -426,8 +429,10 @@ class TrmnlDisplayRepositoryTest { apiService.getNextDisplayData( fullApiUrl = expectedNextApiUrl, accessToken = byodDeviceConfig.apiAccessToken, + deviceMacId = any(), useBase64 = any(), rssi = any(), + percentCharged = any(), ) } } @@ -579,6 +584,9 @@ class TrmnlDisplayRepositoryTest { assertThat(result.httpResponseMetadata?.contentLength).isEqualTo(123L) } + // DEPRECATED: Device ID fetching is no longer needed for battery reporting + // Battery is now sent via Percent-Charged header + @Ignore("Device ID fetching deprecated - battery now uses Percent-Charged header") @Test fun `getDeviceIdFromApi should return mocked device ID`() = runTest { @@ -593,6 +601,9 @@ class TrmnlDisplayRepositoryTest { coVerify(exactly = 0) { apiService.getDeviceMe(any(), any()) } } + // DEPRECATED: Battery reporting via separate API call is no longer used + // Battery is now sent via Percent-Charged header in /api/display call + @Ignore("Battery reporting via PATCH /api/devices/{id} deprecated - now uses Percent-Charged header") @Test fun `reportDeviceBatteryStatus should report battery for valid BYOD config`() = runTest { @@ -628,6 +639,8 @@ class TrmnlDisplayRepositoryTest { } } + // DEPRECATED: Battery reporting via separate API call is no longer used + @Ignore("Battery reporting via PATCH /api/devices/{id} deprecated - now uses Percent-Charged header") @Test fun `reportDeviceBatteryStatus should skip for non-BYOD device`() = runTest { @@ -646,6 +659,8 @@ class TrmnlDisplayRepositoryTest { coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() } } + // DEPRECATED: Battery reporting via separate API call is no longer used + @Ignore("Battery reporting via PATCH /api/devices/{id} deprecated - now uses Percent-Charged header") @Test fun `reportDeviceBatteryStatus should skip when deviceId is null`() = runTest { @@ -664,6 +679,8 @@ class TrmnlDisplayRepositoryTest { coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() } } + // DEPRECATED: Battery reporting via separate API call is no longer used + @Ignore("Battery reporting via PATCH /api/devices/{id} deprecated - now uses Percent-Charged header") @Test fun `reportDeviceBatteryStatus should skip when userApiToken is null`() = runTest { @@ -682,6 +699,8 @@ class TrmnlDisplayRepositoryTest { coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() } } + // DEPRECATED: Battery reporting via separate API call is no longer used + @Ignore("Battery reporting via PATCH /api/devices/{id} deprecated - now uses Percent-Charged header") @Test fun `reportDeviceBatteryStatus should skip when battery level unavailable`() = runTest { @@ -715,8 +734,10 @@ class TrmnlDisplayRepositoryTest { apiAccessToken = "test_api_key", ) val expectedRssi = -65 + val expectedBattery = 80 every { androidDeviceInfoProvider.getWifiSignalStrength() } returns expectedRssi + every { androidDeviceInfoProvider.getBatteryLevel() } returns expectedBattery coEvery { apiService.getNextDisplayData( @@ -725,6 +746,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = expectedRssi, + percentCharged = expectedBattery.toDouble(), ) } returns ApiResult.success(mockk(relaxed = true)) @@ -740,6 +762,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = expectedRssi, + percentCharged = expectedBattery.toDouble(), ) } } @@ -754,10 +777,12 @@ class TrmnlDisplayRepositoryTest { userApiToken = "test_token", apiAccessToken = "test_api_key", ) + val expectedBattery = 80 every { androidDeviceInfoProvider.getWifiSignalStrength() } returns null + every { androidDeviceInfoProvider.getBatteryLevel() } returns expectedBattery - coEvery { apiService.getNextDisplayData(any(), any(), any(), any()) } returns + coEvery { apiService.getNextDisplayData(any(), any(), any(), any(), any(), any()) } returns ApiResult.success(mockk(relaxed = true)) // Act @@ -772,6 +797,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = null, + percentCharged = expectedBattery.toDouble(), ) } } @@ -785,7 +811,7 @@ class TrmnlDisplayRepositoryTest { apiAccessToken = "trmnl_api_key", ) - coEvery { apiService.getNextDisplayData(any(), any(), any(), any()) } returns + coEvery { apiService.getNextDisplayData(any(), any(), any(), any(), any(), any()) } returns ApiResult.success(mockk(relaxed = true)) // Act @@ -800,6 +826,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = null, + percentCharged = null, ) } } @@ -833,6 +860,164 @@ class TrmnlDisplayRepositoryTest { } } + // Battery Percentage (Percent-Charged Header) Tests + + @Test + fun `getNextDisplayData should send battery percentage for BYOD device when available`() = + runTest { + // Arrange + val byodConfig = + byodDeviceConfig.copy( + apiAccessToken = "test_api_key", + ) + val expectedBatteryLevel = 75 + + every { androidDeviceInfoProvider.getBatteryLevel() } returns expectedBatteryLevel + every { androidDeviceInfoProvider.getWifiSignalStrength() } returns -65 + + coEvery { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = any(), + percentCharged = 75.0, + ) + } returns ApiResult.success(mockk(relaxed = true)) + + // Act + repository.getNextDisplayData(byodConfig) + + // Assert - Verify battery level was fetched and sent as header + coVerify(exactly = 1) { androidDeviceInfoProvider.getBatteryLevel() } + coVerify { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = any(), + percentCharged = 75.0, + ) + } + } + + @Test + fun `getNextDisplayData should send null battery percentage for BYOD when unavailable`() = + runTest { + // Arrange + val byodConfig = + byodDeviceConfig.copy( + apiAccessToken = "test_api_key", + ) + + every { androidDeviceInfoProvider.getBatteryLevel() } returns null + every { androidDeviceInfoProvider.getWifiSignalStrength() } returns -65 + + coEvery { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = any(), + percentCharged = null, + ) + } returns ApiResult.success(mockk(relaxed = true)) + + // Act + repository.getNextDisplayData(byodConfig) + + // Assert - Verify battery level was fetched but null was sent + coVerify(exactly = 1) { androidDeviceInfoProvider.getBatteryLevel() } + coVerify { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = any(), + percentCharged = null, + ) + } + } + + @Test + fun `getNextDisplayData should NOT send battery percentage for TRMNL device`() = + runTest { + // Arrange - TRMNL device (not BYOD) + val trmnlConfig = + testDeviceConfig.copy( + apiAccessToken = "trmnl_api_key", + ) + + coEvery { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = null, + percentCharged = null, + ) + } returns ApiResult.success(mockk(relaxed = true)) + + // Act + repository.getNextDisplayData(trmnlConfig) + + // Assert - Verify battery level was NOT fetched and null was sent + coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() } + coVerify { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = null, + percentCharged = null, + ) + } + } + + @Test + fun `getNextDisplayData should NOT send battery percentage for BYOS device`() = + runTest { + // Arrange - BYOS device + val byosConfig = + byosDeviceConfig.copy( + apiAccessToken = "byos_api_key", + ) + + coEvery { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = null, + percentCharged = null, + ) + } returns ApiResult.success(mockk(relaxed = true)) + + // Act + repository.getNextDisplayData(byosConfig) + + // Assert - Verify battery level was NOT fetched for BYOS device + coVerify(exactly = 0) { androidDeviceInfoProvider.getBatteryLevel() } + // Verify null battery percentage was sent + coVerify { + apiService.getNextDisplayData( + fullApiUrl = any(), + accessToken = any(), + deviceMacId = any(), + useBase64 = any(), + rssi = null, + percentCharged = null, + ) + } + } + @Test fun `getNextDisplayData should call getWifiSignalStrength only for BYOD devices`() = runTest { @@ -842,8 +1027,9 @@ class TrmnlDisplayRepositoryTest { val byosConfig = byosDeviceConfig.copy(apiAccessToken = "byos_key") every { androidDeviceInfoProvider.getWifiSignalStrength() } returns -70 + every { androidDeviceInfoProvider.getBatteryLevel() } returns 75 - coEvery { apiService.getNextDisplayData(any(), any(), any(), any(), any()) } returns + coEvery { apiService.getNextDisplayData(any(), any(), any(), any(), any(), any()) } returns ApiResult.success(mockk(relaxed = true)) // Act - Fetch for all device types @@ -861,8 +1047,10 @@ class TrmnlDisplayRepositoryTest { // Arrange val byodConfig = byodDeviceConfig.copy(apiAccessToken = "test_key") val strongSignal = -30 // Excellent signal + val expectedBattery = 80 every { androidDeviceInfoProvider.getWifiSignalStrength() } returns strongSignal + every { androidDeviceInfoProvider.getBatteryLevel() } returns expectedBattery coEvery { apiService.getNextDisplayData( @@ -871,6 +1059,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = strongSignal, + percentCharged = expectedBattery.toDouble(), ) } returns ApiResult.success(mockk(relaxed = true)) @@ -885,6 +1074,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = strongSignal, + percentCharged = expectedBattery.toDouble(), ) } } @@ -895,8 +1085,10 @@ class TrmnlDisplayRepositoryTest { // Arrange val byodConfig = byodDeviceConfig.copy(apiAccessToken = "test_key") val weakSignal = -90 // Very weak signal + val expectedBattery = 80 every { androidDeviceInfoProvider.getWifiSignalStrength() } returns weakSignal + every { androidDeviceInfoProvider.getBatteryLevel() } returns expectedBattery coEvery { apiService.getNextDisplayData( @@ -905,6 +1097,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = weakSignal, + percentCharged = expectedBattery.toDouble(), ) } returns ApiResult.success(mockk(relaxed = true)) @@ -919,6 +1112,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = weakSignal, + percentCharged = expectedBattery.toDouble(), ) } } diff --git a/project-resources/trmnl-api/trmnl-openapi.yaml b/project-resources/trmnl-api/trmnl-openapi.yaml index df7436d..553e41f 100644 --- a/project-resources/trmnl-api/trmnl-openapi.yaml +++ b/project-resources/trmnl-api/trmnl-openapi.yaml @@ -22,6 +22,12 @@ paths: description: Device battery voltage (eg. 3.7) schema: type: number + - name: Percent-Charged + in: header + required: false + description: Device percent charged (eg. 69.4) + schema: + type: number - name: FW-Version in: header required: false