mirror of
https://github.com/usetrmnl/trmnl-android.git
synced 2026-04-29 13:35:26 -07:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user