feat: add Bluetooth Android companion

This commit is contained in:
2026-07-31 23:32:57 +02:00
parent e53b7400bd
commit 2d715abb12
35 changed files with 1739 additions and 4 deletions
+3
View File
@@ -16,6 +16,7 @@ The current MVP slice includes:
- live MG4 speed, gear, and steering-angle telemetry with a dedicated visual Car dashboard; - live MG4 speed, gear, and steering-angle telemetry with a dedicated visual Car dashboard;
- a responsive 1920×720-oriented setup, login, and dashboard UI; - a responsive 1920×720-oriented setup, login, and dashboard UI;
- an installable PWA shell with standalone landscape display and cached dashboard assets; - an installable PWA shell with standalone landscape display and cached dashboard assets;
- a focused native Android companion with authenticated BLE status, car controls, and phone GPS uplink;
- an idempotent Raspberry Pi installer with systemd start-on-boot support; - an idempotent Raspberry Pi installer with systemd start-on-boot support;
- atomic, dashboard-triggered Git updates with automatic verification and rollback safety; - atomic, dashboard-triggered Git updates with automatic verification and rollback safety;
- optional dual-radio Wi-Fi failover with an always-recoverable offline car hotspot; - optional dual-radio Wi-Fi failover with an always-recoverable offline car hotspot;
@@ -56,6 +57,8 @@ The script asks for sudo access and then:
The installer prints the final dashboard address, normally `http://<pi-hostname>.local:8787`. Open it and create the first administrator account. The installer prints the final dashboard address, normally `http://<pi-hostname>.local:8787`. Open it and create the first administrator account.
It also installs the BlueZ GATT bridge used by the optional [Android companion](./android/README.md). Pair phones from **Settings → Mobile** after the Pi has Bluetooth enabled.
`install.sh` is idempotent. After pulling changes in the original clone, run it again to deploy that checkout: `install.sh` is idempotent. After pulling changes in the original clone, run it again to deploy that checkout:
```bash ```bash
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
app/build/
local.properties
*.jks
*.keystore
+33
View File
@@ -0,0 +1,33 @@
# Android companion
The Android app is a focused native companion rather than a copy of the web dashboard. It connects directly to the Pi's BLE GATT service and provides:
- live Pi health, temperature, memory, storage, and ADB connection status;
- live car speed, gear, steering, battery, range, and lifetime statistics;
- launch access to ADB shortcuts that were explicitly saved in the web dashboard;
- phone GPS, speed, bearing, accuracy, battery, and network-state uplink;
- background BLE reconnection and location updates through a foreground service.
## Pairing
1. Deploy the latest Pi release with `./install.sh` so BlueZ and the Bluetooth bridge are installed.
2. Open **Settings → Mobile** in the web dashboard and select **Pair a phone**.
3. Install and open the Android application and grant Bluetooth, notification, and precise-location permissions.
4. Enter the six-digit code shown by the dashboard.
The one-use code expires after ten minutes. The resulting credential is encrypted with Android Keystore and can be revoked from **Settings → Mobile**. A phone can only launch existing allowlisted ADB shortcuts; the Bluetooth protocol has no shell, arbitrary ADB, APK upload, update, or power operation.
## Build
Open this `android/` directory in Android Studio (JDK 17, Android SDK 35) and run the `app` configuration, or use the included Gradle wrapper:
```bash
cd android
./gradlew :app:assembleDebug
```
The debug APK is produced at `app/build/outputs/apk/debug/app-debug.apk`. Hardware validation requires an Android 8+ phone with BLE and a Raspberry Pi with a BlueZ-compatible BLE adapter.
## BLE protocol
The custom service UUID is `6c7a0001-7c6d-4f74-9bb0-c5a4e5efc001`. RX and TX carry newline-delimited UTF-8 JSON, fragmented into BLE-sized chunks. Protocol version 1 exposes only `pair`, `snapshot`, `location`, and `launchShortcut` operations. Android sends GPS at most every second and refreshes the dashboard snapshot every five seconds.
+39
View File
@@ -0,0 +1,39 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "cloud.molberg.picar"
compileSdk = 35
defaultConfig {
applicationId = "cloud.molberg.picar"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
buildFeatures { compose = true }
packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" }
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.activity:activity-compose:1.10.0")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("com.google.android.gms:play-services-location:21.3.0")
}
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<application
android:name=".CompanionApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="Pi Car Companion"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.PiCar">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".CompanionService"
android:exported="false"
android:foregroundServiceType="location|connectedDevice" />
</application>
</manifest>
@@ -0,0 +1,125 @@
package cloud.molberg.picar
import android.annotation.SuppressLint
import android.bluetooth.*
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.ParcelUuid
import android.os.Handler
import android.os.Looper
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withTimeout
import org.json.JSONObject
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@SuppressLint("MissingPermission")
class BleClient(private val context: Context) {
companion object {
val SERVICE_UUID: UUID = UUID.fromString("6c7a0001-7c6d-4f74-9bb0-c5a4e5efc001")
val RX_UUID: UUID = UUID.fromString("6c7a0002-7c6d-4f74-9bb0-c5a4e5efc001")
val TX_UUID: UUID = UUID.fromString("6c7a0003-7c6d-4f74-9bb0-c5a4e5efc001")
val CCC_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}
val state = MutableStateFlow(ConnectionState.STOPPED)
private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)
private var gatt: BluetoothGatt? = null
private var rx: BluetoothGattCharacteristic? = null
private val incoming = StringBuilder()
private val pending = ConcurrentHashMap<String, CompletableDeferred<JSONObject>>()
private val writes = ArrayDeque<ByteArray>()
private val handler = Handler(Looper.getMainLooper())
private var stopped = true
fun connect() {
stopped = false
val adapter = bluetoothManager.adapter ?: return
state.value = ConnectionState.SCANNING
val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build()
adapter.bluetoothLeScanner?.startScan(listOf(filter), ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), scanCallback)
}
fun disconnect() {
stopped = true
handler.removeCallbacksAndMessages(null)
bluetoothManager.adapter?.bluetoothLeScanner?.stopScan(scanCallback)
gatt?.close(); gatt = null; rx = null
state.value = ConnectionState.STOPPED
}
suspend fun request(operation: String, token: String?, payload: JSONObject = JSONObject()): JSONObject {
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<JSONObject>()
pending[id] = deferred
val message = JSONObject().put("id", id).put("op", operation).put("payload", payload)
if (token != null) message.put("token", token)
enqueue((message.toString() + "\n").toByteArray())
return try {
withTimeout(20_000) { deferred.await() }
} finally {
pending.remove(id)
}
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
bluetoothManager.adapter.bluetoothLeScanner?.stopScan(this)
state.value = ConnectionState.CONNECTING
gatt = result.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
}
override fun onScanFailed(errorCode: Int) { state.value = ConnectionState.DISCONNECTED }
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) { this@BleClient.gatt = gatt; gatt.requestMtu(247); gatt.discoverServices() }
else {
rx = null; gatt.close(); state.value = ConnectionState.DISCONNECTED
if (!stopped) handler.postDelayed({ connect() }, 3_000)
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
val service = gatt.getService(SERVICE_UUID) ?: return
rx = service.getCharacteristic(RX_UUID)
val tx = service.getCharacteristic(TX_UUID) ?: return
gatt.setCharacteristicNotification(tx, true)
tx.getDescriptor(CCC_UUID)?.let { descriptor ->
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
state.value = ConnectionState.CONNECTED
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
incoming.append(characteristic.value.toString(Charsets.UTF_8))
while (incoming.contains("\n")) {
val index = incoming.indexOf("\n")
val line = incoming.substring(0, index); incoming.delete(0, index + 1)
runCatching { JSONObject(line) }.getOrNull()?.let { response ->
pending.remove(response.optString("id"))?.let { deferred ->
if (response.optBoolean("ok")) deferred.complete(response.getJSONObject("data"))
else deferred.completeExceptionally(IllegalStateException(response.optJSONObject("error")?.optString("message") ?: "Bluetooth request failed"))
}
}
}
}
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { writeNext() }
}
@Synchronized private fun enqueue(bytes: ByteArray) {
bytes.asList().chunked(180).forEach { chunk -> writes.add(chunk.toByteArray()) }
if (writes.size == bytes.asList().chunked(180).size) writeNext()
}
@Synchronized private fun writeNext() {
val characteristic = rx ?: return
val next = writes.removeFirstOrNull() ?: return
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.value = next
gatt?.writeCharacteristic(characteristic)
}
}
@@ -0,0 +1,7 @@
package cloud.molberg.picar
import android.app.Application
class CompanionApplication : Application() {
val repository by lazy { CompanionRepository(this) }
}
@@ -0,0 +1,156 @@
package cloud.molberg.picar
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.os.BatteryManager
import android.provider.Settings
import com.google.android.gms.location.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import java.nio.charset.StandardCharsets
import java.util.UUID
class CompanionRepository(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val tokenStore = TokenStore(context)
private val ble = BleClient(context)
private val locationClient = LocationServices.getFusedLocationProviderClient(context)
private val mutableState = MutableStateFlow(CompanionUiState(paired = tokenStore.load() != null))
val state = mutableState.asStateFlow()
private var polling: Job? = null
init {
scope.launch { ble.state.collect { connection ->
mutableState.value = mutableState.value.copy(connection = connection)
if (connection == ConnectionState.CONNECTED) startPolling()
} }
}
fun connect() = ble.connect()
fun stop() { polling?.cancel(); ble.disconnect() }
fun pair(code: String, onComplete: (Boolean) -> Unit) {
scope.launch {
runCatching {
val deviceId = stableDeviceId()
val payload = JSONObject().put("code", code).put("deviceId", deviceId).put("deviceName", android.os.Build.MODEL.take(80))
val response = ble.request("pair", null, payload)
tokenStore.save(response.getString("token"))
mutableState.value = mutableState.value.copy(paired = true, error = null)
}.onFailure { mutableState.value = mutableState.value.copy(error = it.message) }
withContext(Dispatchers.Main) { onComplete(tokenStore.load() != null) }
}
}
fun forget() {
tokenStore.clear()
mutableState.value = mutableState.value.copy(paired = false, snapshot = null)
}
fun refresh() = scope.launch { fetchSnapshot() }
fun launchShortcut(shortcut: Shortcut) {
val token = tokenStore.load() ?: return
scope.launch {
mutableState.value = mutableState.value.copy(busyShortcut = shortcut.id, error = null)
runCatching { ble.request("launchShortcut", token, JSONObject().put("id", shortcut.id)) }
.onFailure { mutableState.value = mutableState.value.copy(error = it.message) }
mutableState.value = mutableState.value.copy(busyShortcut = null)
}
}
private fun startPolling() {
polling?.cancel()
polling = scope.launch {
while (isActive) { fetchSnapshot(); delay(5_000) }
}
}
private suspend fun fetchSnapshot() {
val token = tokenStore.load() ?: return
runCatching { parseSnapshot(ble.request("snapshot", token)) }
.onSuccess { mutableState.value = mutableState.value.copy(snapshot = it, error = null) }
.onFailure { mutableState.value = mutableState.value.copy(error = it.message) }
}
@SuppressLint("MissingPermission")
fun startLocation() {
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 2_000)
.setMinUpdateIntervalMillis(1_000).setMinUpdateDistanceMeters(3f).build()
locationClient.requestLocationUpdates(request, locationCallback, context.mainLooper)
}
fun stopLocation() = locationClient.removeLocationUpdates(locationCallback)
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) { result.lastLocation?.let(::sendLocation) }
}
private fun sendLocation(location: Location) {
val token = tokenStore.load() ?: return
if (ble.state.value != ConnectionState.CONNECTED) return
val battery = context.getSystemService(BatteryManager::class.java).getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
val payload = JSONObject()
.put("capturedAt", java.time.Instant.ofEpochMilli(location.time).toString())
.put("latitude", location.latitude).put("longitude", location.longitude)
.put("accuracyMeters", location.accuracy.toDouble())
.put("altitudeMeters", if (location.hasAltitude()) location.altitude else JSONObject.NULL)
.put("speedKph", if (location.hasSpeed()) location.speed * 3.6 else JSONObject.NULL)
.put("bearingDegrees", if (location.hasBearing()) location.bearing.toDouble() else JSONObject.NULL)
.put("batteryPercent", if (battery in 0..100) battery else JSONObject.NULL)
.put("networkType", networkType())
scope.launch { runCatching { ble.request("location", token, payload) } }
}
private fun networkType(): String {
val manager = context.getSystemService(android.net.ConnectivityManager::class.java)
val capabilities = manager.getNetworkCapabilities(manager.activeNetwork) ?: return "offline"
return when {
capabilities.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
capabilities.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
capabilities.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
else -> "other"
}
}
private fun stableDeviceId(): String {
val source = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: android.os.Build.MODEL
return UUID.nameUUIDFromBytes(source.toByteArray(StandardCharsets.UTF_8)).toString()
}
private fun parseSnapshot(json: JSONObject): Snapshot {
val system = json.getJSONObject("system")
val memory = system.getJSONObject("memory")
val disk = system.getJSONObject("disk")
val diskValue = if (disk.optBoolean("available")) disk.optJSONObject("value") else null
val car = json.optJSONObject("car")
val stats = json.getJSONObject("statistics")
val lifetime = stats.getJSONObject("lifetime")
val records = stats.getJSONObject("records")
fun availableNumber(name: String) = car?.optJSONObject(name)?.takeIf { it.optBoolean("available") }?.optDouble("value")
fun availableString(name: String) = car?.optJSONObject(name)?.takeIf { it.optBoolean("available") }?.optString("value")
val shortcutsJson = json.getJSONArray("shortcuts")
val shortcuts = (0 until shortcutsJson.length()).map { index -> shortcutsJson.getJSONObject(index).let { Shortcut(it.getInt("id"), it.getString("name"), it.getString("value")) } }
return Snapshot(
json.getString("collectedAt"),
SystemSummary(
system.getString("hostname"),
system.getJSONObject("cpu").getJSONObject("temperatureCelsius").takeIf { it.optBoolean("available") }?.optDouble("value"),
((memory.getDouble("usedBytes") / memory.getDouble("totalBytes")) * 100).toInt(),
diskValue?.let { ((it.getDouble("usedBytes") / it.getDouble("totalBytes")) * 100).toInt() },
system.getDouble("uptimeSeconds").toLong(),
system.getJSONObject("headUnit").optBoolean("available")
),
CarSummary(availableNumber("speedKph"), availableString("gear"), availableNumber("wheelAngleDegrees"),
availableNumber("batteryPercent"), availableNumber("rangeKm"), availableNumber("batteryVoltage"),
availableNumber("totalConsumptionKwh"), lifetime.optDouble("trackedDistanceKm", 0.0),
lifetime.optNullableDouble("averageMovingSpeedKph"), records.optNullableDouble("peakSpeedKph")),
shortcuts
)
}
private fun JSONObject.optNullableDouble(name: String): Double? = if (isNull(name) || !has(name)) null else getDouble(name)
}
@@ -0,0 +1,29 @@
package cloud.molberg.picar
import android.app.*
import android.content.Intent
import android.os.IBinder
class CompanionService : Service() {
private val repository get() = (application as CompanionApplication).repository
override fun onCreate() {
super.onCreate()
val channel = NotificationChannel("companion_connection", "Car companion connection", NotificationManager.IMPORTANCE_LOW)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
val launch = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
val notification = Notification.Builder(this, channel.id)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setContentTitle("Pi Car Companion")
.setContentText("Bluetooth and phone GPS are active")
.setContentIntent(launch)
.setOngoing(true)
.build()
startForeground(42, notification)
repository.connect()
repository.startLocation()
}
override fun onDestroy() { repository.stopLocation(); repository.stop(); super.onDestroy() }
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,126 @@
package cloud.molberg.picar
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import java.util.Locale
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val repository = (application as CompanionApplication).repository
setContent { CompanionTheme { CompanionApp(repository) { startCompanionService() } } }
}
private fun startCompanionService() = ContextCompat.startForegroundService(this, Intent(this, CompanionService::class.java))
}
@Composable private fun CompanionApp(repository: CompanionRepository, startService: () -> Unit) {
val state by repository.state.collectAsStateWithLifecycle()
val permissions = buildList {
if (Build.VERSION.SDK_INT >= 31) { add(Manifest.permission.BLUETOOTH_SCAN); add(Manifest.permission.BLUETOOTH_CONNECT) }
add(Manifest.permission.ACCESS_FINE_LOCATION)
if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS)
}.toTypedArray()
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> if (grants.values.all { it }) startService() }
LaunchedEffect(Unit) { launcher.launch(permissions) }
if (!state.paired) PairScreen(state, onConnect = startService, onPair = repository::pair)
else Dashboard(state, repository::refresh, repository::launchShortcut, repository::forget)
}
@Composable private fun PairScreen(state: CompanionUiState, onConnect: () -> Unit, onPair: (String, (Boolean) -> Unit) -> Unit) {
var code by remember { mutableStateOf("") }
Surface(Modifier.fillMaxSize(), color = Bg) {
Column(Modifier.padding(28.dp), verticalArrangement = Arrangement.Center) {
Text("PI CAR COMPANION", color = Accent, fontSize = 13.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp)
Spacer(Modifier.height(14.dp)); Text("Connect your phone.", color = Text, fontSize = 38.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp)); Text("Generate a pairing code in the Pi web dashboard, then enter it here. Bluetooth and GPS stay active while you drive.", color = Muted, lineHeight = 23.sp)
Spacer(Modifier.height(28.dp))
OutlinedTextField(code, { code = it.filter(Char::isDigit).take(6) }, label = { Text("Six-digit code") }, singleLine = true, modifier = Modifier.fillMaxWidth(), colors = fieldColors())
Spacer(Modifier.height(14.dp))
Button(onClick = { onConnect(); onPair(code) {} }, enabled = code.length == 6 && state.connection == ConnectionState.CONNECTED, modifier = Modifier.fillMaxWidth().height(56.dp), colors = ButtonDefaults.buttonColors(containerColor = Accent, contentColor = AccentInk)) { Text(if (state.connection == ConnectionState.CONNECTED) "Pair phone" else connectionLabel(state.connection), fontWeight = FontWeight.Bold) }
state.error?.let { Spacer(Modifier.height(12.dp)); Text(it, color = Danger) }
}
}
}
@Composable private fun Dashboard(state: CompanionUiState, refresh: () -> Unit, launch: (Shortcut) -> Unit, forget: () -> Unit) {
var tab by remember { mutableIntStateOf(0) }
Scaffold(containerColor = Bg, bottomBar = {
NavigationBar(containerColor = Surface) {
listOf("Overview", "Car", "Controls").forEachIndexed { index, title -> NavigationBarItem(selected = tab == index, onClick = { tab = index }, icon = { Text(listOf("", "", "")[index]) }, label = { Text(title) }, colors = NavigationBarItemDefaults.colors(selectedIconColor = Accent, selectedTextColor = Text, indicatorColor = Raised, unselectedIconColor = Muted, unselectedTextColor = Muted)) }
}
}) { padding ->
Column(Modifier.padding(padding).padding(horizontal = 18.dp).fillMaxSize()) {
Row(Modifier.padding(top = 20.dp, bottom = 14.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Text("PI CAR COMPANION", color = Accent, fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp); Text(state.snapshot?.system?.hostname ?: "Your Raspberry Pi", color = Text, fontSize = 25.sp, fontWeight = FontWeight.Bold) }
Text(if (state.connection == ConnectionState.CONNECTED) "● LIVE" else "● OFFLINE", color = if (state.connection == ConnectionState.CONNECTED) Accent else Danger, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
state.error?.let { Text(it, color = Danger, modifier = Modifier.padding(bottom = 8.dp)) }
when (tab) {
0 -> OverviewScreen(state.snapshot, refresh)
1 -> CarScreen(state.snapshot?.car)
else -> ControlsScreen(state, launch, forget)
}
}
}
}
@Composable private fun OverviewScreen(snapshot: Snapshot?, refresh: () -> Unit) {
val system = snapshot?.system
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(bottom = 20.dp)) {
item { MetricCard("Pi status", if (system != null) "Healthy" else "Waiting", "Last snapshot ${snapshot?.collectedAt?.substringAfter('T')?.take(8) ?: "--"}", Accent) }
item { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Box(Modifier.weight(1f)) { MetricCard("CPU temperature", system?.temperatureC?.let { "${it.round(1)}°C" } ?: "--", "Thermal sensor") }; Box(Modifier.weight(1f)) { MetricCard("Memory", system?.let { "${it.memoryUsedPercent}%" } ?: "--", "Used") } } }
item { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Box(Modifier.weight(1f)) { MetricCard("Storage", system?.diskUsedPercent?.let { "$it%" } ?: "--", "Used") }; Box(Modifier.weight(1f)) { MetricCard("ADB", if (system?.adbConnected == true) "Connected" else "Offline", "Head unit") } } }
item { Button(onClick = refresh, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = Accent, contentColor = AccentInk)) { Text("Refresh now", fontWeight = FontWeight.Bold) } }
}
}
@Composable private fun CarScreen(car: CarSummary?) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(bottom = 20.dp)) {
item { Card(colors = CardDefaults.cardColors(containerColor = Surface), shape = RoundedCornerShape(18.dp), modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text(car?.speedKph?.round(0) ?: "--", color = Text, fontSize = 64.sp, fontWeight = FontWeight.Bold); Text("KM/H", color = Accent, fontSize = 12.sp, letterSpacing = 2.sp); Spacer(Modifier.height(18.dp)); Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) { SmallValue("GEAR", car?.gear ?: "--"); SmallValue("STEERING", car?.wheelAngle?.let { "${it.round(0)}°" } ?: "--"); SmallValue("BATTERY", car?.batteryPercent?.let { "${it.round(0)}%" } ?: "--") } } } }
item { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Box(Modifier.weight(1f)) { MetricCard("Range", car?.rangeKm?.let { "${it.round(0)} km" } ?: "--", "OEM estimate") }; Box(Modifier.weight(1f)) { MetricCard("Distance", car?.trackedDistanceKm?.let { "${it.round(1)} km" } ?: "--", "Tracked") } } }
item { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Box(Modifier.weight(1f)) { MetricCard("Average", car?.averageSpeedKph?.let { "${it.round(1)} km/h" } ?: "--", "Moving speed") }; Box(Modifier.weight(1f)) { MetricCard("Peak", car?.peakSpeedKph?.let { "${it.round(0)} km/h" } ?: "--", "All time") } } }
}
}
@Composable private fun ControlsScreen(state: CompanionUiState, launch: (Shortcut) -> Unit, forget: () -> Unit) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 20.dp)) {
item { Text("ADB SHORTCUTS", color = Accent, fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp) }
val shortcuts = state.snapshot?.shortcuts.orEmpty()
if (shortcuts.isEmpty()) item { MetricCard("No shortcuts", "Add them in the web dashboard", "Only saved allowlisted targets appear here") }
items(shortcuts) { shortcut -> Button(onClick = { launch(shortcut) }, enabled = state.busyShortcut == null, modifier = Modifier.fillMaxWidth().height(62.dp), colors = ButtonDefaults.buttonColors(containerColor = Surface, contentColor = Text), shape = RoundedCornerShape(14.dp)) { Column(Modifier.weight(1f), horizontalAlignment = Alignment.Start) { Text(shortcut.name, fontWeight = FontWeight.Bold); Text(shortcut.target, color = Muted, fontSize = 11.sp, maxLines = 1) }; Text(if (state.busyShortcut == shortcut.id) "" else "OPEN", color = Accent, fontWeight = FontWeight.Bold, fontSize = 12.sp) } }
item { Spacer(Modifier.height(10.dp)); TextButton(onClick = forget, modifier = Modifier.fillMaxWidth()) { Text("Forget this Pi", color = Danger) } }
}
}
@Composable private fun MetricCard(label: String, value: String, detail: String, valueColor: Color = Text) { Card(colors = CardDefaults.cardColors(containerColor = Surface), shape = RoundedCornerShape(14.dp), modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(17.dp)) { Text(label, color = Muted, fontSize = 12.sp); Spacer(Modifier.height(10.dp)); Text(value, color = valueColor, fontSize = 21.sp, fontWeight = FontWeight.Bold); Text(detail, color = Muted, fontSize = 11.sp) } } }
@Composable private fun SmallValue(label: String, value: String) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(label, color = Muted, fontSize = 10.sp); Text(value, color = Text, fontSize = 20.sp, fontWeight = FontWeight.Bold) } }
@Composable private fun fieldColors() = OutlinedTextFieldDefaults.colors(focusedTextColor = Text, unfocusedTextColor = Text, focusedBorderColor = Accent, unfocusedBorderColor = Line, focusedLabelColor = Accent, unfocusedLabelColor = Muted, cursorColor = Accent)
private fun connectionLabel(state: ConnectionState) = when (state) { ConnectionState.SCANNING -> "Finding Pi…"; ConnectionState.CONNECTING -> "Connecting…"; ConnectionState.CONNECTED -> "Pair phone"; else -> "Start Bluetooth" }
private fun Double.round(decimals: Int) = String.format(Locale.getDefault(), "%.${decimals}f", this)
private val Bg = Color(0xFF10130F); private val Surface = Color(0xFF171B16); private val Raised = Color(0xFF252B23); private val Line = Color(0xFF30372D); private val Text = Color(0xFFF0F2EB); private val Muted = Color(0xFFAEB7A7); private val Accent = Color(0xFFB9E769); private val AccentInk = Color(0xFF172008); private val Danger = Color(0xFFFF9B8D)
@Composable private fun CompanionTheme(content: @Composable () -> Unit) { MaterialTheme(colorScheme = darkColorScheme(background = Bg, surface = Surface, primary = Accent, onPrimary = AccentInk, onBackground = Text, onSurface = Text, error = Danger), typography = Typography(), content = content) }
@@ -0,0 +1,32 @@
package cloud.molberg.picar
data class Shortcut(val id: Int, val name: String, val target: String)
data class SystemSummary(
val hostname: String,
val temperatureC: Double?,
val memoryUsedPercent: Int,
val diskUsedPercent: Int?,
val uptimeSeconds: Long,
val adbConnected: Boolean
)
data class CarSummary(
val speedKph: Double?,
val gear: String?,
val wheelAngle: Double?,
val batteryPercent: Double?,
val rangeKm: Double?,
val voltage: Double?,
val consumptionKwh: Double?,
val trackedDistanceKm: Double,
val averageSpeedKph: Double?,
val peakSpeedKph: Double?
)
data class Snapshot(val collectedAt: String, val system: SystemSummary, val car: CarSummary, val shortcuts: List<Shortcut>)
enum class ConnectionState { STOPPED, SCANNING, CONNECTING, CONNECTED, DISCONNECTED }
data class CompanionUiState(
val connection: ConnectionState = ConnectionState.STOPPED,
val paired: Boolean = false,
val snapshot: Snapshot? = null,
val error: String? = null,
val busyShortcut: Int? = null
)
@@ -0,0 +1,47 @@
package cloud.molberg.picar
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class TokenStore(context: Context) {
private val preferences = context.getSharedPreferences("companion_credentials", Context.MODE_PRIVATE)
private val alias = "pi_car_mobile_token"
fun save(token: String) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key())
preferences.edit()
.putString("token", Base64.encodeToString(cipher.doFinal(token.toByteArray()), Base64.NO_WRAP))
.putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
.apply()
}
fun load(): String? = runCatching {
val encrypted = Base64.decode(preferences.getString("token", null) ?: return null, Base64.NO_WRAP)
val iv = Base64.decode(preferences.getString("iv", null) ?: return null, Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, iv))
String(cipher.doFinal(encrypted))
}.getOrNull()
fun clear() = preferences.edit().clear().apply()
private fun key(): SecretKey {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(store.getKey(alias, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
init(KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build())
generateKey()
}
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="512" android:viewportHeight="512">
<path
android:pathData="M256,92 A164,164 0,1 0,256 420 A164,164 0,1 0,256 92"
android:fillColor="#171B16"
android:strokeColor="#30372D"
android:strokeWidth="16" />
<path
android:pathData="M148,300 A120,120 0,1 1,364 300"
android:fillColor="@android:color/transparent"
android:strokeColor="#B9E769"
android:strokeWidth="30"
android:strokeLineCap="round" />
<path
android:pathData="M256,268 L329,186"
android:fillColor="@android:color/transparent"
android:strokeColor="#F0F2EB"
android:strokeWidth="26"
android:strokeLineCap="round" />
<path
android:pathData="M256,238 A30,30 0,1 0,256 298 A30,30 0,1 0,256 238"
android:fillColor="#B9E769" />
<path
android:pathData="M173,337 L339,337"
android:fillColor="@android:color/transparent"
android:strokeColor="#F0F2EB"
android:strokeWidth="24"
android:strokeLineCap="round" />
</vector>
@@ -0,0 +1,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,3 @@
<resources>
<color name="launcher_background">#10130F</color>
</resources>
@@ -0,0 +1,9 @@
<resources>
<style name="Theme.PiCar" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:statusBarColor">#10130F</item>
<item name="android:navigationBarColor">#10130F</item>
<item name="android:windowBackground">#10130F</item>
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+9
View File
@@ -0,0 +1,9 @@
pluginManagement {
repositories { google(); mavenCentral(); gradlePluginPortal() }
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { google(); mavenCentral() }
}
rootProject.name = "PiCarCompanion"
include(":app")
+9
View File
@@ -41,6 +41,7 @@ apt-get update
apt-get install -y \ apt-get install -y \
android-tools-adb \ android-tools-adb \
avahi-daemon \ avahi-daemon \
bluez \
build-essential \ build-essential \
ca-certificates \ ca-certificates \
conntrack \ conntrack \
@@ -53,6 +54,8 @@ apt-get install -y \
network-manager \ network-manager \
nftables \ nftables \
python3 \ python3 \
python3-dbus \
python3-gi \
rsync \ rsync \
sqlite3 \ sqlite3 \
sudo \ sudo \
@@ -141,12 +144,15 @@ echo "[5/7] Installing system services"
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update
install -d -m 0755 /usr/local/libexec
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-bluetooth" /usr/local/libexec/pi-car-companion-bluetooth
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update-check.service" /etc/systemd/system/pi-car-companion-update-check.service install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update-check.service" /etc/systemd/system/pi-car-companion-update-check.service
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-bluetooth.service" /etc/systemd/system/pi-car-companion-bluetooth.service
install -m 0440 "${SCRIPT_DIR}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion install -m 0440 "${SCRIPT_DIR}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion
visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null
install -d -m 0755 /etc/avahi/services install -d -m 0755 /etc/avahi/services
@@ -214,11 +220,14 @@ echo "[6/7] Enabling start-on-boot services"
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now avahi-daemon systemctl enable --now avahi-daemon
systemctl enable pi-car-companion.service systemctl enable pi-car-companion.service
systemctl enable --now bluetooth.service
systemctl enable pi-car-companion-bluetooth.service
systemctl enable pi-car-companion-network-observer.service systemctl enable pi-car-companion-network-observer.service
if grep -qx 'NETWORK_MANAGER_ENABLED=true' "${CONFIG_DIR}/companion.env"; then if grep -qx 'NETWORK_MANAGER_ENABLED=true' "${CONFIG_DIR}/companion.env"; then
systemctl enable --now pi-car-companion-network.service systemctl enable --now pi-car-companion-network.service
fi fi
systemctl restart pi-car-companion.service systemctl restart pi-car-companion.service
systemctl restart pi-car-companion-bluetooth.service
systemctl restart pi-car-companion-network-observer.service systemctl restart pi-car-companion-network-observer.service
echo "[7/7] Verifying the service" echo "[7/7] Verifying the service"
+1 -1
View File
@@ -16,7 +16,7 @@
"typecheck": "npm run typecheck --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present",
"test": "npm run test --workspaces --if-present", "test": "npm run test --workspaces --if-present",
"build": "npm run build --workspaces --if-present", "build": "npm run build --workspaces --if-present",
"check:scripts": "bash -n install.sh scripts/pi-car-companion-update scripts/pi-car-companion-network-setup scripts/pi-car-companion-power", "check:scripts": "bash -n install.sh scripts/pi-car-companion-update scripts/pi-car-companion-network-setup scripts/pi-car-companion-power && python3 -c \"compile(open('scripts/pi-car-companion-bluetooth', encoding='utf-8').read(), 'scripts/pi-car-companion-bluetooth', 'exec')\"",
"check": "npm run check:scripts && npm run lint && npm run typecheck && npm test && npm run build" "check": "npm run check:scripts && npm run lint && npm run typecheck && npm test && npm run build"
}, },
"devDependencies": { "devDependencies": {
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""BlueZ GATT transport for the Pi Car Companion Android application."""
import json
import os
import threading
import urllib.error
import urllib.request
import uuid
from collections import deque
import dbus
import dbus.exceptions
import dbus.mainloop.glib
import dbus.service
from gi.repository import GLib
BLUEZ = "org.bluez"
DBUS_OM = "org.freedesktop.DBus.ObjectManager"
DBUS_PROPERTIES = "org.freedesktop.DBus.Properties"
GATT_MANAGER = "org.bluez.GattManager1"
GATT_SERVICE = "org.bluez.GattService1"
GATT_CHARACTERISTIC = "org.bluez.GattCharacteristic1"
AD_MANAGER = "org.bluez.LEAdvertisingManager1"
ADVERTISEMENT = "org.bluez.LEAdvertisement1"
SERVICE_UUID = "6c7a0001-7c6d-4f74-9bb0-c5a4e5efc001"
RX_UUID = "6c7a0002-7c6d-4f74-9bb0-c5a4e5efc001"
TX_UUID = "6c7a0003-7c6d-4f74-9bb0-c5a4e5efc001"
API_BASE = os.environ.get("MOBILE_API_BASE", "http://127.0.0.1:8787")
MAX_MESSAGE_BYTES = 64 * 1024
NOTIFY_CHUNK_BYTES = 180
class InvalidArgs(dbus.exceptions.DBusException):
_dbus_error_name = "org.freedesktop.DBus.Error.InvalidArgs"
class NotSupported(dbus.exceptions.DBusException):
_dbus_error_name = "org.bluez.Error.NotSupported"
class Application(dbus.service.Object):
def __init__(self, bus):
self.path = "/org/pi_car_companion"
self.services = []
super().__init__(bus, self.path)
def add_service(self, service):
self.services.append(service)
@dbus.service.method(DBUS_OM, out_signature="a{oa{sa{sv}}}")
def GetManagedObjects(self):
objects = {}
for service in self.services:
objects[service.path] = service.properties()
for characteristic in service.characteristics:
objects[characteristic.path] = characteristic.properties()
return objects
class Service(dbus.service.Object):
def __init__(self, bus, index, uuid_value):
self.path = f"/org/pi_car_companion/service{index}"
self.uuid = uuid_value
self.characteristics = []
super().__init__(bus, self.path)
def add_characteristic(self, characteristic):
self.characteristics.append(characteristic)
def properties(self):
return {GATT_SERVICE: {"UUID": self.uuid, "Primary": dbus.Boolean(True), "Characteristics": dbus.Array([item.path for item in self.characteristics], signature="o")}}
@dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
def GetAll(self, interface):
if interface != GATT_SERVICE:
raise InvalidArgs()
return self.properties()[GATT_SERVICE]
class Characteristic(dbus.service.Object):
def __init__(self, bus, index, uuid_value, flags, service):
self.path = service.path + f"/char{index}"
self.uuid = uuid_value
self.flags = flags
self.service = service
super().__init__(bus, self.path)
def properties(self):
return {GATT_CHARACTERISTIC: {"Service": dbus.ObjectPath(self.service.path), "UUID": self.uuid, "Flags": dbus.Array(self.flags, signature="s")}}
@dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
def GetAll(self, interface):
if interface != GATT_CHARACTERISTIC:
raise InvalidArgs()
return self.properties()[GATT_CHARACTERISTIC]
@dbus.service.signal(DBUS_PROPERTIES, signature="sa{sv}as")
def PropertiesChanged(self, interface, changed, invalidated):
pass
class TxCharacteristic(Characteristic):
def __init__(self, bus, service):
super().__init__(bus, 1, TX_UUID, ["notify"], service)
self.notifying = False
self.queue = deque()
self.sending = False
@dbus.service.method(GATT_CHARACTERISTIC, in_signature="a{sv}")
def StartNotify(self, _options):
self.notifying = True
@dbus.service.method(GATT_CHARACTERISTIC, in_signature="a{sv}")
def StopNotify(self, _options):
self.notifying = False
self.queue.clear()
def send(self, message):
encoded = (json.dumps(message, separators=(",", ":")) + "\n").encode("utf-8")
for offset in range(0, len(encoded), NOTIFY_CHUNK_BYTES):
self.queue.append(encoded[offset:offset + NOTIFY_CHUNK_BYTES])
if not self.sending:
self.sending = True
GLib.timeout_add(20, self._send_next)
def _send_next(self):
if not self.notifying or not self.queue:
self.sending = False
return False
chunk = self.queue.popleft()
value = dbus.Array([dbus.Byte(byte) for byte in chunk], signature="y")
self.PropertiesChanged(GATT_CHARACTERISTIC, {"Value": value}, [])
return bool(self.queue)
class RxCharacteristic(Characteristic):
def __init__(self, bus, service, tx):
super().__init__(bus, 0, RX_UUID, ["write", "write-without-response"], service)
self.tx = tx
self.buffer = bytearray()
@dbus.service.method(GATT_CHARACTERISTIC, in_signature="aya{sv}")
def WriteValue(self, value, _options):
self.buffer.extend(bytes(value))
if len(self.buffer) > MAX_MESSAGE_BYTES:
self.buffer.clear()
self.tx.send({"id": None, "ok": False, "error": {"code": "MESSAGE_TOO_LARGE", "message": "Bluetooth message was too large"}})
return
while b"\n" in self.buffer:
raw, _, remaining = self.buffer.partition(b"\n")
self.buffer = bytearray(remaining)
if raw:
threading.Thread(target=self._handle, args=(bytes(raw),), daemon=True).start()
def _handle(self, raw):
request_id = None
try:
message = json.loads(raw.decode("utf-8"))
request_id = message.get("id")
operation = message.get("op")
token = message.get("token")
payload = message.get("payload") or {}
method, path, body = route_operation(operation, payload)
data = call_api(method, path, body, token)
response = {"id": request_id, "ok": True, "data": data}
except ProtocolError as error:
response = {"id": request_id, "ok": False, "error": {"code": error.code, "message": str(error)}}
except Exception:
response = {"id": request_id, "ok": False, "error": {"code": "BRIDGE_ERROR", "message": "The Pi Bluetooth bridge could not complete the request"}}
GLib.idle_add(self.tx.send, response)
class ProtocolError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
def route_operation(operation, payload):
if operation == "pair":
return "POST", "/internal/mobile/pair", payload
if operation == "snapshot":
return "GET", "/internal/mobile/snapshot", None
if operation == "location":
return "POST", "/internal/mobile/location", payload
if operation == "launchShortcut":
shortcut_id = payload.get("id")
if not isinstance(shortcut_id, int) or shortcut_id < 1:
raise ProtocolError("INVALID_SHORTCUT", "Shortcut ID was invalid")
return "POST", f"/internal/mobile/shortcuts/{shortcut_id}/launch", {}
raise ProtocolError("UNKNOWN_OPERATION", "That Bluetooth operation is not available")
def call_api(method, path, body, token):
data = None if body is None else json.dumps(body).encode("utf-8")
headers = {"Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
if isinstance(token, str):
headers["X-Mobile-Token"] = token
request = urllib.request.Request(API_BASE + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=15) as response:
raw = response.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as error:
try:
detail = json.loads(error.read())
api_error = detail.get("error", {})
raise ProtocolError(api_error.get("code", "REQUEST_FAILED"), api_error.get("message", "The Pi rejected the request"))
except (ValueError, AttributeError):
raise ProtocolError("REQUEST_FAILED", "The Pi rejected the request") from error
except urllib.error.URLError as error:
raise ProtocolError("PI_SERVICE_UNAVAILABLE", "The Pi companion service is unavailable") from error
class Advertisement(dbus.service.Object):
def __init__(self, bus):
self.path = "/org/pi_car_companion/advertisement0"
super().__init__(bus, self.path)
@dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
def GetAll(self, interface):
if interface != ADVERTISEMENT:
raise InvalidArgs()
return {"Type": "peripheral", "ServiceUUIDs": dbus.Array([SERVICE_UUID], signature="s"), "LocalName": "Pi Car"}
@dbus.service.method(ADVERTISEMENT)
def Release(self):
pass
def find_adapter(bus):
objects = dbus.Interface(bus.get_object(BLUEZ, "/"), DBUS_OM).GetManagedObjects()
for path, interfaces in objects.items():
if GATT_MANAGER in interfaces and AD_MANAGER in interfaces:
return path
raise RuntimeError("No Bluetooth LE adapter with GATT server support was found")
def main():
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
adapter_path = find_adapter(bus)
adapter = bus.get_object(BLUEZ, adapter_path)
dbus.Interface(adapter, DBUS_PROPERTIES).Set("org.bluez.Adapter1", "Powered", dbus.Boolean(True))
application = Application(bus)
service = Service(bus, 0, SERVICE_UUID)
tx = TxCharacteristic(bus, service)
rx = RxCharacteristic(bus, service, tx)
service.add_characteristic(rx)
service.add_characteristic(tx)
application.add_service(service)
advertisement = Advertisement(bus)
dbus.Interface(adapter, GATT_MANAGER).RegisterApplication(application.path, {}, reply_handler=lambda: None, error_handler=lambda error: (_ for _ in ()).throw(error))
dbus.Interface(adapter, AD_MANAGER).RegisterAdvertisement(advertisement.path, {}, reply_handler=lambda: None, error_handler=lambda error: (_ for _ in ()).throw(error))
GLib.MainLoop().run()
if __name__ == "__main__":
main()
+4
View File
@@ -38,6 +38,7 @@ import {
import { CarTelemetryRecorder, type TelemetryPeriod } from "./car-telemetry.js"; import { CarTelemetryRecorder, type TelemetryPeriod } from "./car-telemetry.js";
import { openShell } from "./shell.js"; import { openShell } from "./shell.js";
import { systemPowerActionSchema, triggerSystemPower } from "./system.js"; import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
import { isLoopbackAddress, registerMobileRoutes } from "./mobile.js";
import "./types.js"; import "./types.js";
const usernameSchema = z const usernameSchema = z
@@ -189,6 +190,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
app.addHook("preValidation", async (request, reply) => { app.addHook("preValidation", async (request, reply) => {
if (!["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) return; if (!["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) return;
if (request.url.startsWith("/internal/mobile/") && isLoopbackAddress(request.ip)) return;
const origin = request.headers.origin; const origin = request.headers.origin;
let sameHostOrigin = false; let sameHostOrigin = false;
@@ -325,6 +327,8 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
return { loggedOut: true }; return { loggedOut: true };
}); });
registerMobileRoutes(app, database, config.adb, carTelemetry, requireUser);
app.get("/api/status", async (request, reply) => { app.get("/api/status", async (request, reply) => {
if (!requireUser(request, reply)) return; if (!requireUser(request, reply)) return;
return collectSystemStatus(config.adb); return collectSystemStatus(config.adb);
+31
View File
@@ -70,6 +70,37 @@ function migrate(database: CompanionDatabase): void {
UNIQUE(target_type, target_value) UNIQUE(target_type, target_value)
); );
CREATE TABLE IF NOT EXISTS mobile_pairing_codes (
id INTEGER PRIMARY KEY CHECK (id = 1),
code_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS mobile_devices (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS mobile_phone_state (
device_id TEXT PRIMARY KEY REFERENCES mobile_devices(id) ON DELETE CASCADE,
received_at TEXT NOT NULL,
captured_at TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
accuracy_meters REAL NOT NULL,
altitude_meters REAL,
speed_kph REAL,
bearing_degrees REAL,
battery_percent REAL,
network_type TEXT
);
CREATE TABLE IF NOT EXISTS car_telemetry_samples ( CREATE TABLE IF NOT EXISTS car_telemetry_samples (
recorded_at INTEGER PRIMARY KEY, recorded_at INTEGER PRIMARY KEY,
speed_kph REAL, speed_kph REAL,
+185
View File
@@ -0,0 +1,185 @@
import { randomInt } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import type { AdbConfig } from "./adb.js";
import { launchAdbTarget } from "./adb.js";
import type { CarTelemetryRecorder } from "./car-telemetry.js";
import type { CompanionDatabase } from "./database.js";
import { hashToken, randomToken } from "./security.js";
import { collectSystemStatus } from "./status.js";
const pairingSchema = z.object({
code: z.string().regex(/^\d{6}$/),
deviceId: z.string().uuid(),
deviceName: z.string().trim().min(1).max(80)
});
const locationSchema = z.object({
capturedAt: z.string().datetime(),
latitude: z.number().finite().min(-90).max(90),
longitude: z.number().finite().min(-180).max(180),
accuracyMeters: z.number().finite().min(0).max(10_000),
altitudeMeters: z.number().finite().min(-1_000).max(20_000).nullable().optional(),
speedKph: z.number().finite().min(0).max(1_000).nullable().optional(),
bearingDegrees: z.number().finite().min(0).max(360).nullable().optional(),
batteryPercent: z.number().finite().min(0).max(100).nullable().optional(),
networkType: z.enum(["offline", "wifi", "cellular", "ethernet", "other"]).nullable().optional()
});
type MobileDevice = { id: string; name: string; createdAt: string; lastSeenAt: string; revokedAt: string | null };
type AuthenticatedMobileDevice = { id: string; name: string };
export function isLoopbackAddress(address: string): boolean {
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
}
function requireLoopback(request: FastifyRequest, reply: FastifyReply): boolean {
if (isLoopbackAddress(request.ip)) return true;
void reply.code(404).send({ error: { code: "NOT_FOUND", message: "Not found" } });
return false;
}
function authenticateMobile(database: CompanionDatabase, request: FastifyRequest, reply: FastifyReply): AuthenticatedMobileDevice | null {
if (!requireLoopback(request, reply)) return null;
const token = request.headers["x-mobile-token"];
if (typeof token !== "string" || token.length < 32) {
void reply.code(401).send({ error: { code: "MOBILE_AUTH_REQUIRED", message: "Pair the phone again" } });
return null;
}
const device = database.prepare(
"SELECT id, name FROM mobile_devices WHERE token_hash = ? AND revoked_at IS NULL"
).get(hashToken(token)) as AuthenticatedMobileDevice | undefined;
if (!device) {
void reply.code(401).send({ error: { code: "MOBILE_AUTH_INVALID", message: "This phone is no longer paired" } });
return null;
}
database.prepare("UPDATE mobile_devices SET last_seen_at = ? WHERE id = ?").run(new Date().toISOString(), device.id);
return device;
}
function auditMobile(database: CompanionDatabase, device: AuthenticatedMobileDevice, action: string, result: "success" | "failure", details: Record<string, unknown> = {}) {
database.prepare(
"INSERT INTO audit_events (action, result, details_json, created_at) VALUES (?, ?, ?, ?)"
).run(action, result, JSON.stringify({ mobileDeviceId: device.id, mobileDeviceName: device.name, ...details }), new Date().toISOString());
}
export function registerMobileRoutes(
app: FastifyInstance,
database: CompanionDatabase,
adbConfig: AdbConfig,
carTelemetry: CarTelemetryRecorder,
requireWebUser: (request: FastifyRequest, reply: FastifyReply) => boolean
): void {
app.post("/api/mobile/pairing", async (request, reply) => {
if (!requireWebUser(request, reply)) return;
const code = String(randomInt(100_000, 1_000_000));
const now = new Date();
const expiresAt = new Date(now.getTime() + 10 * 60_000).toISOString();
database.prepare(
`INSERT INTO mobile_pairing_codes (id, code_hash, expires_at, created_by, created_at)
VALUES (1, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET
code_hash = excluded.code_hash, expires_at = excluded.expires_at,
created_by = excluded.created_by, created_at = excluded.created_at`
).run(hashToken(code), expiresAt, request.authUser!.id, now.toISOString());
return { code, expiresAt };
});
app.get("/api/mobile/devices", async (request, reply) => {
if (!requireWebUser(request, reply)) return;
const devices = database.prepare(
`SELECT id, name, created_at AS createdAt, last_seen_at AS lastSeenAt, revoked_at AS revokedAt
FROM mobile_devices ORDER BY created_at DESC`
).all() as MobileDevice[];
return { devices };
});
app.delete<{ Params: { id: string } }>("/api/mobile/devices/:id", async (request, reply) => {
if (!requireWebUser(request, reply)) return;
const result = database.prepare("UPDATE mobile_devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL")
.run(new Date().toISOString(), request.params.id);
if (result.changes === 0) return reply.code(404).send({ error: { code: "MOBILE_DEVICE_NOT_FOUND", message: "Phone not found" } });
return reply.code(204).send();
});
app.post("/internal/mobile/pair", { config: { rateLimit: { max: 8, timeWindow: "1 minute" } } }, async (request, reply) => {
if (!requireLoopback(request, reply)) return;
const parsed = pairingSchema.safeParse(request.body);
if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PAIRING_REQUEST", message: "Enter the six-digit pairing code" } });
const row = database.prepare("SELECT code_hash, expires_at FROM mobile_pairing_codes WHERE id = 1")
.get() as { code_hash: string; expires_at: string } | undefined;
if (!row || row.code_hash !== hashToken(parsed.data.code) || row.expires_at <= new Date().toISOString()) {
return reply.code(401).send({ error: { code: "PAIRING_CODE_INVALID", message: "The pairing code is invalid or expired" } });
}
const token = randomToken();
const now = new Date().toISOString();
database.transaction(() => {
database.prepare(
`INSERT INTO mobile_devices (id, name, token_hash, created_at, last_seen_at, revoked_at)
VALUES (?, ?, ?, ?, ?, NULL) ON CONFLICT(id) DO UPDATE SET
name = excluded.name, token_hash = excluded.token_hash,
last_seen_at = excluded.last_seen_at, revoked_at = NULL`
).run(parsed.data.deviceId, parsed.data.deviceName, hashToken(token), now, now);
database.prepare("DELETE FROM mobile_pairing_codes WHERE id = 1").run();
})();
return { token, deviceId: parsed.data.deviceId, pairedAt: now };
});
app.get("/internal/mobile/snapshot", async (request, reply) => {
const device = authenticateMobile(database, request, reply);
if (!device) return;
const [system, car] = await Promise.all([
collectSystemStatus(adbConfig),
carTelemetry.readLive().catch(() => null)
]);
const shortcuts = database.prepare(
"SELECT id, name, target_type AS type, target_value AS value FROM adb_shortcuts ORDER BY created_at, id"
).all();
const phone = database.prepare(
`SELECT received_at AS receivedAt, captured_at AS capturedAt, latitude, longitude,
accuracy_meters AS accuracyMeters, altitude_meters AS altitudeMeters, speed_kph AS speedKph,
bearing_degrees AS bearingDegrees, battery_percent AS batteryPercent, network_type AS networkType
FROM mobile_phone_state WHERE device_id = ?`
).get(device.id) ?? null;
return { protocolVersion: 1, collectedAt: new Date().toISOString(), system, car, statistics: carTelemetry.store.overview(), shortcuts, phone };
});
app.post("/internal/mobile/location", async (request, reply) => {
const device = authenticateMobile(database, request, reply);
if (!device) return;
const parsed = locationSchema.safeParse(request.body);
if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PHONE_LOCATION", message: "The phone location was invalid" } });
const value = parsed.data;
database.prepare(
`INSERT INTO mobile_phone_state
(device_id, received_at, captured_at, latitude, longitude, accuracy_meters, altitude_meters,
speed_kph, bearing_degrees, battery_percent, network_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET
received_at = excluded.received_at, captured_at = excluded.captured_at,
latitude = excluded.latitude, longitude = excluded.longitude, accuracy_meters = excluded.accuracy_meters,
altitude_meters = excluded.altitude_meters, speed_kph = excluded.speed_kph,
bearing_degrees = excluded.bearing_degrees, battery_percent = excluded.battery_percent,
network_type = excluded.network_type`
).run(device.id, new Date().toISOString(), value.capturedAt, value.latitude, value.longitude, value.accuracyMeters,
value.altitudeMeters ?? null, value.speedKph ?? null, value.bearingDegrees ?? null,
value.batteryPercent ?? null, value.networkType ?? null);
return { accepted: true };
});
app.post<{ Params: { id: string } }>("/internal/mobile/shortcuts/:id/launch", async (request, reply) => {
const device = authenticateMobile(database, request, reply);
if (!device) return;
const id = Number(request.params.id);
const shortcut = Number.isInteger(id) ? database.prepare(
"SELECT target_type AS type, target_value AS value FROM adb_shortcuts WHERE id = ?"
).get(id) as { type: "component" | "package"; value: string } | undefined : undefined;
if (!shortcut) return reply.code(404).send({ error: { code: "ADB_SHORTCUT_NOT_FOUND", message: "Shortcut not found" } });
try {
await launchAdbTarget(adbConfig, shortcut);
auditMobile(database, device, "mobile.adb-launch", "success", { shortcutId: id });
return { launched: true };
} catch {
auditMobile(database, device, "mobile.adb-launch", "failure", { shortcutId: id });
return reply.code(503).send({ error: { code: "ADB_LAUNCH_FAILED", message: "The shortcut could not be opened" } });
}
});
}
+60
View File
@@ -341,6 +341,66 @@ describe("authentication boundary", () => {
expect(removed.statusCode).toBe(204); expect(removed.statusCode).toBe(204);
}); });
it("pairs, updates, and revokes a Bluetooth mobile companion", async () => {
const app = await createApp();
const csrfToken = await csrf(app);
await setup(app, csrfToken);
const login = await app.inject({
method: "POST",
url: "/api/auth/login",
headers: mutationHeaders(csrfToken),
payload: { username: "owner", password: "Correct-horse1" }
});
const session = login.cookies.find((item) => item.name === SESSION_COOKIE)?.value ?? "";
const cookie = `${CSRF_COOKIE}=${csrfToken}; ${SESSION_COOKIE}=${session}`;
const webHeaders = { ...mutationHeaders(csrfToken), cookie };
expect((await app.inject({ method: "GET", url: "/api/mobile/devices" })).statusCode).toBe(401);
const pairing = await app.inject({ method: "POST", url: "/api/mobile/pairing", headers: webHeaders });
expect(pairing.statusCode).toBe(200);
const code = pairing.json<{ code: string }>().code;
expect(code).toMatch(/^\d{6}$/);
const deviceId = "2f9ca5d6-28cd-45a7-a7f5-27a13dc9eb98";
const paired = await app.inject({
method: "POST",
url: "/internal/mobile/pair",
payload: { code, deviceId, deviceName: "Owner phone" }
});
expect(paired.statusCode).toBe(200);
const mobileToken = paired.json<{ token: string }>().token;
const mobileHeaders = { "x-mobile-token": mobileToken };
const location = await app.inject({
method: "POST",
url: "/internal/mobile/location",
headers: mobileHeaders,
payload: {
capturedAt: "2026-07-31T20:00:00.000Z",
latitude: 55.6761,
longitude: 12.5683,
accuracyMeters: 4.2,
speedKph: 42.5,
batteryPercent: 76,
networkType: "cellular"
}
});
expect(location.statusCode).toBe(200);
const snapshot = await app.inject({ method: "GET", url: "/internal/mobile/snapshot", headers: mobileHeaders });
expect(snapshot.statusCode).toBe(200);
expect(snapshot.json()).toMatchObject({
protocolVersion: 1,
phone: { latitude: 55.6761, speedKph: 42.5, networkType: "cellular" },
shortcuts: []
});
const devices = await app.inject({ method: "GET", url: "/api/mobile/devices", headers: { cookie } });
expect(devices.json()).toMatchObject({ devices: [{ id: deviceId, name: "Owner phone", revokedAt: null }] });
expect((await app.inject({ method: "DELETE", url: `/api/mobile/devices/${deviceId}`, headers: webHeaders })).statusCode).toBe(204);
expect((await app.inject({ method: "GET", url: "/internal/mobile/snapshot", headers: mobileHeaders })).statusCode).toBe(401);
});
it("exposes only allowlisted jobs and authenticated run history", async () => { it("exposes only allowlisted jobs and authenticated run history", async () => {
const app = await createApp(); const app = await createApp();
const token = await csrf(app); const token = await csrf(app);
@@ -0,0 +1,24 @@
[Unit]
Description=Pi Car Companion Bluetooth bridge
After=bluetooth.service pi-car-companion.service
Requires=bluetooth.service
Wants=pi-car-companion.service
[Service]
Type=simple
ExecStart=/usr/local/libexec/pi-car-companion-bluetooth
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
LockPersonality=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
+73 -3
View File
@@ -2,6 +2,7 @@ import { lazy, Suspense, useCallback, useEffect, useState, type FormEvent } from
import { import {
ArrowClockwise, ArrowClockwise,
AndroidLogo, AndroidLogo,
Bluetooth,
CarProfile, CarProfile,
CheckCircle, CheckCircle,
Cpu, Cpu,
@@ -14,6 +15,7 @@ import {
Power, Power,
SignOut, SignOut,
TerminalWindow, TerminalWindow,
Trash,
WarningCircle, WarningCircle,
WifiHigh WifiHigh
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
@@ -26,12 +28,15 @@ import {
getNetworkStatus, getNetworkStatus,
getNetworkActivity, getNetworkActivity,
getUpdateStatus, getUpdateStatus,
getMobileDevices,
post, post,
revokeMobileDevice,
type AuthState, type AuthState,
type SystemStatus, type SystemStatus,
type NetworkStatus, type NetworkStatus,
type NetworkActivity, type NetworkActivity,
type UpdateStatus type UpdateStatus,
type MobileDevice
} from "./api"; } from "./api";
const ShellTerminal = lazy(async () => { const ShellTerminal = lazy(async () => {
@@ -322,7 +327,7 @@ function portLabel(port: number): string {
} }
function SettingsPage({ csrfToken }: { csrfToken: string }) { function SettingsPage({ csrfToken }: { csrfToken: string }) {
const [tab, setTab] = useState<"network" | "system" | "shell">("network"); const [tab, setTab] = useState<"network" | "system" | "mobile" | "shell">("network");
const [update, setUpdate] = useState<UpdateStatus | null>(null); const [update, setUpdate] = useState<UpdateStatus | null>(null);
const [network, setNetwork] = useState<NetworkStatus | null>(null); const [network, setNetwork] = useState<NetworkStatus | null>(null);
const [updateError, setUpdateError] = useState(""); const [updateError, setUpdateError] = useState("");
@@ -338,6 +343,9 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
const [ssidTouched, setSsidTouched] = useState(false); const [ssidTouched, setSsidTouched] = useState(false);
const [confirmHotspot, setConfirmHotspot] = useState(false); const [confirmHotspot, setConfirmHotspot] = useState(false);
const [networkNotice, setNetworkNotice] = useState(""); const [networkNotice, setNetworkNotice] = useState("");
const [mobileDevices, setMobileDevices] = useState<MobileDevice[]>([]);
const [pairingCode, setPairingCode] = useState<{ code: string; expiresAt: string } | null>(null);
const [mobileError, setMobileError] = useState("");
const running = update?.state === "checking" || update?.state === "building"; const running = update?.state === "checking" || update?.state === "building";
const refreshUpdate = useCallback(async () => { const refreshUpdate = useCallback(async () => {
@@ -358,15 +366,49 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
} }
}, []); }, []);
const refreshMobile = useCallback(async () => {
try {
setMobileDevices(await getMobileDevices());
setMobileError("");
} catch (caught) {
setMobileError(caught instanceof Error ? caught.message : "Paired phones could not be loaded");
}
}, []);
useEffect(() => { useEffect(() => {
void refreshUpdate(); void refreshUpdate();
void refreshNetwork(); void refreshNetwork();
void refreshMobile();
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
void refreshUpdate(); void refreshUpdate();
void refreshNetwork(); void refreshNetwork();
}, 3_000); }, 3_000);
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [refreshNetwork, refreshUpdate]); }, [refreshMobile, refreshNetwork, refreshUpdate]);
async function createPairingCode() {
setRequesting(true);
setMobileError("");
try {
setPairingCode(await post<{ code: string; expiresAt: string }>("/api/mobile/pairing", {}, csrfToken));
} catch (caught) {
setMobileError(caught instanceof Error ? caught.message : "A pairing code could not be created");
} finally {
setRequesting(false);
}
}
async function revokePhone(device: MobileDevice) {
setRequesting(true);
try {
await revokeMobileDevice(device.id, csrfToken);
await refreshMobile();
} catch (caught) {
setMobileError(caught instanceof Error ? caught.message : "The phone could not be revoked");
} finally {
setRequesting(false);
}
}
useEffect(() => { useEffect(() => {
if (!ssidTouched && network?.hotspotSsid) setHotspotSsid(network.hotspotSsid); if (!ssidTouched && network?.hotspotSsid) setHotspotSsid(network.hotspotSsid);
@@ -489,6 +531,7 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
<nav className="settings-tabs" aria-label="Settings categories" role="tablist"> <nav className="settings-tabs" aria-label="Settings categories" role="tablist">
<button role="tab" aria-selected={tab === "network"} className={tab === "network" ? "active" : ""} onClick={() => setTab("network")}><WifiHigh size={20} />Network</button> <button role="tab" aria-selected={tab === "network"} className={tab === "network" ? "active" : ""} onClick={() => setTab("network")}><WifiHigh size={20} />Network</button>
<button role="tab" aria-selected={tab === "system"} className={tab === "system" ? "active" : ""} onClick={() => setTab("system")}><Gear size={20} />System</button> <button role="tab" aria-selected={tab === "system"} className={tab === "system" ? "active" : ""} onClick={() => setTab("system")}><Gear size={20} />System</button>
<button role="tab" aria-selected={tab === "mobile"} className={tab === "mobile" ? "active" : ""} onClick={() => setTab("mobile")}><Bluetooth size={20} />Mobile</button>
<button role="tab" aria-selected={tab === "shell"} className={tab === "shell" ? "active" : ""} onClick={() => setTab("shell")}><TerminalWindow size={20} />Shell</button> <button role="tab" aria-selected={tab === "shell"} className={tab === "shell" ? "active" : ""} onClick={() => setTab("shell")}><TerminalWindow size={20} />Shell</button>
</nav> </nav>
@@ -612,6 +655,33 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
</section> </section>
</div>} </div>}
{tab === "mobile" && <div className="settings-layout mobile-settings-layout" role="tabpanel">
<section className="settings-panel mobile-pairing-panel">
<div className="settings-title">
<div className="settings-icon"><Bluetooth size={30} weight="duotone" /></div>
<div><h2>Android companion</h2><p>Pair the phone app over Bluetooth to receive live Pi and car status and contribute phone GPS.</p></div>
</div>
{mobileError && <div className="form-error" role="alert"><WarningCircle size={20} />{mobileError}</div>}
{pairingCode ? <div className="pairing-code" role="status">
<span>Enter this code in the Android app</span>
<strong>{pairingCode.code}</strong>
<small>Expires {new Date(pairingCode.expiresAt).toLocaleTimeString()}</small>
</div> : <p className="settings-note">Codes work once and expire after ten minutes. The phone receives a revocable credential stored in Android Keystore.</p>}
<button className="primary-button" onClick={() => void createPairingCode()} disabled={requesting}><Bluetooth size={22} />{pairingCode ? "Generate another code" : "Pair a phone"}</button>
</section>
<section className="settings-panel mobile-devices-panel">
<div className="panel-heading"><div><h2>Paired phones</h2><p>Revoke a phone immediately if it is lost or replaced.</p></div></div>
<div className="mobile-device-list">
{mobileDevices.filter((device) => !device.revokedAt).map((device) => <div className="mobile-device" key={device.id}>
<Bluetooth size={22} />
<div><strong>{device.name}</strong><small>Last seen {new Date(device.lastSeenAt).toLocaleString()}</small></div>
<button className="icon-button" aria-label={`Revoke ${device.name}`} onClick={() => void revokePhone(device)} disabled={requesting}><Trash size={19} /></button>
</div>)}
{mobileDevices.every((device) => device.revokedAt) && <div className="activity-empty">No phones are paired yet.</div>}
</div>
</section>
</div>}
{tab === "shell" && <div role="tabpanel"><Suspense fallback={<div className="shell-loading">Loading terminal</div>}><ShellTerminal csrfToken={csrfToken} /></Suspense></div>} {tab === "shell" && <div role="tabpanel"><Suspense fallback={<div className="shell-loading">Loading terminal</div>}><ShellTerminal csrfToken={csrfToken} /></Suspense></div>}
{confirming && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirming(false)}> {confirming && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirming(false)}>
+23
View File
@@ -114,6 +114,14 @@ export type AdbShortcut = AdbTarget & {
createdAt: string; createdAt: string;
}; };
export type MobileDevice = {
id: string;
name: string;
createdAt: string;
lastSeenAt: string;
revokedAt: string | null;
};
export type CarTelemetry = { export type CarTelemetry = {
available: true; available: true;
collectedAt: string; collectedAt: string;
@@ -247,6 +255,21 @@ export async function getAdbShortcuts(): Promise<AdbShortcut[]> {
return response.shortcuts; return response.shortcuts;
} }
export async function getMobileDevices(): Promise<MobileDevice[]> {
const response = await parseResponse<{ devices: MobileDevice[] }>(
await fetch("/api/mobile/devices", { credentials: "same-origin" })
);
return response.devices;
}
export async function revokeMobileDevice(id: string, csrfToken: string): Promise<void> {
await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "same-origin",
headers: { "X-CSRF-Token": csrfToken }
}));
}
export async function uploadApk(file: File, csrfToken: string): Promise<void> { export async function uploadApk(file: File, csrfToken: string): Promise<void> {
await parseResponse( await parseResponse(
await fetch("/api/adb/install", { await fetch("/api/adb/install", {
+9
View File
@@ -138,6 +138,15 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.activity-privacy { margin: 0; color: var(--muted); font-size: 0.72rem; text-align: right; } .activity-privacy { margin: 0; color: var(--muted); font-size: 0.72rem; text-align: right; }
.settings-page { min-width: 0; width: 100%; display: grid; gap: 18px; } .settings-page { min-width: 0; width: 100%; display: grid; gap: 18px; }
.mobile-settings-layout { grid-template-columns: minmax(340px, 0.8fr) minmax(420px, 1.2fr); }
.pairing-code { display: grid; justify-items: center; gap: 8px; margin: 28px 0 20px; padding: 24px; background: #10140f; border: 1px solid var(--line); border-radius: 12px; }
.pairing-code span, .pairing-code small { color: var(--muted); font-size: 0.78rem; }
.pairing-code strong { color: var(--accent); font: 800 2.7rem/1 ui-monospace, "Cascadia Code", monospace; letter-spacing: 0.18em; }
.mobile-device-list { display: grid; gap: 9px; margin-top: 22px; }
.mobile-device { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; min-height: 65px; padding: 10px 12px; background: #121610; border-radius: 10px; }
.mobile-device > svg { color: var(--accent); }
.mobile-device div { min-width: 0; display: grid; gap: 4px; }
.mobile-device small { overflow: hidden; color: var(--muted); font-size: 0.72rem; text-overflow: ellipsis; white-space: nowrap; }
.settings-page > [role="tabpanel"] { min-width: 0; width: 100%; max-width: 100%; } .settings-page > [role="tabpanel"] { min-width: 0; width: 100%; max-width: 100%; }
.settings-tabs { width: fit-content; display: flex; gap: 5px; padding: 5px; background: #121610; border: 1px solid var(--line); border-radius: 12px; } .settings-tabs { width: fit-content; display: flex; gap: 5px; padding: 5px; background: #121610; border: 1px solid var(--line); border-radius: 12px; }
.settings-tabs button { min-height: 48px; display: inline-flex; align-items: center; gap: 9px; padding: 0 19px; color: var(--muted); background: transparent; border: 0; border-radius: 8px; font-weight: 700; cursor: pointer; transition: color 160ms ease, background 160ms ease, transform 120ms ease; } .settings-tabs button { min-height: 48px; display: inline-flex; align-items: center; gap: 9px; padding: 0 19px; color: var(--muted); background: transparent; border: 0; border-radius: 8px; font-weight: 700; cursor: pointer; transition: color 160ms ease, background 160ms ease, transform 120ms ease; }