Merge pull request #242 from usetrmnl/feat/239-add-battery-update

Add device management API endpoints with comprehensive tests
This commit is contained in:
Hossain Khan
2026-01-31 08:48:11 -05:00
committed by GitHub
10 changed files with 1631 additions and 2 deletions
@@ -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
}
}
+25
View File
@@ -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