Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,46 @@ jobs:
name: helm-debug
path: app/build/outputs/apk/debug/app-debug.apk
retention-days: 14

smoke:
name: UI smoke test
runs-on: ubuntu-latest
needs: build

steps:
- uses: actions/checkout@v4

- name: Download debug APK
uses: actions/download-artifact@v4
with:
name: helm-debug
path: app/build/outputs/apk/debug

# The emulator needs KVM, and the runner does not grant it by default.
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

# API 29 + 768x1024 portrait mirrors the real head unit, so the smoke run
# exercises the same layout the car does.
- name: Run smoke test
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
target: google_apis
arch: x86_64
emulator-options: >-
-no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
-camera-back emulated -skin 768x1024
script: bash tools/smoke.sh

- name: Upload smoke screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: smoke-screenshots
path: build/smoke/
retention-days: 14
7 changes: 7 additions & 0 deletions ota/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
testOptions {
unitTests.all { it.useJUnitPlatform() }
}
}

dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.lifecycle.viewmodel.ktx)

testImplementation(libs.junit5.api)
testImplementation(libs.junit5.params)
testRuntimeOnly(libs.junit5.engine)
}
20 changes: 20 additions & 0 deletions ota/src/main/kotlin/dev/helm/ota/OtaInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,23 @@ data class OtaInfo(
val apkUrl: String,
val changelog: String,
)

// Semantic-ish version compare. Tolerates a leading "v", ignores pre-release ("-rc1")
// and build metadata ("+42") suffixes, and pads missing components with 0 so
// "1.2" and "1.2.0" compare equal.
internal fun isNewer(server: String, installed: String): Boolean {
fun String.components(): List<Int> {
val core = substringBefore('+').substringBefore('-').trimStart('v')
val parts = core.split('.').mapNotNull { it.toIntOrNull() }
return parts.ifEmpty { listOf(0) }
}
val s = server.components()
val c = installed.components()
for (i in 0 until maxOf(s.size, c.size)) {
val sv = s.getOrElse(i) { 0 }
val cv = c.getOrElse(i) { 0 }
if (sv > cv) return true
if (sv < cv) return false
}
return false
}
18 changes: 0 additions & 18 deletions ota/src/main/kotlin/dev/helm/ota/OtaViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,22 +85,4 @@ class OtaViewModel(application: Application) : AndroidViewModel(application) {
fun reset() {
_state.value = OtaState.Idle
}

private fun isNewer(server: String, installed: String): Boolean {
fun String.components(): List<Int> {
val core = substringBefore('+').substringBefore('-').trimStart('v')
val parts = core.split('.').mapNotNull { it.toIntOrNull() }
return parts.ifEmpty { listOf(0) }
}
val s = server.components()
val c = installed.components()
val len = maxOf(s.size, c.size)
for (i in 0 until len) {
val sv = s.getOrElse(i) { 0 }
val cv = c.getOrElse(i) { 0 }
if (sv > cv) return true
if (sv < cv) return false
}
return false
}
}
61 changes: 61 additions & 0 deletions ota/src/test/kotlin/dev/helm/ota/OtaVersionTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package dev.helm.ota

import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource

class OtaVersionTest {

@ParameterizedTest(name = "server {0} is newer than installed {1}")
@CsvSource(
"0.2.0, 0.1.0",
"1.0.0, 0.9.9",
"0.1.1, 0.1.0",
"0.10.0, 0.9.0", // numeric compare, not lexicographic
"1.1, 1.0.9", // missing component pads to 0, compared left to right
)
fun `newer server version triggers update`(server: String, installed: String) {
assertTrue(isNewer(server, installed))
}

@ParameterizedTest(name = "server {0} is NOT newer than installed {1}")
@CsvSource(
"0.1.0, 0.1.0",
"0.1.0, 0.2.0",
"0.9.0, 0.10.0",
"1.0, 1.0.0", // equal once padded
"0.1.0, 1.0.0", // downgrade must never be offered
)
fun `same or older server version does not trigger update`(server: String, installed: String) {
assertFalse(isNewer(server, installed))
}

@Test
fun `leading v is stripped from the release tag`() {
assertTrue(isNewer("v0.2.0", "0.1.0"))
assertFalse(isNewer("v0.1.0", "0.1.0"))
}

@Test
fun `pre-release and build metadata suffixes are ignored`() {
assertFalse(isNewer("0.1.0-rc1", "0.1.0"))
assertFalse(isNewer("0.1.0+42", "0.1.0"))
assertTrue(isNewer("0.2.0-rc1", "0.1.0"))
}

@Test
fun `unparseable versions never trigger an update`() {
// A garbage tag_name must not push an APK onto the head unit.
assertFalse(isNewer("", "0.1.0"))
assertFalse(isNewer("latest", "0.1.0"))
assertFalse(isNewer("nightly", "0.1.0"))
}

@Test
fun `unparseable installed version accepts any real release`() {
// versionName came back empty from PackageManager — treated as 0.
assertTrue(isNewer("0.1.0", ""))
}
}
12 changes: 0 additions & 12 deletions sdk/src/main/kotlin/dev/helm/sdk/OpenMeteoWeatherDataSource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,4 @@ class OpenMeteoWeatherDataSource(
}
}
}

