Skip to content

Commit eefd037

Browse files
authored
Merge pull request #84 from usetrmnl/76-byos_hanami-compatibility-part-2
[ADDED] Support for BYOS device setup and display image compatibility
2 parents 16878e9 + 6e4d781 commit eefd037

File tree

11 files changed

+337
-40
lines changed

11 files changed

+337
-40
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package ink.trmnl.android.data
2+
3+
/**
4+
* Represents the setup information for a TRMNL device.
5+
*
6+
* This data class is used to encapsulate the result of setting up a new device,
7+
* including whether the setup was successful, the device's MAC ID, API key,
8+
* and any relevant messages.
9+
*/
10+
data class DeviceSetupInfo(
11+
val success: Boolean,
12+
val deviceMacId: String,
13+
val apiKey: String,
14+
val message: String,
15+
)

app/src/main/java/ink/trmnl/android/data/TrmnlDisplayInfo.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ink.trmnl.android.data
33
import androidx.annotation.Keep
44
import ink.trmnl.android.data.AppConfig.DEFAULT_REFRESH_INTERVAL_SEC
55
import ink.trmnl.android.model.TrmnlDeviceType
6+
import ink.trmnl.android.util.ERROR_TYPE_DEVICE_SETUP_REQUIRED
67
import ink.trmnl.android.util.HTTP_200
78
import ink.trmnl.android.util.HTTP_500
89
import ink.trmnl.android.util.HTTP_OK
@@ -19,7 +20,14 @@ data class TrmnlDisplayInfo constructor(
1920
val status: Int,
2021
val trmnlDeviceType: TrmnlDeviceType,
2122
val imageUrl: String,
22-
val imageName: String,
23+
/**
24+
* The file name of the image to be displayed.
25+
*
26+
* If this is an error type, it indicates a specific error condition.
27+
* For example:
28+
* - [ERROR_TYPE_DEVICE_SETUP_REQUIRED]
29+
*/
30+
val imageFileName: String,
2331
val error: String? = null,
2432
val refreshIntervalSeconds: Long? = DEFAULT_REFRESH_INTERVAL_SEC,
2533
/**

app/src/main/java/ink/trmnl/android/data/TrmnlDisplayRepository.kt

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package ink.trmnl.android.data
22

33
import com.slack.eithernet.ApiResult
44
import com.slack.eithernet.InternalEitherNetApi
5+
import com.slack.eithernet.exceptionOrNull
56
import com.squareup.anvil.annotations.optional.SingleIn
67
import ink.trmnl.android.BuildConfig.USE_FAKE_API
78
import ink.trmnl.android.di.AppScope
@@ -10,6 +11,8 @@ import ink.trmnl.android.model.TrmnlDeviceType
1011
import ink.trmnl.android.network.TrmnlApiService
1112
import ink.trmnl.android.network.TrmnlApiService.Companion.CURRENT_PLAYLIST_SCREEN_API_PATH
1213
import ink.trmnl.android.network.TrmnlApiService.Companion.NEXT_PLAYLIST_SCREEN_API_PATH
14+
import ink.trmnl.android.network.model.TrmnlDisplayResponse
15+
import ink.trmnl.android.util.ERROR_TYPE_DEVICE_SETUP_REQUIRED
1316
import ink.trmnl.android.util.HTTP_200
1417
import ink.trmnl.android.util.HTTP_500
1518
import ink.trmnl.android.util.isHttpOk
@@ -57,7 +60,7 @@ class TrmnlDisplayRepository
5760
deviceMacId = trmnlDeviceConfig.deviceMacId,
5861
// TEMP FIX: Use Base64 encoding to avoid relative path issue
5962
// See https://github.com/usetrmnl/trmnl-android/issues/76#issuecomment-2980018109
60-
useBase64 = trmnlDeviceConfig.type == TrmnlDeviceType.BYOS,
63+
// useBase64 = trmnlDeviceConfig.type == TrmnlDeviceType.BYOS, // Disabled for now
6164
)
6265

6366
when (result) {
@@ -66,13 +69,18 @@ class TrmnlDisplayRepository
6669
}
6770
is ApiResult.Success -> {
6871
// Map the response to the display info
69-
val response = result.value
72+
val response: TrmnlDisplayResponse = result.value
73+
74+
if (isDeviceSetupRequired(trmnlDeviceConfig, response)) {
75+
return setupRequiredTrmnlDisplayInfo(trmnlDeviceConfig)
76+
}
77+
7078
val displayInfo =
7179
TrmnlDisplayInfo(
7280
status = response.status,
7381
trmnlDeviceType = trmnlDeviceConfig.type,
7482
imageUrl = response.imageUrl ?: "",
75-
imageName = response.imageName ?: "",
83+
imageFileName = response.imageFileName ?: "",
7684
error = response.error,
7785
refreshIntervalSeconds = response.refreshRate,
7886
httpResponseMetadata = extractHttpResponseMetadata(result),
@@ -132,7 +140,7 @@ class TrmnlDisplayRepository
132140
status = response.status,
133141
trmnlDeviceType = trmnlDeviceConfig.type,
134142
imageUrl = response.imageUrl ?: "",
135-
imageName = response.filename ?: "",
143+
imageFileName = response.filename ?: "",
136144
error = response.error,
137145
refreshIntervalSeconds = response.refreshRateSec,
138146
httpResponseMetadata = extractHttpResponseMetadata(result),
@@ -151,6 +159,46 @@ class TrmnlDisplayRepository
151159
}
152160
}
153161

162+
/**
163+
* Sets up a new device by calling the setup API endpoint.
164+
*
165+
* This is only applicable for BYOS devices, as other device types do not require setup.
166+
*
167+
* @param trmnlDeviceConfig The configuration for the device to be set up.
168+
* @return A [DeviceSetupInfo] object containing the result of the setup operation.
169+
*/
170+
suspend fun setupNewDevice(trmnlDeviceConfig: TrmnlDeviceConfig): DeviceSetupInfo {
171+
if (trmnlDeviceConfig.type != TrmnlDeviceType.BYOS) {
172+
Timber.w("Device setup is only applicable for BYOS devices.")
173+
}
174+
175+
val result =
176+
apiService.setupNewDevice(
177+
fullApiUrl = constructApiUrl(trmnlDeviceConfig.apiBaseUrl, TrmnlApiService.SETUP_API_PATH),
178+
deviceMacId = requireNotNull(trmnlDeviceConfig.deviceMacId) { "Device MAC ID is required for setup" },
179+
)
180+
when (result) {
181+
is ApiResult.Failure -> {
182+
Timber.e("Failed to setup device: ${result.exceptionOrNull()}")
183+
return DeviceSetupInfo(
184+
success = false,
185+
deviceMacId = trmnlDeviceConfig.deviceMacId,
186+
apiKey = "",
187+
message = "Failed to setup device with ID (${trmnlDeviceConfig.deviceMacId}). Reason: $result",
188+
)
189+
}
190+
is ApiResult.Success -> {
191+
Timber.i("Device setup successful: ${result.value}")
192+
return DeviceSetupInfo(
193+
success = true,
194+
deviceMacId = trmnlDeviceConfig.deviceMacId,
195+
apiKey = result.value.apiKey,
196+
message = result.value.message,
197+
)
198+
}
199+
}
200+
}
201+
154202
/**
155203
* Generates fake display info for debugging purposes without wasting an API request.
156204
*
@@ -169,7 +217,7 @@ class TrmnlDisplayRepository
169217
status = HTTP_200,
170218
trmnlDeviceType = TrmnlDeviceType.TRMNL,
171219
imageUrl = mockImageUrl,
172-
imageName = "mocked-image-" + mockImageUrl.substringAfterLast('?'),
220+
imageFileName = "mocked-image-" + mockImageUrl.substringAfterLast('?'),
173221
error = null,
174222
refreshIntervalSeconds = mockRefreshRate,
175223
)
@@ -208,7 +256,7 @@ class TrmnlDisplayRepository
208256
status = HTTP_500,
209257
trmnlDeviceType = trmnlDeviceConfig.type,
210258
imageUrl = "",
211-
imageName = "",
259+
imageFileName = "",
212260
error = "API failure",
213261
refreshIntervalSeconds = 0L,
214262
)
@@ -222,7 +270,7 @@ class TrmnlDisplayRepository
222270
status = HTTP_500,
223271
trmnlDeviceType = trmnlDeviceConfig.type,
224272
imageUrl = "",
225-
imageName = "",
273+
imageFileName = "",
226274
error = "HTTP failure: ${failure.code}, error: ${failure.error}",
227275
refreshIntervalSeconds = 0L,
228276
)
@@ -236,7 +284,7 @@ class TrmnlDisplayRepository
236284
status = HTTP_500,
237285
trmnlDeviceType = trmnlDeviceConfig.type,
238286
imageUrl = "",
239-
imageName = "",
287+
imageFileName = "",
240288
error = "Network failure: ${failure.error.localizedMessage}",
241289
refreshIntervalSeconds = 0L,
242290
)
@@ -250,7 +298,7 @@ class TrmnlDisplayRepository
250298
status = HTTP_500,
251299
trmnlDeviceType = trmnlDeviceConfig.type,
252300
imageUrl = "",
253-
imageName = "",
301+
imageFileName = "",
254302
error = "Unknown failure: ${failure.error.localizedMessage}",
255303
refreshIntervalSeconds = 0L,
256304
)
@@ -285,4 +333,38 @@ class TrmnlDisplayRepository
285333
timestamp = System.currentTimeMillis(),
286334
)
287335
}
336+
337+
/**
338+
* Right now there is no good known way to determine if a device requires setup.
339+
* The logic here is based on sample responses from the Terminus server API.
340+
*
341+
* See
342+
* - https://discord.com/channels/1281055965508141100/1331360842809348106/1384605617456545904
343+
* - https://discord.com/channels/1281055965508141100/1384605617456545904/1384613229086511135
344+
*/
345+
private fun isDeviceSetupRequired(
346+
trmnlDeviceConfig: TrmnlDeviceConfig,
347+
response: TrmnlDisplayResponse,
348+
): Boolean =
349+
trmnlDeviceConfig.type == TrmnlDeviceType.BYOS &&
350+
response.imageFileName?.startsWith("setup", ignoreCase = true) == true &&
351+
// This ensures that no screen is generated yet for the device
352+
response.imageUrl?.contains("screens", ignoreCase = true) == false
353+
354+
/**
355+
* Creates a [TrmnlDisplayInfo] indicating that the device requires setup.
356+
*
357+
* This is used when the device is not yet configured and needs to be set up before it can display content.
358+
* @see ERROR_TYPE_DEVICE_SETUP_REQUIRED
359+
* @see [isDeviceSetupRequired]
360+
*/
361+
private fun setupRequiredTrmnlDisplayInfo(trmnlDeviceConfig: TrmnlDeviceConfig): TrmnlDisplayInfo =
362+
TrmnlDisplayInfo(
363+
status = HTTP_500,
364+
trmnlDeviceType = trmnlDeviceConfig.type,
365+
imageUrl = "",
366+
imageFileName = ERROR_TYPE_DEVICE_SETUP_REQUIRED,
367+
error = "Device setup required",
368+
refreshIntervalSeconds = 0L,
369+
)
288370
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package ink.trmnl.android.model
2+
3+
import com.squareup.moshi.Json
4+
import com.squareup.moshi.JsonClass
5+
6+
/**
7+
* Data class representing the response from the TRMNL setup API.
8+
*
9+
* Sample JSON response:
10+
* ```json
11+
* {
12+
* "api_key": "abc1234567890abcdef",
13+
* "friendly_id": "GO87665",
14+
* "image_url": "https://localhost:1234/assets/setup.bmp",
15+
* "message": "Welcome to Terminus!"
16+
* }
17+
* ```
18+
*
19+
* @property apiKey The API key for the device.
20+
* @property friendlyId A user-friendly identifier for the device.
21+
* @property imageUrl The URL of an image to display during setup.
22+
* @property message A welcome message or setup instructions.
23+
* @see ink.trmnl.android.network.TrmnlApiService.setupNewDevice
24+
*/
25+
@JsonClass(generateAdapter = true)
26+
data class TrmnlSetupResponse(
27+
@Json(name = "api_key") val apiKey: String,
28+
@Json(name = "friendly_id") val friendlyId: String,
29+
@Json(name = "image_url") val imageUrl: String,
30+
@Json(name = "message") val message: String,
31+
)

app/src/main/java/ink/trmnl/android/network/TrmnlApiService.kt

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ package ink.trmnl.android.network
22

33
import com.slack.eithernet.ApiResult
44
import ink.trmnl.android.data.TrmnlDisplayRepository
5+
import ink.trmnl.android.model.TrmnlSetupResponse
56
import ink.trmnl.android.network.model.TrmnlCurrentImageResponse
67
import ink.trmnl.android.network.model.TrmnlDisplayResponse
78
import retrofit2.http.GET
89
import retrofit2.http.Header
910
import retrofit2.http.Url
1011

1112
/**
12-
* API service interface for TRMNL.
13+
* API service interface for TRMNL or BYOS servers.
1314
*
1415
* This interface defines the endpoints for the TRMNL API.
1516
*
@@ -22,18 +23,39 @@ import retrofit2.http.Url
2223
interface TrmnlApiService {
2324
companion object {
2425
/**
25-
* https://docs.usetrmnl.com/go/private-api/fetch-screen-content#auto-advance-content
26+
* Path for the TRMNL API endpoint that provides the next image in a playlist.
27+
*
28+
* - https://docs.usetrmnl.com/go/private-api/fetch-screen-content#auto-advance-content
29+
* - https://github.com/usetrmnl/byos_hanami?tab=readme-ov-file#display
2630
*
2731
* @see getNextDisplayData
2832
*/
2933
internal const val NEXT_PLAYLIST_SCREEN_API_PATH = "api/display"
3034

3135
/**
36+
* Path for the TRMNL API endpoint that provides the current image in a playlist.
37+
*
3238
* https://docs.usetrmnl.com/go/private-api/fetch-screen-content#current-screen
3339
*
3440
* @see getCurrentDisplayData
3541
*/
3642
internal const val CURRENT_PLAYLIST_SCREEN_API_PATH = "api/current_screen"
43+
44+
/**
45+
* Path for the TRMNL API endpoint used for new device setup.
46+
*
47+
* https://github.com/usetrmnl/byos_hanami?tab=readme-ov-file#setup-1
48+
*
49+
* @see setupNewDevice
50+
*/
51+
internal const val SETUP_API_PATH = "api/setup/"
52+
53+
/**
54+
* Default content type for API requests.
55+
*
56+
* This is used when setting up a new device or making other API calls that require a content type header.
57+
*/
58+
private const val DEFAULT_CONTENT_TYPE = "application/json"
3759
}
3860

