feat: Add BYOD device ID fetching and battery reporting (#246)

- Add /api/devices/me endpoint to TrmnlApiService for device info retrieval
- Add deviceId field to TrmnlDeviceConfig model with documentation
- Extend TrmnlDeviceConfigDataStore with deviceId persistence (dual-storage)
- Add getDeviceIdFromApi() to repository (mocked until server endpoint exists)
- Add getBatteryLevel() and reportBatteryStatus() for battery management
- Integrate battery reporting in display fetch methods (non-blocking)
- Update AppSettingsScreen to fetch deviceId during BYOD validation
- Update TrmnlDisplayRepositoryTest with Context mock dependency

Battery reporting is BYOD-only, requires deviceId and userApiToken.
Runs asynchronously after successful display fetches without blocking UI.
This commit is contained in:
Hossain Khan
2026-01-31 13:21:51 -05:00
parent 4227e32f05
commit 7976e77509
6 changed files with 261 additions and 1 deletions
@@ -100,6 +100,7 @@ class TrmnlDeviceConfigDataStore
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")
}
@@ -261,9 +262,10 @@ class TrmnlDeviceConfigDataStore
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()}",
"Loading device config (legacy): type=$type, deviceApiToken=${token.obfuscated()}, deviceId=$deviceId",
)
if (token != null) {
@@ -275,6 +277,7 @@ class TrmnlDeviceConfigDataStore
refreshRateSecs = refreshRate,
isMasterDevice = isMasterDevice,
userApiToken = userApiToken,
deviceId = deviceId,
)
} else {
null
@@ -335,6 +338,11 @@ class TrmnlDeviceConfigDataStore
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) {
@@ -374,6 +382,29 @@ class TrmnlDeviceConfigDataStore
return token
}
/**
* Saves the device ID (TRMNL device ID from /api/devices/me)
*/
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
*/
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
*/
@@ -1,5 +1,7 @@
package ink.trmnl.android.data
import android.content.Context
import android.os.BatteryManager
import com.slack.eithernet.ApiResult
import com.slack.eithernet.exceptionOrNull
import com.squareup.anvil.annotations.optional.SingleIn
@@ -7,6 +9,7 @@ import ink.trmnl.android.BuildConfig.USE_FAKE_API
import ink.trmnl.android.data.fake.generateFakeDeviceSetupInfo
import ink.trmnl.android.data.fake.generateFakeTrmnlDisplayInfo
import ink.trmnl.android.di.AppScope
import ink.trmnl.android.di.ApplicationContext
import ink.trmnl.android.model.SupportedDeviceModel
import ink.trmnl.android.model.TrmnlDeviceConfig
import ink.trmnl.android.model.TrmnlDeviceType
@@ -15,8 +18,11 @@ 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
@@ -24,6 +30,9 @@ import ink.trmnl.android.network.util.extractHttpResponseMetadata
import ink.trmnl.android.network.util.extractHttpResponseMetadataFromFailure
import ink.trmnl.android.util.HTTP_500
import ink.trmnl.android.util.isHttpOk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -40,11 +49,26 @@ import javax.inject.Inject
class TrmnlDisplayRepository
@Inject
constructor(
@ApplicationContext private val context: Context,
private val apiService: TrmnlApiService,
private val userApiService: TrmnlUserApiService,
private val imageMetadataStore: ImageMetadataStore,
private val repositoryConfigProvider: RepositoryConfigProvider,
) {
/**
* Gets the current battery level of the Android device.
*
* @return Battery percentage (0-100), or null if unable to retrieve
*/
private fun getBatteryLevel(): Int? =
try {
val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
} catch (e: Exception) {
Timber.e(e, "Failed to get battery level")
null
}
/**
* Fetches display data for next plugin from the server using the provided access token.
* If the app is in debug mode, it uses mock data instead.
@@ -101,6 +125,19 @@ class TrmnlDisplayRepository
displayInfo.imageUrl,
displayInfo.refreshIntervalSeconds,
)
// Report battery status for BYOD devices (non-blocking)
if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD &&
trmnlDeviceConfig.deviceId != null &&
trmnlDeviceConfig.userApiToken != null
) {
val batteryLevel = getBatteryLevel()
if (batteryLevel != null) {
CoroutineScope(Dispatchers.IO).launch {
reportBatteryStatus(trmnlDeviceConfig, batteryLevel)
}
}
}
}
return displayInfo
@@ -161,6 +198,19 @@ class TrmnlDisplayRepository
displayInfo.imageUrl,
displayInfo.refreshIntervalSeconds,
)
// Report battery status for BYOD devices (non-blocking)
if (trmnlDeviceConfig.type == TrmnlDeviceType.BYOD &&
trmnlDeviceConfig.deviceId != null &&
trmnlDeviceConfig.userApiToken != null
) {
val batteryLevel = getBatteryLevel()
if (batteryLevel != null) {
CoroutineScope(Dispatchers.IO).launch {
reportBatteryStatus(trmnlDeviceConfig, batteryLevel)
}
}
}
}
return displayInfo
@@ -368,4 +418,123 @@ class TrmnlDisplayRepository
}
}
}
/**
* Fetches the device ID from the TRMNL API using the device API token.
*
* 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}.
*
* **Note:** This endpoint doesn't exist on the server yet, so this method
* returns a mocked response until the server endpoint is implemented.
*
* @param config Device configuration containing the device API token
* @return A Result containing the device ID on success or an exception on failure
*/
suspend fun getDeviceIdFromApi(config: TrmnlDeviceConfig): Result<Int> {
Timber.i("Fetching device ID from API for device type: ${config.type}")
// Always use mocked response since the endpoint doesn't exist yet
// TODO: Remove this mock when the server endpoint is implemented
val mockedDevice =
TrmnlDevice(
id = 1,
name = "BYOD TRMNL",
friendlyId = "_____",
macAddress = "********",
batteryVoltage = null,
rssi = null,
sleepModeEnabled = false,
sleepStartTime = 1320,
sleepEndTime = 480,
percentCharged = 100.0,
wifiStrength = 100.0,
)
Timber.i("Using mocked device ID: ${mockedDevice.id}")
return Result.success(mockedDevice.id)
/*
* TODO: Uncomment this when the server endpoint is implemented:
*
* val result = apiService.getDeviceMe(
* fullApiUrl = constructApiUrl(config.apiBaseUrl, DEVICE_ME_API_PATH),
* accessToken = config.apiAccessToken,
* )
*
* return when (result) {
* is ApiResult.Failure -> {
* val exception = result.exceptionOrNull()
* Timber.e(exception, "Failed to fetch device ID")
* Result.failure(exception ?: Exception("Failed to fetch device ID"))
* }
* is ApiResult.Success -> {
* val deviceId = result.value.data.id
* Timber.i("Device ID fetched successfully: $deviceId")
* Result.success(deviceId)
* }
* }
*/
}
/**
* 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.
*
* This operation is non-blocking and should not affect display updates.
*
* @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
*/
suspend fun reportBatteryStatus(
config: TrmnlDeviceConfig,
batteryPercent: Int,
): Result<Unit> {
val deviceId = config.deviceId
val userApiToken = config.userApiToken
if (deviceId == null) {
Timber.w("Cannot report battery status: device ID is null")
return Result.failure(IllegalStateException("Device ID is required"))
}
if (userApiToken == null) {
Timber.w("Cannot report battery status: user API token is null")
return Result.failure(IllegalStateException("User API token is required"))
}
Timber.d("Reporting battery status: $batteryPercent% for device ID: $deviceId")
if (repositoryConfigProvider.shouldUseFakeData) {
// Skip API call in debug mode
Timber.d("Skipping battery status report (fake API mode)")
return Result.success(Unit)
}
val updateRequest = TrmnlDeviceUpdateRequest(percentCharged = batteryPercent.toDouble())
val apiUrl = constructApiUrl(config.apiBaseUrl, DEVICE_API_PATH.replace("{id}", deviceId.toString()))
val result =
userApiService.updateDevice(
fullApiUrl = apiUrl,
accessToken = "Bearer $userApiToken",
updateRequest = updateRequest,
)
return when (result) {
is ApiResult.Failure -> {
val exception = result.exceptionOrNull()
Timber.e(exception, "Failed to report battery status")
Result.failure(exception ?: Exception("Failed to report battery status"))
}
is ApiResult.Success -> {
Timber.d("Battery status reported successfully")
Result.success(Unit)
}
}
}
}
@@ -40,4 +40,12 @@ data class TrmnlDeviceConfig constructor(
* 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.
*/
val deviceId: Int? = null,
)
@@ -3,6 +3,7 @@ package ink.trmnl.android.network
import com.slack.eithernet.ApiResult
import ink.trmnl.android.data.TrmnlDisplayRepository
import ink.trmnl.android.network.model.TrmnlCurrentImageResponse
import ink.trmnl.android.network.model.TrmnlDeviceResponse
import ink.trmnl.android.network.model.TrmnlDisplayResponse
import ink.trmnl.android.network.model.TrmnlModelsResponse
import ink.trmnl.android.network.model.TrmnlSetupResponse
@@ -65,6 +66,18 @@ interface TrmnlApiService {
* @see getDeviceModels
*/
internal const val MODELS_API_PATH = "api/models"
/**
* Path for the TRMNL API endpoint to get the device information.
*
* **Authentication:** Requires device-level Access-Token header
*
* **Note:** This endpoint doesn't exist on the server yet. The repository layer
* provides a mocked response until the server endpoint is implemented.
*
* @see getDeviceMe
*/
internal const val DEVICE_ME_API_PATH = "api/devices/me"
}
/**
@@ -132,4 +145,25 @@ interface TrmnlApiService {
suspend fun getDeviceModels(
@Url fullApiUrl: String,
): ApiResult<TrmnlModelsResponse, Unit>
/**
* Retrieve device information using [DEVICE_ME_API_PATH].
*
* This endpoint provides device details including the device ID, which is needed
* for making user-level API calls to `/api/devices/{id}`.
*
* **Authentication:** Requires device-level Access-Token header (device API key)
*
* **Note:** This endpoint doesn't exist on the server yet. The repository layer
* provides a mocked response until the server endpoint is implemented.
*
* @param fullApiUrl The complete API URL to call (e.g., "https://usetrmnl.com/api/devices/me")
* @param accessToken The device's API key (required)
* @return An [ApiResult] containing [TrmnlDeviceResponse] with the device information
*/
@GET
suspend fun getDeviceMe(
@Url fullApiUrl: String,
@Header("access-token") accessToken: String,
): ApiResult<TrmnlDeviceResponse, Unit>
}
@@ -513,6 +513,20 @@ 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", deviceIdResult.exceptionOrNull())
}
}
} else {
// No error but also no image URL
val errorMessage = response.error ?: ""
@@ -1,5 +1,6 @@
package ink.trmnl.android.data
import android.content.Context
import com.google.common.truth.Truth.assertThat
import com.slack.eithernet.ApiResult
import ink.trmnl.android.model.TrmnlDeviceConfig
@@ -28,6 +29,7 @@ import org.junit.Test
@OptIn(com.slack.eithernet.InternalEitherNetApi::class)
class TrmnlDisplayRepositoryTest {
private lateinit var repository: TrmnlDisplayRepository
private lateinit var context: Context
private lateinit var apiService: TrmnlApiService
private lateinit var userApiService: TrmnlUserApiService
private lateinit var imageMetadataStore: ImageMetadataStore
@@ -60,6 +62,7 @@ class TrmnlDisplayRepositoryTest {
@Before
fun setup() {
context = mockk(relaxed = true)
apiService = mockk()
userApiService = mockk()
repositoryConfigProvider = mockk()
@@ -70,6 +73,7 @@ class TrmnlDisplayRepositoryTest {
repository =
TrmnlDisplayRepository(
context = context,
apiService = apiService,
userApiService = userApiService,
imageMetadataStore = imageMetadataStore,