Merge pull request #253 from usetrmnl/feat/migrate-battery-to-percent-charged-header

Migrate battery reporting to Percent-Charged header
This commit is contained in:
Hossain Khan
2026-02-05 20:12:51 -05:00
committed by GitHub
12 changed files with 295 additions and 449 deletions
@@ -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
*/
@@ -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<Int> {
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<Unit> {
// 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)
}
}
*/
}
}
@@ -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,
)
@@ -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<TrmnlDisplayResponse, Unit>
/**
@@ -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 {
/**
@@ -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,
)
@@ -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<ValidationResult?>(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,
@@ -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(
@@ -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.
@@ -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()
}
}
@@ -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,
)
}
}
@@ -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