3961
/**
@@ -68,4 +90,21 @@ interface TrmnlApiService {
6890
@Url fullApiUrl: String,
6991
@Header("access-token") accessToken: String,
7092
): ApiResult<TrmnlCurrentImageResponse, Unit>
93+
94+
/**
95+
* Setup a new TRMNL device using it's MAC ID. Using same API with same ID has no effect.
96+
*
97+
* This API is typically used once during the initial setup of a BYOS device.
98+
* See https://github.com/usetrmnl/byos_hanami?tab=readme-ov-file#setup-1
99+
*
100+
* @param fullApiUrl The complete API URL to call (e.g., "https://your-server.com/api/setup").
101+
* @param deviceMacId The device's MAC address, sent in the "ID" header.
102+
* @return An [ApiResult] containing [TrmnlSetupResponse] on success.
103+
*/
104+
@GET
105+
suspend fun setupNewDevice(
106+
@Url fullApiUrl: String,
107+
@Header("ID") deviceMacId: String,
108+
@Header("Content-Type") contentType: String = DEFAULT_CONTENT_TYPE,
109+
): ApiResult<TrmnlSetupResponse, Unit>
71110
}

app/src/main/java/ink/trmnl/android/network/model/TrmnlDisplayResponse.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ import ink.trmnl.android.util.HTTP_NONE
4949
* "update_firmware": false
5050
* }
5151
* ```
52+
*
53+
* Sample 4404 response from BYOS Hanami server:
54+
* ```json
55+
* {
56+
* "type": "/problem_details#device_id",
57+
* "title": "Not Found",
58+
* "status": 404,
59+
* "detail": "Invalid device ID.",
60+
* "instance": "/api/display"
61+
* }
62+
* ```
5263
*/
5364
@JsonClass(generateAdapter = true)
5465
data class TrmnlDisplayResponse(
@@ -64,7 +75,7 @@ data class TrmnlDisplayResponse(
6475
*/
6576
val status: Int = HTTP_NONE,
6677
@Json(name = "image_url") val imageUrl: String?,
67-
@Json(name = "filename") val imageName: String?,
78+
@Json(name = "filename") val imageFileName: String?,
6879
@Json(name = "update_firmware") val updateFirmware: Boolean?,
6980
@Json(name = "firmware_url") val firmwareUrl: String?,
7081
@Json(name = "refresh_rate") val refreshRate: Long?,

0 commit comments

Comments
 (0)