diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55f6b9c..1bbe774 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") } 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 3f285fc..15ed805 100644 --- a/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt +++ b/app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt @@ -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 + + /** + * 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 + + /** + * 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 } diff --git a/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceResponse.kt b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceResponse.kt new file mode 100644 index 0000000..e38cd99 --- /dev/null +++ b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceResponse.kt @@ -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, +) 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 new file mode 100644 index 0000000..f9d5971 --- /dev/null +++ b/app/src/main/java/ink/trmnl/android/network/model/TrmnlDeviceUpdateRequest.kt @@ -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, +) diff --git a/app/src/test/java/ink/trmnl/android/network/model/TrmnlDeviceResponseTest.kt b/app/src/test/java/ink/trmnl/android/network/model/TrmnlDeviceResponseTest.kt new file mode 100644 index 0000000..ad1eb5b --- /dev/null +++ b/app/src/test/java/ink/trmnl/android/network/model/TrmnlDeviceResponseTest.kt @@ -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 + } +} diff --git a/app/src/test/resources/device_response.json b/app/src/test/resources/device_response.json new file mode 100644 index 0000000..6cd0194 --- /dev/null +++ b/app/src/test/resources/device_response.json @@ -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 + } +} diff --git a/app/src/test/resources/device_response_edge_cases.json b/app/src/test/resources/device_response_edge_cases.json new file mode 100644 index 0000000..c79e554 --- /dev/null +++ b/app/src/test/resources/device_response_edge_cases.json @@ -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 + } +} diff --git a/app/src/test/resources/device_response_nullable_fields.json b/app/src/test/resources/device_response_nullable_fields.json new file mode 100644 index 0000000..c1f56ec --- /dev/null +++ b/app/src/test/resources/device_response_nullable_fields.json @@ -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 + } +}