Add device management API endpoints with comprehensive tests

- Add GET /api/devices/{id} endpoint to retrieve device information
- Add PATCH /api/devices/{id} endpoint to update device settings
- Create TrmnlDeviceResponse and TrmnlDevice data models
- Create TrmnlDeviceUpdateRequest for PATCH operations
- Add comprehensive test suite with 11 unit tests covering:
  * JSON parsing and deserialization
  * Nullable field handling
  * Edge case values (0, max values, midnight times)
  * Field type validation (Double, Int, Boolean)
  * JSON field mapping (snake_case to camelCase)
- Add test fixtures for normal, nullable, and edge case scenarios
- Update wifiStrength type from Int to Double per API schema
- All tests passing (11/11)
This commit is contained in:
Hossain Khan
2026-01-30 17:36:10 -05:00
parent 000a575e3d
commit 419af89ba5
8 changed files with 434 additions and 1 deletions
+1 -1
View File
@@ -102,7 +102,7 @@ android {
// Allow developers to configure this value for debug builds
// Use fake API response for local development and testing purposes.
// ️ To override during local development, change the value in `RepositoryConfigProvider`
buildConfigField("Boolean", "USE_FAKE_API", "true")
buildConfigField("Boolean", "USE_FAKE_API", "false")
signingConfig = signingConfigs.getByName("debug")
}
@@ -3,12 +3,16 @@ 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.TrmnlDeviceUpdateRequest
import ink.trmnl.android.network.model.TrmnlDisplayResponse
import ink.trmnl.android.network.model.TrmnlModelsResponse
import ink.trmnl.android.network.model.TrmnlSetupResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.PATCH
import retrofit2.http.Url
/**
@@ -60,6 +64,16 @@ interface TrmnlApiService {
* @see getDeviceModels
*/
internal const val MODELS_API_PATH = "api/models"
/**
* Path template for the TRMNL API endpoint to get or update a specific device.
*
* Replace `{id}` with the actual device ID.
*
* @see getDevice
* @see updateDevice
*/
internal const val DEVICE_API_PATH = "api/devices/{id}"
}
/**
@@ -127,4 +141,39 @@ interface TrmnlApiService {
suspend fun getDeviceModels(
@Url fullApiUrl: String,
): ApiResult<TrmnlModelsResponse, Unit>
/**
* Retrieve device data for a specific device using [DEVICE_API_PATH].
*
* This endpoint provides information about a single device including its configuration,
* battery status, WiFi strength, and sleep mode settings.
*
* @param fullApiUrl The complete API URL to call (e.g., "https://usetrmnl.com/api/devices/1")
* @param accessToken The bearer authentication token
* @return An [ApiResult] containing [TrmnlDeviceResponse] with the device data
*/
@GET
suspend fun getDevice(
@Url fullApiUrl: String,
@Header("Authorization") accessToken: String,
): ApiResult<TrmnlDeviceResponse, Unit>
/**
* Update device settings for a specific device using [DEVICE_API_PATH].
*
* This endpoint allows updating device configuration such as sleep mode settings
* and battery charge percentage.
*
* @param fullApiUrl The complete API URL to call (e.g., "https://usetrmnl.com/api/devices/1")
* @param accessToken The bearer authentication token
* @param updateRequest The device update request containing the fields to update
* @return An [ApiResult] containing [TrmnlDeviceResponse] with the updated device data
*/
@Headers("Content-Type: application/json")
@PATCH
suspend fun updateDevice(
@Url fullApiUrl: String,
@Header("Authorization") accessToken: String,
@Body updateRequest: TrmnlDeviceUpdateRequest,
): ApiResult<TrmnlDeviceResponse, Unit>
}
@@ -0,0 +1,65 @@
package ink.trmnl.android.network.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Data class representing the response from the TRMNL device API.
*
* Sample JSON response:
* ```json
* {
* "data": {
* "id": 1,
* "name": "BYOD TRMNL",
* "friendly_id": "_____",
* "mac_address": "********",
* "battery_voltage": null,
* "rssi": null,
* "sleep_mode_enabled": false,
* "sleep_start_time": 1320,
* "sleep_end_time": 480,
* "percent_charged": 100,
* "wifi_strength": 100
* }
* }
* ```
*
* @property data The device data object.
* @see ink.trmnl.android.network.TrmnlApiService.getDevice
* @see ink.trmnl.android.network.TrmnlApiService.updateDevice
*/
@JsonClass(generateAdapter = true)
data class TrmnlDeviceResponse(
@Json(name = "data") val data: TrmnlDevice,
)
/**
* Data class representing a TRMNL device.
*
* @property id The unique identifier for the device (e.g., 123).
* @property name The name of the device (e.g., "My TRMNL").
* @property friendlyId A user-friendly identifier for the device (e.g., "ABC-123").
* @property macAddress The MAC address of the device (e.g., "12:34:56:78:9A:BC").
* @property batteryVoltage The battery voltage of the device in volts (e.g., 3.7), nullable.
* @property rssi The received signal strength indicator in dBm (e.g., -70), nullable.
* @property sleepModeEnabled Whether sleep mode is enabled (e.g., false), nullable in API response.
* @property sleepStartTime The time when sleep mode starts in minutes from midnight (e.g., 1320 = 10:00 PM), nullable.
* @property sleepEndTime The time when sleep mode ends in minutes from midnight (e.g., 480 = 8:00 AM), nullable.
* @property percentCharged The battery percentage charged (e.g., 85.0). Valid range: 0.0 to 100.0.
* @property wifiStrength The WiFi signal strength percentage (e.g., 75.0). Valid range: 0.0 to 100.0.
*/
@JsonClass(generateAdapter = true)
data class TrmnlDevice(
@Json(name = "id") val id: Int,
@Json(name = "name") val name: String,
@Json(name = "friendly_id") val friendlyId: String,
@Json(name = "mac_address") val macAddress: String,
@Json(name = "battery_voltage") val batteryVoltage: Double?,
@Json(name = "rssi") val rssi: Int?,
@Json(name = "sleep_mode_enabled") val sleepModeEnabled: Boolean?,
@Json(name = "sleep_start_time") val sleepStartTime: Int?,
@Json(name = "sleep_end_time") val sleepEndTime: Int?,
@Json(name = "percent_charged") val percentCharged: Double,
@Json(name = "wifi_strength") val wifiStrength: Double,
)
@@ -0,0 +1,33 @@
package ink.trmnl.android.network.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Data class representing a request to update a TRMNL device.
*
* All fields are optional - only include the fields you want to update.
*
* Sample JSON request:
* ```json
* {
* "sleep_mode_enabled": true,
* "sleep_start_time": 1320,
* "sleep_end_time": 480,
* "percent_charged": 69.0
* }
* ```
*
* @property sleepModeEnabled Whether sleep mode is enabled.
* @property sleepStartTime The time when sleep mode starts (minutes from midnight).
* @property sleepEndTime The time when sleep mode ends (minutes from midnight).
* @property percentCharged The battery percentage charged.
* @see ink.trmnl.android.network.TrmnlApiService.updateDevice
*/
@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,
)
@@ -0,0 +1,241 @@
package ink.trmnl.android.network.model
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import org.junit.Test
/**
* Unit tests for [TrmnlDeviceResponse] and [TrmnlDevice].
*
* These tests verify JSON parsing of the /api/devices endpoint responses.
*/
class TrmnlDeviceResponseTest {
private val moshi =
Moshi
.Builder()
.build()
private val deviceResponseAdapter = moshi.adapter(TrmnlDeviceResponse::class.java)
@Test
fun `parse device response from JSON successfully`() {
// Arrange - Load the test JSON from resources
val json =
javaClass.classLoader
?.getResourceAsStream("device_response.json")
?.bufferedReader()
?.use { it.readText() }
// Assert that we successfully loaded the JSON
assertThat(json).isNotNull()
// Act
val response = deviceResponseAdapter.fromJson(json!!)
// Assert
assertThat(response).isNotNull()
assertThat(response?.data).isNotNull()
}
@Test
fun `parse device with all fields correctly`() {
// Arrange
val json =
javaClass.classLoader
?.getResourceAsStream("device_response.json")
?.bufferedReader()
?.use { it.readText() }
// Act
val response = deviceResponseAdapter.fromJson(json!!)
val device = response?.data
// Assert - Verify all fields
assertThat(device).isNotNull()
assertThat(device?.id).isEqualTo(123)
assertThat(device?.name).isEqualTo("My TRMNL")
assertThat(device?.friendlyId).isEqualTo("ABC-123")
assertThat(device?.macAddress).isEqualTo("12:34:56:78:9A:BC")
assertThat(device?.batteryVoltage).isEqualTo(3.7)
assertThat(device?.rssi).isEqualTo(-70)
assertThat(device?.sleepModeEnabled).isEqualTo(false)
assertThat(device?.sleepStartTime).isEqualTo(1320)
assertThat(device?.sleepEndTime).isEqualTo(480)
assertThat(device?.percentCharged).isEqualTo(85.0)
assertThat(device?.wifiStrength).isEqualTo(75.0)
}
@Test
fun `parse device with nullable fields as null`() {
// Arrange
val json =
javaClass.classLoader
?.getResourceAsStream("device_response_nullable_fields.json")
?.bufferedReader()
?.use { it.readText() }
// Act
val response = deviceResponseAdapter.fromJson(json!!)
val device = response?.data
// Assert - Verify nullable fields are null
assertThat(device).isNotNull()
assertThat(device?.id).isEqualTo(456)
assertThat(device?.name).isEqualTo("Test Device")
assertThat(device?.batteryVoltage).isNull()
assertThat(device?.rssi).isNull()
assertThat(device?.sleepModeEnabled).isNull()
assertThat(device?.sleepStartTime).isNull()
assertThat(device?.sleepEndTime).isNull()
assertThat(device?.percentCharged).isEqualTo(100.0)
assertThat(device?.wifiStrength).isEqualTo(100.0)
}
@Test
fun `parse device with edge case values`() {
// Arrange
val json =
javaClass.classLoader
?.getResourceAsStream("device_response_edge_cases.json")
?.bufferedReader()
?.use { it.readText() }
// Act
val response = deviceResponseAdapter.fromJson(json!!)
val device = response?.data
// Assert - Verify edge case values
assertThat(device).isNotNull()
assertThat(device?.id).isEqualTo(789)
assertThat(device?.sleepModeEnabled).isEqualTo(true)
assertThat(device?.sleepStartTime).isEqualTo(0) // Midnight
assertThat(device?.sleepEndTime).isEqualTo(1439) // 11:59 PM
assertThat(device?.percentCharged).isEqualTo(0.0) // Minimum
assertThat(device?.wifiStrength).isEqualTo(0.0) // Minimum
}
@Test
fun `parse sleep time values correctly`() {
// Arrange
val json =
javaClass.classLoader
?.getResourceAsStream("device_response.json")
?.bufferedReader()
?.use { it.readText() }
// Act
val response = deviceResponseAdapter.fromJson(json!!)
val device = response?.data
// Assert - Verify time calculations
assertThat(device).isNotNull()
// 1320 minutes from midnight = 22:00 (10:00 PM)
assertThat(device?.sleepStartTime).isEqualTo(1320)
// 480 minutes from midnight = 08:00 (8:00 AM)
assertThat(device?.sleepEndTime).isEqualTo(480)
}
@Test
fun `parse battery and wifi percentages within valid range`() {
// Arrange
val json =
javaClass.classLoader
?.getResourceAsStream("device_response.json")
?.bufferedReader()
?.use { it.readText() }
// Act
val response = deviceResponseAdapter.fromJson(json!!)
val device = response?.data
// Assert - Verify percentages are in valid range (0-100)
assertThat(device).isNotNull()
assertThat(device?.percentCharged).isAtLeast(0.0)
assertThat(device?.percentCharged).isAtMost(100.0)
assertThat(device?.wifiStrength).isAtLeast(0.0)
assertThat(device?.wifiStrength).isAtMost(100.0)
}
@Test
fun `parse MAC address format correctly`() {
// Arrange
val json =
javaClass.classLoader
?.getResourceAsStream("device_response.json")
?.bufferedReader()
?.use { it.readText() }
// Act
val response = deviceResponseAdapter.fromJson(json!!)
val device = response?.data
// Assert - Verify MAC address format
assertThat(device).isNotNull()
assertThat(device?.macAddress).matches("[0-9A-Fa-f:]{17}")
assertThat(device?.macAddress).contains(":")
}
@Test
fun `parse battery voltage as double`() {
// Arrange
val json =
javaClass.classLoader
?.getResourceAsStream("device_response.json")
?.bufferedReader()
?.use { it.readText() }
// Act
val response = deviceResponseAdapter.fromJson(json!!)
val device = response?.data
// Assert - Verify battery voltage is a Double type
assertThat(device).isNotNull()
assertThat(device?.batteryVoltage).isInstanceOf(Double::class.java)
assertThat(device?.batteryVoltage).isGreaterThan(0.0)
}
@Test
fun `parse RSSI as negative integer`() {
// Arrange
val json =
javaClass.classLoader
?.getResourceAsStream("device_response.json")
?.bufferedReader()
?.use { it.readText() }
// Act
val response = deviceResponseAdapter.fromJson(json!!)
val device = response?.data
// Assert - Verify RSSI is typically negative (dBm)
assertThat(device).isNotNull()
assertThat(device?.rssi).isInstanceOf(Int::class.java)
assertThat(device?.rssi).isLessThan(0)
}
@Test
fun `verify all JSON field mappings`() {
// Arrange
val json =
javaClass.classLoader
?.getResourceAsStream("device_response.json")
?.bufferedReader()
?.use { it.readText() }
// Act
val response = deviceResponseAdapter.fromJson(json!!)
val device = response?.data
// Assert - Verify snake_case to camelCase mapping
assertThat(device).isNotNull()
// Verify that JSON snake_case fields are mapped correctly
assertThat(device?.friendlyId).isNotNull() // friendly_id -> friendlyId
assertThat(device?.macAddress).isNotNull() // mac_address -> macAddress
assertThat(device?.batteryVoltage).isNotNull() // battery_voltage -> batteryVoltage
assertThat(device?.sleepModeEnabled).isNotNull() // sleep_mode_enabled -> sleepModeEnabled
assertThat(device?.sleepStartTime).isNotNull() // sleep_start_time -> sleepStartTime
assertThat(device?.sleepEndTime).isNotNull() // sleep_end_time -> sleepEndTime
assertThat(device?.percentCharged).isNotNull() // percent_charged -> percentCharged
assertThat(device?.wifiStrength).isNotNull() // wifi_strength -> wifiStrength
}
}
@@ -0,0 +1,15 @@
{
"data": {
"id": 123,
"name": "My TRMNL",
"friendly_id": "ABC-123",
"mac_address": "12:34:56:78:9A:BC",
"battery_voltage": 3.7,
"rssi": -70,
"sleep_mode_enabled": false,
"sleep_start_time": 1320,
"sleep_end_time": 480,
"percent_charged": 85.0,
"wifi_strength": 75.0
}
}
@@ -0,0 +1,15 @@
{
"data": {
"id": 789,
"name": "Edge Case Device",
"friendly_id": "EDGE-789",
"mac_address": "00:00:00:00:00:00",
"battery_voltage": 4.2,
"rssi": -100,
"sleep_mode_enabled": true,
"sleep_start_time": 0,
"sleep_end_time": 1439,
"percent_charged": 0.0,
"wifi_strength": 0.0
}
}
@@ -0,0 +1,15 @@
{
"data": {
"id": 456,
"name": "Test Device",
"friendly_id": "TEST-456",
"mac_address": "AA:BB:CC:DD:EE:FF",
"battery_voltage": null,
"rssi": null,
"sleep_mode_enabled": null,
"sleep_start_time": null,
"sleep_end_time": null,
"percent_charged": 100.0,
"wifi_strength": 100.0
}
}