mirror of
https://github.com/usetrmnl/trmnl-android.git
synced 2026-04-29 13:35:26 -07:00
Merge pull request #242 from usetrmnl/feat/239-add-battery-update
Add device management API endpoints with comprehensive tests
This commit is contained in:
@@ -12,15 +12,20 @@ import retrofit2.http.Headers
|
||||
import retrofit2.http.Url
|
||||
|
||||
/**
|
||||
* API service interface for TRMNL or BYOS servers.
|
||||
* API service interface for TRMNL device-level API endpoints.
|
||||
*
|
||||
* This interface defines the endpoints for the TRMNL API.
|
||||
* This interface defines endpoints that require device-level authentication via
|
||||
* Access-Token header (Device API key), as opposed to user-level Bearer token authentication.
|
||||
*
|
||||
* For user-level (account) API endpoints, see [TrmnlUserApiService].
|
||||
*
|
||||
* See:
|
||||
* - https://docs.usetrmnl.com/go
|
||||
* - https://docs.usetrmnl.com/go/private-api/introduction
|
||||
* - https://trmnl.com/api-docs/index.html (OpenAPI documentation)
|
||||
*
|
||||
* @see TrmnlDisplayRepository
|
||||
* @see TrmnlUserApiService
|
||||
*/
|
||||
interface TrmnlApiService {
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package ink.trmnl.android.network
|
||||
|
||||
import com.slack.eithernet.ApiResult
|
||||
import ink.trmnl.android.network.model.TrmnlDeviceResponse
|
||||
import ink.trmnl.android.network.model.TrmnlDeviceUpdateRequest
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.Url
|
||||
|
||||
/**
|
||||
* API service interface for TRMNL user-level (account) API endpoints.
|
||||
*
|
||||
* This interface defines endpoints that require user-level authentication via Bearer token
|
||||
* (Account API key), as opposed to device-level authentication.
|
||||
*
|
||||
* See:
|
||||
* - https://docs.usetrmnl.com/go
|
||||
* - https://trmnl.com/api-docs/index.html (OpenAPI documentation)
|
||||
*/
|
||||
interface TrmnlUserApiService {
|
||||
companion object {
|
||||
/**
|
||||
* Path template for the TRMNL API endpoint to get or update a specific device.
|
||||
*
|
||||
* Replace `{id}` with the actual device ID.
|
||||
*
|
||||
* **Authentication:** Requires Bearer token (user-level Account API key)
|
||||
*
|
||||
* See: https://trmnl.com/api-docs/index.html#/Devices
|
||||
*
|
||||
* @see getDevice
|
||||
* @see updateDevice
|
||||
*/
|
||||
internal const val DEVICE_API_PATH = "api/devices/{id}"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* **Authentication:** Requires Bearer token with user-level Account API key
|
||||
*
|
||||
* @param fullApiUrl The complete API URL to call (e.g., "https://usetrmnl.com/api/devices/1")
|
||||
* @param accessToken The bearer authentication token (format: "Bearer your_api_key")
|
||||
* @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.
|
||||
*
|
||||
* **Authentication:** Requires Bearer token with user-level Account API key
|
||||
*
|
||||
* @param fullApiUrl The complete API URL to call (e.g., "https://usetrmnl.com/api/devices/1")
|
||||
* @param accessToken The bearer authentication token (format: "Bearer your_api_key")
|
||||
* @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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# TRMNL API Specification
|
||||
|
||||
This directory contains the OpenAPI specification for the TRMNL API.
|
||||
|
||||
## Resources
|
||||
|
||||
- **OpenAPI Portal:** https://trmnl.com/api-docs/index.html
|
||||
- **API Documentation:** https://docs.usetrmnl.com/go
|
||||
- **Private API Docs:** https://docs.usetrmnl.com/go/private-api/introduction
|
||||
|
||||
## Files
|
||||
|
||||
- `trmnl-openapi.yaml` - OpenAPI 3.0.1 specification exported from the TRMNL API portal
|
||||
|
||||
## Authentication
|
||||
|
||||
The TRMNL API uses two authentication methods:
|
||||
|
||||
1. **Device-level authentication** - Uses `Access-Token` header with device API key
|
||||
- Implemented in `TrmnlApiService.kt`
|
||||
- Used for device operations (display, setup, etc.)
|
||||
|
||||
2. **User-level authentication** - Uses `Authorization: Bearer` header with account API key
|
||||
- Implemented in `TrmnlUserApiService.kt`
|
||||
- Used for account operations (device management, plugin settings, etc.)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user