private fun wmoToCondition(code: Int): WeatherCondition = when (code) {
0 -> WeatherCondition.CLEAR
in 1..3 -> WeatherCondition.CLOUDY
45, 48 -> WeatherCondition.HAZE
in 51..67 -> WeatherCondition.RAIN
in 71..77 -> WeatherCondition.SNOW
in 80..82 -> WeatherCondition.RAIN
85, 86 -> WeatherCondition.SNOW
in 95..99 -> WeatherCondition.THUNDERSTORM
else -> WeatherCondition.CLOUDY
}
}
9 changes: 8 additions & 1 deletion sdk/src/main/kotlin/dev/helm/sdk/TwUtilMcuDataSource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@ internal class TwUtilMcuDataSource : McuDataSource {

override fun events(): Flow<McuEvent> = adapter.events()
.retryWhen { _, attempt ->
delay(minOf(500L shl attempt.toInt().coerceAtMost(6), 30_000L))
delay(reconnectDelayMs(attempt))
true
}

override suspend fun send(code: Int, arg1: Int, arg2: Int, data: ByteArray): Result<Unit> =
adapter.send(code, arg1, arg2, data)
}

// Exponential back-off for MCU reconnection: 500 ms, 1 s, 2 s, 4 s, 8 s, 16 s, then 30 s
// forever. Clamp the attempt as a Long *before* narrowing to Int: Long.MAX_VALUE.toInt()
// is -1, and `shl` only reads the low 6 bits of the count, so narrowing first turns a very
// long outage into a negative delay and busy-loops the UART.
internal fun reconnectDelayMs(attempt: Long): Long =
minOf(500L shl attempt.coerceIn(0L, 6L).toInt(), 30_000L)
15 changes: 15 additions & 0 deletions sdk/src/main/kotlin/dev/helm/sdk/WeatherCondition.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,18 @@ enum class WeatherCondition(val label: String) {
SNOW("Snow"),
HAZE("Haze"),
}

// WMO weather interpretation codes → Helm's 6 icon buckets.
// https://open-meteo.com/en/docs — unknown codes fall back to CLOUDY so the
// widget always renders something rather than blanking out.
internal fun wmoToCondition(code: Int): WeatherCondition = when (code) {
0 -> WeatherCondition.CLEAR
in 1..3 -> WeatherCondition.CLOUDY
45, 48 -> WeatherCondition.HAZE
in 51..67 -> WeatherCondition.RAIN
in 71..77 -> WeatherCondition.SNOW
in 80..82 -> WeatherCondition.RAIN
85, 86 -> WeatherCondition.SNOW
in 95..99 -> WeatherCondition.THUNDERSTORM
else -> WeatherCondition.CLOUDY
}
46 changes: 46 additions & 0 deletions sdk/src/test/kotlin/dev/helm/sdk/ReconnectBackoffTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package dev.helm.sdk

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource

class ReconnectBackoffTest {

@ParameterizedTest(name = "attempt {0} waits {1} ms")
@CsvSource(
"0, 500",
"1, 1000",
"2, 2000",
"3, 4000",
"4, 8000",
"5, 16000",
"6, 30000", // 32000 clamped to the 30 s ceiling
)
fun `back-off follows the documented schedule`(attempt: Long, expectedMs: Long) {
assertEquals(expectedMs, reconnectDelayMs(attempt))
}

@ParameterizedTest(name = "attempt {0} stays at the 30 s ceiling")
@ValueSource(longs = [7, 8, 20, 63, 64, 1_000, Int.MAX_VALUE.toLong(), Long.MAX_VALUE])
fun `long outages stay at the ceiling`(attempt: Long) {
assertEquals(30_000L, reconnectDelayMs(attempt))
}

@Test
fun `delay is never zero or negative`() {
// A non-positive delay would turn reconnection into a busy loop on the UART.
listOf(0L, 6L, 7L, 64L, Long.MAX_VALUE).forEach {
assertTrue(reconnectDelayMs(it) > 0, "attempt $it produced a non-positive delay")
}
}

@Test
fun `back-off is monotonic up to the ceiling`() {
val delays = (0L..10L).map { reconnectDelayMs(it) }
delays.zipWithNext { a, b -> assertTrue(b >= a, "delay decreased: $a then $b") }
assertEquals(30_000L, delays.last())
}
}
45 changes: 45 additions & 0 deletions sdk/src/test/kotlin/dev/helm/sdk/WmoConditionTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package dev.helm.sdk

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource

class WmoConditionTest {

@ParameterizedTest(name = "WMO {0} maps to {1}")
@CsvSource(
"0, CLEAR",
"1, CLOUDY", "2, CLOUDY", "3, CLOUDY",
"45, HAZE", "48, HAZE",
"51, RAIN", "61, RAIN", "67, RAIN",
"71, SNOW", "77, SNOW",
"80, RAIN", "82, RAIN",
"85, SNOW", "86, SNOW",
"95, THUNDERSTORM", "99, THUNDERSTORM",
)
fun `documented WMO codes map to the right icon`(code: Int, expected: WeatherCondition) {
assertEquals(expected, wmoToCondition(code))
}

@ParameterizedTest(name = "gap code {0} falls back to CLOUDY")
@ValueSource(ints = [4, 44, 49, 50, 68, 70, 78, 79, 83, 84, 87, 94, 100])
fun `codes in the gaps fall back to CLOUDY`(code: Int) {
assertEquals(WeatherCondition.CLOUDY, wmoToCondition(code))
}

@Test
fun `nonsense codes never crash the widget`() {
// A malformed API response must degrade to an icon, not blank the home screen.
assertEquals(WeatherCondition.CLOUDY, wmoToCondition(-1))
assertEquals(WeatherCondition.CLOUDY, wmoToCondition(9999))
assertEquals(WeatherCondition.CLOUDY, wmoToCondition(Int.MAX_VALUE))
assertEquals(WeatherCondition.CLOUDY, wmoToCondition(Int.MIN_VALUE))
}

@Test
fun `every condition has a non-empty label for the widget`() {
WeatherCondition.entries.forEach { assertEquals(it.label, it.label.trim().ifEmpty { null }) }
}
}
Loading
Loading