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..bc575b7 100644 --- a/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt +++ b/app/src/main/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStore.kt @@ -99,8 +99,6 @@ class TrmnlDeviceConfigDataStore private val CONFIG_JSON_KEY = stringPreferencesKey("config_json") private val DEVICE_MAC_ID_KEY = stringPreferencesKey("device_mac_id") private val IS_MASTER_DEVICE_KEY = stringPreferencesKey("is_master_device") - private val USER_API_TOKEN_KEY = stringPreferencesKey("user_api_token") - private val DEVICE_ID_KEY = stringPreferencesKey("device_id") private val DEVICE_MODEL_PREFERENCES_KEY = stringPreferencesKey("device_model_preferences") } @@ -238,7 +236,7 @@ class TrmnlDeviceConfigDataStore try { val config: TrmnlDeviceConfig? = deviceConfigAdapter.fromJson(configJson) Timber.tag(TAG).d( - "Loading device config (JSON): type=${config?.type}, userApiToken=${config?.userApiToken.obfuscated()}", + "Loading device config (JSON): type=${config?.type}", ) config } catch (e: Exception) { @@ -261,11 +259,9 @@ class TrmnlDeviceConfigDataStore val refreshRate = preferences[REFRESH_RATE_SEC_KEY] ?: DEFAULT_REFRESH_INTERVAL_SEC val deviceMacId = preferences[DEVICE_MAC_ID_KEY] val isMasterDevice = preferences[IS_MASTER_DEVICE_KEY]?.toBoolean() - val userApiToken = preferences[USER_API_TOKEN_KEY] - val deviceId = preferences[DEVICE_ID_KEY]?.toIntOrNull() Timber.tag(TAG).d( - "Loading device config (legacy): type=$type, deviceApiToken=${token.obfuscated()}, deviceId=$deviceId", + "Loading device config (legacy): type=$type, deviceApiToken=${token.obfuscated()}", ) if (token != null) { @@ -276,8 +272,6 @@ class TrmnlDeviceConfigDataStore deviceMacId = deviceMacId, refreshRateSecs = refreshRate, isMasterDevice = isMasterDevice, - userApiToken = userApiToken, - deviceId = deviceId, ) } else { null @@ -311,7 +305,7 @@ class TrmnlDeviceConfigDataStore suspend fun saveDeviceConfig(config: TrmnlDeviceConfig) { try { Timber.tag(TAG).d( - "Saving device config: type=${config.type}, userApiToken=${config.userApiToken.obfuscated()}", + "Saving device config: type=${config.type}", ) val configJson = deviceConfigAdapter.toJson(config) context.deviceConfigStore.edit { preferences -> @@ -333,16 +327,6 @@ class TrmnlDeviceConfigDataStore config.isMasterDevice?.let { isMaster -> preferences[IS_MASTER_DEVICE_KEY] = isMaster.toString() } ?: preferences.remove(IS_MASTER_DEVICE_KEY) - - // Save userApiToken if available - config.userApiToken?.let { userToken -> - preferences[USER_API_TOKEN_KEY] = userToken - } ?: preferences.remove(USER_API_TOKEN_KEY) - - // Save deviceId if available - config.deviceId?.let { deviceId -> - preferences[DEVICE_ID_KEY] = deviceId.toString() - } ?: preferences.remove(DEVICE_ID_KEY) } Timber.tag(TAG).d("Device config saved successfully") } catch (e: Exception) { @@ -359,56 +343,6 @@ class TrmnlDeviceConfigDataStore } } - /** - * Saves the user-level API token (Account API key) - */ - suspend fun saveUserApiToken(token: String) { - Timber.tag(TAG).d("Saving user API token: ${token.obfuscated()}") - context.deviceConfigStore.edit { preferences -> - preferences[USER_API_TOKEN_KEY] = token - } - Timber.tag(TAG).d("User API token saved successfully") - } - - /** - * Gets the user-level API token - */ - suspend fun getUserApiToken(): String? { - val token = - context.deviceConfigStore.data - .map { preferences -> preferences[USER_API_TOKEN_KEY] } - .first() - Timber.tag(TAG).d("Retrieved user API token: ${token.obfuscated()}") - return token - } - - /** - * Saves the device ID (TRMNL device ID from /api/devices/me). - * - * **Note:** This is only applicable for BYOD device types. - */ - suspend fun saveDeviceId(deviceId: Int) { - Timber.tag(TAG).d("Saving device ID: $deviceId") - context.deviceConfigStore.edit { preferences -> - preferences[DEVICE_ID_KEY] = deviceId.toString() - } - Timber.tag(TAG).d("Device ID saved successfully") - } - - /** - * Gets the device ID. - * - * **Note:** This is only applicable for BYOD device types. - */ - suspend fun getDeviceId(): Int? { - val deviceId = - context.deviceConfigStore.data - .map { preferences -> preferences[DEVICE_ID_KEY]?.toIntOrNull() } - .first() - Timber.tag(TAG).d("Retrieved device ID: $deviceId") - return deviceId - } - /** * Saves the access token */ 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..3669002 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,17 @@ class TrmnlDisplayRepository } else { null }, + percentCharged = + if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD) { + // Send battery percentage if available for BYOD devices only + // See following for context: + // https://github.com/usetrmnl/trmnl-android/issues/252 + // https://github.com/usetrmnl/trmnl-android/issues/239 + // https://discord.com/channels/1281055965508141100/1466030731770855434/1469103763846463620 + androidDeviceInfoProvider.getBatteryLevel() + } else { + null + }, ) when (result) { @@ -384,6 +392,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 +408,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 @@ -410,7 +434,7 @@ class TrmnlDisplayRepository sleepModeEnabled = false, sleepStartTime = 1320, sleepEndTime = 480, - percentCharged = 100.0, + percentCharged = 100, wifiStrength = 100.0, ) @@ -438,20 +462,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 +517,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 @@ -524,7 +567,7 @@ class TrmnlDisplayRepository return Result.success(Unit) } - val updateRequest = TrmnlDeviceUpdateRequest(percentCharged = batteryPercent.toDouble()) + val updateRequest = TrmnlDeviceUpdateRequest(percentCharged = batteryPercent) val apiUrl = constructApiUrl(config.apiBaseUrl, DEVICE_API_PATH.replace("{id}", deviceId.toString())) val result = @@ -545,5 +588,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..33926a1 100644 --- a/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt +++ b/app/src/main/java/ink/trmnl/android/model/TrmnlDeviceConfig.kt @@ -33,21 +33,4 @@ data class TrmnlDeviceConfig constructor( * See https://github.com/usetrmnl/trmnl-android/issues/190 */ val isMasterDevice: Boolean? = null, - /** - * User-level API token (Account API key) for user-level endpoints. - * 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. - */ - val userApiToken: String? = null, - /** - * TRMNL device ID extracted from /api/devices/me endpoint. - * Used for making user-level API calls to /api/devices/{id}. - * - * This ID is fetched during BYOD device validation and is required for - * reporting battery status and other device-specific updates. - * - * **Note:** This field is only applicable for BYOD device types. - */ - 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..9add287 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: Int? = 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..9e635cb 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,10 +29,14 @@ 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, @Json(name = "sleep_start_time") val sleepStartTime: Int? = null, @Json(name = "sleep_end_time") val sleepEndTime: Int? = null, - @Json(name = "percent_charged") val percentCharged: Double? = null, + @Json(name = "percent_charged") val percentCharged: Int? = 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..1775ee2 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 @@ -143,7 +143,6 @@ data class AppSettingsScreen( val accessToken: String, val deviceMacId: String, val isByodMasterDevice: Boolean, - val userApiToken: String, val usesFakeApiData: Boolean, val isLoading: Boolean = false, val validationResult: ValidationResult? = null, @@ -197,18 +196,6 @@ data class AppSettingsScreen( val token: String, ) : Event() - /** - * Event triggered when the user API token is changed. - */ - data class UserApiTokenChanged( - val token: String, - ) : Event() - - /** - * Event triggered to validate the current user API token. - */ - data object ValidateUserToken : Event() - /** * Event triggered to validate the current access token. */ @@ -289,7 +276,6 @@ class AppSettingsPresenter var accessToken by remember { mutableStateOf("") } var deviceMacId by remember { mutableStateOf("") } var isByodMasterDevice by remember { mutableStateOf(true) } - var userApiToken by remember { mutableStateOf("") } var isLoading by remember { mutableStateOf(false) } var validationResult by remember { mutableStateOf(null) } var isDeviceSetupLoading by remember { mutableStateOf(false) } @@ -352,7 +338,6 @@ class AppSettingsPresenter // Load BYOD-specific settings if (it.type == TrmnlDeviceType.BYOD) { isByodMasterDevice = it.isMasterDevice ?: true - userApiToken = it.userApiToken ?: "" } } } @@ -363,7 +348,6 @@ class AppSettingsPresenter accessToken = accessToken, deviceMacId = deviceMacId, isByodMasterDevice = isByodMasterDevice, - userApiToken = userApiToken, usesFakeApiData = usesFakeApiData, isLoading = isLoading, validationResult = validationResult, @@ -380,68 +364,6 @@ class AppSettingsPresenter deviceSetupMessage = null } - is AppSettingsScreen.Event.UserApiTokenChanged -> { - userApiToken = event.token - // Clear previous validation when user token changes - if (validationResult is ValidationResult.UserTokenSuccess || - validationResult is ValidationResult.InvalidUserToken - ) { - validationResult = null - } - } - - AppSettingsScreen.Event.ValidateUserToken -> { - scope.launch { - focusManager.clearFocus() - isLoading = true - - // Clear previous user token validation - if (validationResult is ValidationResult.UserTokenSuccess || - validationResult is ValidationResult.InvalidUserToken - ) { - validationResult = null - } - - // Validate user API token by calling /api/me - val result = - displayRepository.validateUserApiToken( - apiBaseUrl = serverBaseUrl.forDevice(deviceType), - userApiToken = userApiToken, - ) - - validationResult = - when { - result.isSuccess -> { - val user = result.getOrNull() - if (user != null) { - // Token is valid - user will save it via "Save and Continue" - ValidationResult.UserTokenSuccess( - userName = user.name, - userEmail = user.email, - ) - } else { - Timber.e( - "validateUserApiToken succeeded but returned null user. " + - "apiBaseUrl=%s, deviceType=%s", - serverBaseUrl.forDevice(deviceType), - deviceType, - ) - ValidationResult.InvalidUserToken( - "Unexpected error: API returned success but no user data was received", - ) - } - } - else -> { - ValidationResult.InvalidUserToken( - result.exceptionOrNull()?.message ?: "Invalid user API token", - ) - } - } - - isLoading = false - } - } - AppSettingsScreen.Event.ValidateToken -> { scope.launch { focusManager.clearFocus() @@ -513,23 +435,6 @@ class AppSettingsPresenter response.imageUrl, response.refreshIntervalSeconds ?: DEFAULT_REFRESH_INTERVAL_SEC, ) - - // For BYOD devices, also fetch and save the device ID - if (deviceType == TrmnlDeviceType.BYOD) { - val deviceIdResult = displayRepository.getDeviceIdFromApi(deviceConfig) - if (deviceIdResult.isSuccess) { - val deviceId = deviceIdResult.getOrNull() - if (deviceId != null) { - deviceConfigStore.saveDeviceId(deviceId) - Timber.d("Device ID saved successfully for BYOD device: $deviceId") - } - } else { - Timber.w( - "Failed to fetch device ID for BYOD device. Error: %s", - deviceIdResult.exceptionOrNull(), - ) - } - } } else { // No error but also no image URL val errorMessage = response.error ?: "" @@ -552,14 +457,6 @@ class AppSettingsPresenter TrmnlDeviceType.TRMNL -> false } - // For BYOD devices, retrieve the device ID that was fetched during validation - val deviceId = - if (deviceType == TrmnlDeviceType.BYOD) { - deviceConfigStore.getDeviceId() - } else { - null - } - deviceConfigStore.saveDeviceConfig( TrmnlDeviceConfig( type = deviceType, @@ -569,13 +466,6 @@ class AppSettingsPresenter // Normalize the MAC address to standard format if provided in different format deviceMacId = normalizeMacAddress(deviceMacId)?.ifBlank { null }, isMasterDevice = isMaster, - // Save user API token if provided (for BYOD). - // Note: user token validation is optional and may be skipped by the user. - // We still persist the token here; any invalid or expired token will be - // detected and surfaced via downstream API error handling. - userApiToken = userApiToken.ifBlank { null }, - // Include device ID for BYOD devices (fetched during validation) - deviceId = deviceId, ), ) trmnlWorkScheduler.updateRefreshInterval(result.refreshRateSecs) @@ -813,104 +703,6 @@ fun AppSettingsContent( deviceIdError = (state.validationResult as? ValidationResult.InvalidDeviceMacId)?.message, ) - // User API Token field (only for BYOD) - AnimatedVisibility( - visible = state.deviceType == TrmnlDeviceType.BYOD, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Column { - Spacer(modifier = Modifier.height(16.dp)) - - var userTokenVisible by remember { mutableStateOf(false) } - - OutlinedTextField( - value = state.userApiToken, - onValueChange = { state.eventSink(AppSettingsScreen.Event.UserApiTokenChanged(it)) }, - label = { Text("User API Token (Account Key)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - visualTransformation = if (userTokenVisible) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - keyboardActions = - KeyboardActions( - onDone = { - state.eventSink(AppSettingsScreen.Event.ValidateUserToken) - }, - ), - supportingText = { - Text( - "Optional: This token is needed for device management features like battery reporting. Get this from your TRMNL user account settings.", - ) - }, - trailingIcon = { - IconButton(onClick = { userTokenVisible = !userTokenVisible }) { - Icon( - painter = - painterResource( - if (userTokenVisible) R.drawable.visibility_off_24dp else R.drawable.visibility_24dp, - ), - contentDescription = if (userTokenVisible) "Hide user token" else "Show user token", - ) - } - }, - isError = state.validationResult is ValidationResult.InvalidUserToken, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // Determine button state based on validation result - val isValidationSuccess = state.validationResult is ValidationResult.UserTokenSuccess - val isValidationError = state.validationResult is ValidationResult.InvalidUserToken - - Button( - onClick = { state.eventSink(AppSettingsScreen.Event.ValidateUserToken) }, - enabled = state.userApiToken.isNotBlank() && !state.isLoading, - modifier = Modifier.fillMaxWidth(), - colors = - when { - isValidationSuccess -> - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - isValidationError -> - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ) - else -> ButtonDefaults.buttonColors() - }, - ) { - Text("Validate User Token") - when { - isValidationSuccess -> { - Spacer(modifier = Modifier.width(8.dp)) - Icon( - painter = painterResource(R.drawable.check_circle_24dp), - contentDescription = "Validation successful", - modifier = Modifier.size(20.dp), - ) - } - isValidationError -> { - Spacer(modifier = Modifier.width(8.dp)) - Icon( - painter = painterResource(R.drawable.error_24dp), - contentDescription = "Validation failed", - modifier = Modifier.size(20.dp), - ) - } - } - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - // Password field with toggle visibility button OutlinedTextField( value = state.accessToken, @@ -1534,7 +1326,6 @@ private fun PreviewAppSettingsContentInitial() { accessToken = "", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, - userApiToken = "", usesFakeApiData = true, isLoading = false, validationResult = null, @@ -1557,7 +1348,6 @@ private fun PreviewAppSettingsContentLoading() { accessToken = "some-token", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, - userApiToken = "", usesFakeApiData = false, isLoading = true, validationResult = null, @@ -1580,7 +1370,6 @@ private fun PreviewAppSettingsContentSuccess() { accessToken = "valid-token-123", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, - userApiToken = "", usesFakeApiData = false, isLoading = false, validationResult = @@ -1607,7 +1396,6 @@ private fun PreviewAppSettingsContentFailure() { accessToken = "invalid-token", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, - userApiToken = "", usesFakeApiData = false, isLoading = false, validationResult = @@ -1637,7 +1425,6 @@ private fun PreviewAppSettingsContentWithWork() { accessToken = "valid-token-123", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, - userApiToken = "", usesFakeApiData = false, isLoading = false, validationResult = null, // Can also be Success state @@ -1670,7 +1457,6 @@ private fun PreviewWorkScheduleStatusCardScheduled() { accessToken = "some-token", deviceMacId = "AA:BB:CC:DD:EE:FF", isByodMasterDevice = true, - userApiToken = "", usesFakeApiData = false, nextRefreshJobInfo = NextImageRefreshDisplayInfo( @@ -1697,7 +1483,6 @@ private fun PreviewWorkScheduleStatusCardNoWork() { accessToken = "some-token", deviceMacId = "aa:bb:cc:dd:ee:ff", isByodMasterDevice = true, - userApiToken = "", usesFakeApiData = false, nextRefreshJobInfo = null, eventSink = {}, @@ -1726,7 +1511,6 @@ private fun PreviewAppSettingsContentByod() { accessToken = "byod-access-token-here", deviceMacId = "", isByodMasterDevice = false, - userApiToken = "user_test123", usesFakeApiData = false, isLoading = false, validationResult = null, @@ -1750,7 +1534,6 @@ private fun PreviewAppSettingsContentByos() { accessToken = "byos-access-token-here", deviceMacId = "AA:BB:CC:DD:EE:FF", isByodMasterDevice = true, - userApiToken = "", usesFakeApiData = false, isLoading = false, validationResult = null, diff --git a/app/src/main/java/ink/trmnl/android/ui/settings/DeviceTypeInfoTexts.kt b/app/src/main/java/ink/trmnl/android/ui/settings/DeviceTypeInfoTexts.kt index c91adbd..9da7985 100644 --- a/app/src/main/java/ink/trmnl/android/ui/settings/DeviceTypeInfoTexts.kt +++ b/app/src/main/java/ink/trmnl/android/ui/settings/DeviceTypeInfoTexts.kt @@ -151,7 +151,7 @@ internal fun ByosDeviceTypeInfoText() { val linkStyle = SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline) val annotatedString = buildAnnotatedString { - append("Bring your own server (BYOS) config. Only secure HTTPS URL supported. ") + append("Bring your own server (BYOS) config.") withLink( LinkAnnotation.Url( 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/TrmnlDeviceConfigDataStoreTest.kt b/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt index 95a9a33..672c99f 100644 --- a/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt +++ b/app/src/test/java/ink/trmnl/android/data/TrmnlDeviceConfigDataStoreTest.kt @@ -626,100 +626,4 @@ class TrmnlDeviceConfigDataStoreTest { val preferences = deviceConfigDataStore.deviceModelPreferencesFlow.first() assertThat(preferences).isEmpty() } - - @Test - fun `saveDeviceId and getDeviceId work correctly`() = - runTest { - // Arrange - val expectedDeviceId = 12345 - - // Act - deviceConfigDataStore.saveDeviceId(expectedDeviceId) - val retrievedDeviceId = deviceConfigDataStore.getDeviceId() - - // Assert - assertThat(retrievedDeviceId).isEqualTo(expectedDeviceId) - } - - @Test - fun `getDeviceId returns null when not saved`() = - runTest { - // Act - val deviceId = deviceConfigDataStore.getDeviceId() - - // Assert - assertThat(deviceId).isNull() - } - - @Test - fun `saveDeviceConfig persists deviceId to both JSON and legacy storage`() = - runTest { - // Arrange - val configWithDeviceId = - TrmnlDeviceConfig( - type = TrmnlDeviceType.BYOD, - apiAccessToken = "test-token", - apiBaseUrl = "https://trmnl.com", - userApiToken = "user_test_token", - deviceId = 999, - ) - - // Act - deviceConfigDataStore.saveDeviceConfig(configWithDeviceId) - - // Assert - Verify device ID is persisted via getDeviceId (legacy storage) - val deviceId = deviceConfigDataStore.getDeviceId() - assertThat(deviceId).isEqualTo(999) - - // Assert - Verify device ID is persisted via deviceConfigFlow (JSON storage) - val loadedConfig = deviceConfigDataStore.deviceConfigFlow.first() - assertThat(loadedConfig).isNotNull() - assertThat(loadedConfig?.deviceId).isEqualTo(999) - } - - @Test - fun `deviceConfigFlow loads deviceId from legacy storage when JSON not present`() = - runTest { - // Arrange - Save individual fields (legacy approach) without JSON - deviceConfigDataStore.saveDeviceType(TrmnlDeviceType.BYOD) - deviceConfigDataStore.saveAccessToken("test-token") - deviceConfigDataStore.saveServerUrl("https://trmnl.com") - deviceConfigDataStore.saveDeviceId(777) - - // Act - val loadedConfig = deviceConfigDataStore.deviceConfigFlow.first() - - // Assert - assertThat(loadedConfig).isNotNull() - assertThat(loadedConfig?.deviceId).isEqualTo(777) - assertThat(loadedConfig?.type).isEqualTo(TrmnlDeviceType.BYOD) - } - - @Test - fun `saveDeviceConfig removes deviceId from storage when null`() = - runTest { - // Arrange - First save config with deviceId - val configWithDeviceId = - TrmnlDeviceConfig( - type = TrmnlDeviceType.BYOD, - apiAccessToken = "test-token", - apiBaseUrl = "https://trmnl.com", - deviceId = 123, - ) - deviceConfigDataStore.saveDeviceConfig(configWithDeviceId) - - // Verify it was saved - assertThat(deviceConfigDataStore.getDeviceId()).isEqualTo(123) - - // Act - Now save config without deviceId - val configWithoutDeviceId = configWithDeviceId.copy(deviceId = null) - deviceConfigDataStore.saveDeviceConfig(configWithoutDeviceId) - - // Assert - DeviceId should be removed - val deviceId = deviceConfigDataStore.getDeviceId() - assertThat(deviceId).isNull() - - val loadedConfig = deviceConfigDataStore.deviceConfigFlow.first() - assertThat(loadedConfig?.deviceId).isNull() - } } 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..1310e70 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,15 +601,14 @@ 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 { // Arrange - val byodConfigWithDeviceId = - byodDeviceConfig.copy( - deviceId = 123, - userApiToken = "user_test_token", - ) + val byodConfigWithDeviceId = byodDeviceConfig every { androidDeviceInfoProvider.getBatteryLevel() } returns 75 @@ -623,20 +630,18 @@ class TrmnlDisplayRepositoryTest { userApiService.updateDevice( fullApiUrl = expectedApiUrl, accessToken = "Bearer user_test_token", - updateRequest = match { it.percentCharged == 75.0 }, + updateRequest = match { it.percentCharged == 75 }, ) } } + // 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 { // Arrange - TRMNL device (not BYOD) - val trmnlConfig = - testDeviceConfig.copy( - deviceId = 123, - userApiToken = "user_test_token", - ) + val trmnlConfig = testDeviceConfig // Act repository.reportDeviceBatteryStatus(trmnlConfig) @@ -646,15 +651,13 @@ 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 { // Arrange - val configWithoutDeviceId = - byodDeviceConfig.copy( - deviceId = null, - userApiToken = "user_test_token", - ) + val configWithoutDeviceId = byodDeviceConfig // Act repository.reportDeviceBatteryStatus(configWithoutDeviceId) @@ -664,15 +667,13 @@ 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 { // Arrange - val configWithoutUserToken = - byodDeviceConfig.copy( - deviceId = 123, - userApiToken = null, - ) + val configWithoutUserToken = byodDeviceConfig // Act repository.reportDeviceBatteryStatus(configWithoutUserToken) @@ -682,15 +683,13 @@ 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 { // Arrange - val byodConfigWithDeviceId = - byodDeviceConfig.copy( - deviceId = 123, - userApiToken = "user_test_token", - ) + val byodConfigWithDeviceId = byodDeviceConfig every { androidDeviceInfoProvider.getBatteryLevel() } returns null @@ -710,13 +709,13 @@ class TrmnlDisplayRepositoryTest { // Arrange val byodConfig = byodDeviceConfig.copy( - deviceId = null, - userApiToken = "test_token", 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 +724,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = expectedRssi, + percentCharged = expectedBattery, ) } returns ApiResult.success(mockk(relaxed = true)) @@ -740,6 +740,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = expectedRssi, + percentCharged = expectedBattery, ) } } @@ -750,14 +751,14 @@ class TrmnlDisplayRepositoryTest { // Arrange val byodConfig = byodDeviceConfig.copy( - deviceId = null, - 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 +773,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = null, + percentCharged = expectedBattery, ) } } @@ -785,7 +787,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 +802,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = null, + percentCharged = null, ) } } @@ -833,6 +836,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, + ) + } 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, + ) + } + } + + @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 +1003,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 +1023,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 +1035,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = strongSignal, + percentCharged = expectedBattery, ) } returns ApiResult.success(mockk(relaxed = true)) @@ -885,6 +1050,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = strongSignal, + percentCharged = expectedBattery, ) } } @@ -895,8 +1061,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 +1073,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = weakSignal, + percentCharged = expectedBattery, ) } returns ApiResult.success(mockk(relaxed = true)) @@ -919,6 +1088,7 @@ class TrmnlDisplayRepositoryTest { deviceMacId = any(), useBase64 = any(), rssi = weakSignal, + percentCharged = expectedBattery, ) } } 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