docs: FEAT-05 DTC sync shipped — multi-device selector, portal DTC page, Pi snapshot upload
This commit is contained in:
+45
@@ -106,6 +106,51 @@ dedicated PLAN.md phase (or fold it into the relevant existing work) and mark it
|
|||||||
correctly on wlan0 AP and home WiFi. Settings "Last phone IP" link is the workaround.
|
correctly on wlan0 AP and home WiFi. Settings "Last phone IP" link is the workaround.
|
||||||
Cannot be fixed in software — this is a phone-OS limitation.
|
Cannot be fixed in software — this is a phone-OS limitation.
|
||||||
|
|
||||||
|
### FEAT-10 — Home Assistant integration
|
||||||
|
- **Status**: `IDEA`
|
||||||
|
- **Area**: `modules/app_homeassistant.py` (new), `server/` (API endpoints), Pi-side MQTT/HTTP
|
||||||
|
- **Phase**: unassigned
|
||||||
|
- **Requested**: owner — wants the car's live telemetry and status visible in Home Assistant
|
||||||
|
dashboards alongside other smart-home devices.
|
||||||
|
- **Idea**: expose yaris-obd data (speed, RPM, coolant temp, fuel level, GPS position, DTC
|
||||||
|
alerts, ignition state, trip stats, cellular signal, etc.) as Home Assistant entities so
|
||||||
|
they appear automatically via MQTT discovery or a REST-based integration. The Pi already
|
||||||
|
collects all this data — the integration just needs to publish it in a format Home
|
||||||
|
Assistant can consume.
|
||||||
|
- **Possible approaches** (to scope later):
|
||||||
|
- **MQTT discovery**: Pi publishes to a local MQTT broker (e.g. Mosquitto on the same
|
||||||
|
network or on the HA host) with Home Assistant's auto-discovery topic schema
|
||||||
|
(`homeassistant/sensor/yaris/.../config`). HA picks up the entities automatically with
|
||||||
|
zero manual YAML. Works well on home WiFi; not available on phone hotspot or cellular.
|
||||||
|
- **REST API integration**: a custom Home Assistant integration (HACS or core) that polls
|
||||||
|
the Pi's `/api/stats` (or a new `/api/v1/ha/state` endpoint on the server) periodically.
|
||||||
|
Works from anywhere the server is reachable, including remotely via the self-hosted
|
||||||
|
server.
|
||||||
|
- **Hybrid**: MQTT when on home WiFi (real-time, low-latency), REST via server as a
|
||||||
|
fallback for remote/cellular visibility.
|
||||||
|
- **Entity ideas**:
|
||||||
|
- `sensor.yaris_speed` (km/h), `sensor.yaris_rpm`, `sensor.yaris_coolant_temp` (°C)
|
||||||
|
- `sensor.yaris_fuel_level` (%), `sensor.yaris_odometer` (km)
|
||||||
|
- `device_tracker.yaris_location` (lat/lon from GPS)
|
||||||
|
- `binary_sensor.yaris_ignition` (on/off), `binary_sensor.yaris_mil` (check engine light)
|
||||||
|
- `sensor.yaris_dtc_count`, `binary_sensor.yaris_dtc_active` (any active DTCs)
|
||||||
|
- `sensor.yaris_cellular_signal` (dBm or %), `sensor.yaris_uplink_mode` (home/phone/cell)
|
||||||
|
- `sensor.yaris_trip_distance`, `sensor.yaris_trip_duration` (current trip)
|
||||||
|
- `device_tracker.yaris_phone_hotspot` (when connected via phone hotspot — confirms car
|
||||||
|
is near the phone)
|
||||||
|
- **Outstanding considerations**:
|
||||||
|
- MQTT path only works when the Pi is on the same LAN as Home Assistant (home WiFi) — not
|
||||||
|
in the car on its own AP or cellular. This may be fine for "car is home" use cases
|
||||||
|
(charging status, maintenance reminders, trip summaries after returning).
|
||||||
|
- REST path via the self-hosted server works remotely but adds latency and depends on the
|
||||||
|
server being up.
|
||||||
|
- Decide whether to publish directly from the Pi or from the server (server already has
|
||||||
|
post-upload trip data; Pi has real-time data the server doesn't).
|
||||||
|
- Home Assistant OS vs container vs core install — MQTT discovery is universal, REST
|
||||||
|
would need a custom component or HACS add-on.
|
||||||
|
- Keep the integration opt-in and lightweight — don't add MQTT dependencies to the core
|
||||||
|
app.py startup unless the user explicitly configures it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ideas that landed
|
## Ideas that landed
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ dependencies {
|
|||||||
// implementation(libs.room.ktx)
|
// implementation(libs.room.ktx)
|
||||||
// ksp(libs.room.compiler)
|
// ksp(libs.room.compiler)
|
||||||
|
|
||||||
// Background work — will be used in 28.3
|
// Background work
|
||||||
// implementation(libs.workmanager)
|
implementation(libs.workmanager)
|
||||||
|
|
||||||
// Images
|
// Images
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package com.yarisobd.app
|
package com.yarisobd.app
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.workDataOf
|
||||||
import com.yarisobd.app.data.remote.PiApi
|
import com.yarisobd.app.data.remote.PiApi
|
||||||
import com.yarisobd.app.data.remote.dto.VideoFile
|
import com.yarisobd.app.data.remote.dto.VideoFile
|
||||||
import com.yarisobd.app.data.remote.dto.VideoListResponse
|
import com.yarisobd.app.data.remote.dto.VideoListResponse
|
||||||
@@ -16,14 +17,10 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okio.buffer
|
|
||||||
import okio.sink
|
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -109,57 +106,35 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun downloadVideo(video: VideoFile) {
|
fun downloadVideo(video: VideoFile) {
|
||||||
viewModelScope.launch {
|
val url = "$baseUrl/api/local-video/file?path=${video.path}"
|
||||||
val app = getApplication<YarisApp>()
|
val filename = video.path.substringAfterLast("/")
|
||||||
val dir = app.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return@launch
|
|
||||||
val localFile = File(dir, video.path.substringAfterLast("/"))
|
|
||||||
|
|
||||||
// Skip if already downloaded and size matches
|
// Check if already downloaded
|
||||||
if (localFile.exists() && localFile.length() == video.size) {
|
val dir = getDownloadDir()
|
||||||
_downloadEvents.emit("✓ Already downloaded: ${localFile.name}")
|
val localFile = File(dir, filename)
|
||||||
return@launch
|
if (localFile.exists() && localFile.length() == video.size && video.size > 0) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_downloadEvents.emit("✓ Already downloaded: $filename")
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||||
val result = withContext(Dispatchers.IO) {
|
.setInputData(
|
||||||
val request = Request.Builder()
|
workDataOf(
|
||||||
.url("$baseUrl/api/local-video/file?path=${video.path}")
|
DownloadWorker.KEY_PATH to video.path,
|
||||||
.get()
|
DownloadWorker.KEY_URL to url,
|
||||||
|
DownloadWorker.KEY_FILENAME to filename,
|
||||||
|
DownloadWorker.KEY_FILE_SIZE to video.size,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addTag("download_${video.path}")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val response = okHttp.newCall(request).execute()
|
WorkManager.getInstance(getApplication()).enqueue(request)
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return@withContext "Download failed: HTTP ${response.code}"
|
|
||||||
}
|
|
||||||
|
|
||||||
val body = response.body ?: return@withContext "Download failed: Empty body"
|
viewModelScope.launch {
|
||||||
val total = body.contentLength()
|
_downloadEvents.emit("⏳ Downloading: $filename (continues in background)")
|
||||||
localFile.parentFile?.mkdirs()
|
|
||||||
|
|
||||||
var downloaded = 0L
|
|
||||||
body.source().use { source ->
|
|
||||||
localFile.sink().buffer().use { sink ->
|
|
||||||
val buffer = okio.Buffer()
|
|
||||||
while (true) {
|
|
||||||
val read = source.read(buffer, 8192)
|
|
||||||
if (read == -1L) break
|
|
||||||
sink.write(buffer, read)
|
|
||||||
downloaded += read
|
|
||||||
val progress = DownloadProgress(video.path, downloaded, total)
|
|
||||||
_downloadProgress.value = _downloadProgress.value + (video.path to progress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_downloadProgress.value = _downloadProgress.value - video.path
|
|
||||||
"✓ Downloaded: ${localFile.name} (${downloaded} bytes)"
|
|
||||||
}
|
|
||||||
_downloadEvents.emit(result)
|
|
||||||
scanDownloads()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_downloadProgress.value = _downloadProgress.value - video.path
|
|
||||||
_downloadEvents.emit("✗ Download failed: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package com.yarisobd.app
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ForegroundInfo
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.workDataOf
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class DownloadWorker(
|
||||||
|
context: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
|
private val okHttp = OkHttpClient.Builder().build()
|
||||||
|
private val notificationManager = NotificationManagerCompat.from(context)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KEY_PATH = "path"
|
||||||
|
const val KEY_URL = "url"
|
||||||
|
const val KEY_FILENAME = "filename"
|
||||||
|
const val KEY_FILE_SIZE = "file_size"
|
||||||
|
const val CHANNEL_ID = "yaris_downloads"
|
||||||
|
const val NOTIFICATION_ID_BASE = 2000
|
||||||
|
|
||||||
|
fun createChannel(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Downloads",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Dashcam video downloads"
|
||||||
|
}
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||||
|
val path = inputData.getString(KEY_PATH) ?: return@withContext Result.failure()
|
||||||
|
val url = inputData.getString(KEY_URL) ?: return@withContext Result.failure()
|
||||||
|
val filename = inputData.getString(KEY_FILENAME) ?: path.substringAfterLast("/")
|
||||||
|
val totalSize = inputData.getLong(KEY_FILE_SIZE, 0)
|
||||||
|
|
||||||
|
val dir = File(
|
||||||
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
|
||||||
|
"yaris-obd"
|
||||||
|
)
|
||||||
|
dir.mkdirs()
|
||||||
|
val localFile = File(dir, filename)
|
||||||
|
|
||||||
|
// Skip if already downloaded
|
||||||
|
if (localFile.exists() && localFile.length() == totalSize && totalSize > 0) {
|
||||||
|
return@withContext Result.success(
|
||||||
|
workDataOf("filename" to filename, "bytes" to totalSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setForeground(createForegroundInfo(filename))
|
||||||
|
|
||||||
|
try {
|
||||||
|
val request = Request.Builder().url(url).get().build()
|
||||||
|
val response = okHttp.newCall(request).execute()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
return@withContext Result.failure(
|
||||||
|
workDataOf("error" to "HTTP ${response.code}")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.body ?: return@withContext Result.failure(
|
||||||
|
workDataOf("error" to "Empty body")
|
||||||
|
)
|
||||||
|
val total = body.contentLength()
|
||||||
|
|
||||||
|
var downloaded = 0L
|
||||||
|
body.source().use { source ->
|
||||||
|
localFile.sink().buffer().use { sink ->
|
||||||
|
val buffer = okio.Buffer()
|
||||||
|
while (true) {
|
||||||
|
val read = source.read(buffer, 8192)
|
||||||
|
if (read == -1L) break
|
||||||
|
sink.write(buffer, read)
|
||||||
|
downloaded += read
|
||||||
|
|
||||||
|
// Update notification progress
|
||||||
|
if (total > 0) {
|
||||||
|
val pct = ((downloaded * 100) / total).toInt()
|
||||||
|
setForeground(createForegroundInfo(filename, pct, downloaded, total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final notification
|
||||||
|
showCompleteNotification(filename)
|
||||||
|
|
||||||
|
return@withContext Result.success(
|
||||||
|
workDataOf("filename" to filename, "bytes" to downloaded)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return@withContext Result.failure(
|
||||||
|
workDataOf("error" to (e.message ?: "Unknown error"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createForegroundInfo(
|
||||||
|
filename: String,
|
||||||
|
progress: Int = 0,
|
||||||
|
downloaded: Long = 0,
|
||||||
|
total: Long = 0,
|
||||||
|
): ForegroundInfo {
|
||||||
|
val intent = Intent(applicationContext, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
applicationContext, 0, intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setContentTitle("Downloading $filename")
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
|
||||||
|
if (total > 0) {
|
||||||
|
builder.setContentText("$progress% — ${downloaded / 1_000_000} / ${total / 1_000_000} MB")
|
||||||
|
builder.setProgress(100, progress, false)
|
||||||
|
} else {
|
||||||
|
builder.setContentText("Downloading...")
|
||||||
|
builder.setProgress(0, 0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ForegroundInfo(
|
||||||
|
NOTIFICATION_ID_BASE + id.hashCode(),
|
||||||
|
builder.build(),
|
||||||
|
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showCompleteNotification(filename: String) {
|
||||||
|
val intent = Intent(applicationContext, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
applicationContext, 0, intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setContentTitle("Download complete")
|
||||||
|
.setContentText(filename)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
== PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
notificationManager.notify(NOTIFICATION_ID_BASE + filename.hashCode(), builder.build())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notificationManager.notify(NOTIFICATION_ID_BASE + filename.hashCode(), builder.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ class YarisApp : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
|
DownloadWorker.createChannel(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
# yaris-mediacenter — Bluetooth Audio Bridge for the Car
|
||||||
|
|
||||||
|
**Status:** `PLANNING` — do not start building without owner approval.
|
||||||
|
|
||||||
|
A new Pi-side service that acts as a transparent Bluetooth middleman between a phone
|
||||||
|
(Spotify, any audio app) and the car radio. The Pi broadcasts as a Bluetooth A2DP sink
|
||||||
|
that the phone connects to (phone thinks it's the car stereo), receives the audio stream,
|
||||||
|
then re-broadcasts it as an A2DP source that the car radio connects to. Media controls
|
||||||
|
(play, pause, skip, volume) relay bidirectionally through the Pi via AVRCP.
|
||||||
|
|
||||||
|
The car radio sees a normal Bluetooth audio device. The phone sees a normal Bluetooth
|
||||||
|
speaker. The Pi sits invisibly in the middle, bridging them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Problem (why this exists)
|
||||||
|
|
||||||
|
- The 2013 Yaris radio likely has either no Bluetooth at all, or an older Bluetooth
|
||||||
|
module that only supports hands-free calling (HFP), not music streaming (A2DP).
|
||||||
|
- Even if the radio does support Bluetooth A2DP, there's no way to have the phone
|
||||||
|
simultaneously connected to the radio for audio AND the Pi for the local web UI
|
||||||
|
over WiFi without manual reconnection each time.
|
||||||
|
- This service makes the Pi the one permanent Bluetooth connection for the radio,
|
||||||
|
and the phone connects to the Pi instead — the Pi becomes the car's infotainment hub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ Bluetooth A2DP/AVRCP ┌──────────┐ Bluetooth A2DP/AVRCP ┌───────────┐
|
||||||
|
│ Phone │ ──────────────────────────▶ │ Pi │ ──────────────────────────▶ │ Car Radio │
|
||||||
|
│ (Spotify)│ ◀────────────────────────── │(mediacntr)│ ◀────────────────────────── │ │
|
||||||
|
│ │ audio stream (SBC/AAC) │ │ re-encoded audio (SBC) │ │
|
||||||
|
│ │ media controls (AVRCP) │ │ media controls (AVRCP) │ │
|
||||||
|
└──────────┘ └──────────┘ └───────────┘
|
||||||
|
│
|
||||||
|
WiFi hotspot (existing)
|
||||||
|
"yaris 911" @ 10.42.0.1
|
||||||
|
│
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Existing yaris-obd UI │
|
||||||
|
│ (dashboard, dashcam, │
|
||||||
|
│ status — unchanged) │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bluetooth profiles involved
|
||||||
|
|
||||||
|
| Profile | Role (Phone side) | Role (Radio side) | Purpose |
|
||||||
|
|---------|-------------------|-------------------|---------|
|
||||||
|
| **A2DP** | Pi is **Sink** (receives audio from phone) | Pi is **Source** (sends audio to radio) | Stereo audio streaming |
|
||||||
|
| **AVRCP** | Pi is **Target** (receives commands from phone) | Pi is **Controller** (sends commands to radio) | Play/pause/skip/track metadata relay |
|
||||||
|
|
||||||
|
### Audio pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Phone → [Bluetooth A2DP Sink] → PulseAudio source → PulseAudio loopback → PulseAudio sink → [Bluetooth A2DP Source] → Radio
|
||||||
|
```
|
||||||
|
|
||||||
|
- BlueZ registers the Pi as a connectable A2DP sink (for the phone).
|
||||||
|
- BlueZ also connects the Pi to the car radio as an A2DP source.
|
||||||
|
- PulseAudio bridges the two: `module-loopback` routes audio from the phone's A2DP
|
||||||
|
source (in PulseAudio) to the radio's A2DP sink (in PulseAudio).
|
||||||
|
- No audio file is ever written to disk — it's a live stream pipeline, minimal latency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hardware Considerations
|
||||||
|
|
||||||
|
### Pi 3's built-in Bluetooth
|
||||||
|
|
||||||
|
The Pi 3's BCM43438 combo chip provides both WiFi and Bluetooth on the same physical
|
||||||
|
radio. This has two implications:
|
||||||
|
|
||||||
|
1. **Coexistence:** 2.4 GHz WiFi (especially the AP/hotspot on wlan0) shares spectrum
|
||||||
|
with Bluetooth. This can cause audio dropouts. Mitigations:
|
||||||
|
- Bluetooth uses adaptive frequency hopping (AFH), which avoids WiFi channels — this
|
||||||
|
is built into the BCM43438 firmware and normally works well.
|
||||||
|
- If dropouts are a problem in practice, a **USB Bluetooth 5.0 dongle** (~$10) can
|
||||||
|
offload the phone or radio connection to a separate radio entirely, eliminating
|
||||||
|
WiFi/BT contention.
|
||||||
|
- The Phase 23.5 wlan1 (TP-Link Archer T3U, 5 GHz-capable) is already installed —
|
||||||
|
moving the hotspot to 5 GHz would completely eliminate 2.4 GHz WiFi/Bluetooth
|
||||||
|
interference. This is a configuration change, not new hardware.
|
||||||
|
|
||||||
|
2. **Disable-bt overlay:** The Pi currently uses `dtoverlay=disable-bt` in
|
||||||
|
`/boot/firmware/config.txt` to free the PL011 UART for the SIM868 GPS module (see
|
||||||
|
Phase 1 bring-up notes). **This completely disables the onboard Bluetooth
|
||||||
|
controller.** To use Bluetooth for mediacenter:
|
||||||
|
- **Option A:** Use a USB Bluetooth dongle instead (preserves GPS UART routing,
|
||||||
|
cleaner separation, likely better range/audio quality with BT 5.0).
|
||||||
|
- **Option B:** Use the mini-UART (`/dev/ttyS0`) for the SIM868 at a lower,
|
||||||
|
core-frequency-dependent baud rate, re-enable the onboard Bluetooth for the
|
||||||
|
PL011 UART. Riskier — mini-UART is less stable and baud rate depends on the
|
||||||
|
core clock, which can drift.
|
||||||
|
|
||||||
|
**Recommendation: Option A (USB dongle).** The onboard Bluetooth is disabled for
|
||||||
|
GPS, and a USB dongle is cheap, isolated, and doesn't require touching the
|
||||||
|
already-working GPS UART config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### Phase M1: Bluetooth stack validation (research spike)
|
||||||
|
**Goal:** Prove the Pi can simultaneously be an A2DP sink + A2DP source with
|
||||||
|
audio passing through, before writing any production code.
|
||||||
|
|
||||||
|
- [ ] Install BlueZ + PulseAudio + `pulseaudio-module-bluetooth` on the Pi.
|
||||||
|
- [ ] Pair and connect the Pi as an A2DP sink from a real phone.
|
||||||
|
- Verify `pactl list sources` shows the phone's A2DP stream as an audio source.
|
||||||
|
- [ ] Pair and connect the Pi as an A2DP source to a real Bluetooth speaker/radio.
|
||||||
|
- Verify `pactl list sinks` shows the radio/speaker as an audio sink.
|
||||||
|
- [ ] Set up a PulseAudio loopback: `pactl load-module module-loopback source=<phone_source> sink=<radio_sink>`.
|
||||||
|
- [ ] Play audio on the phone, confirm it comes out of the radio/speaker.
|
||||||
|
- Measure rough latency (video-sync test: watch a video on the phone, observe lip sync).
|
||||||
|
- [ ] Test AVRCP relay: play/pause/skip from the radio's controls should affect the phone.
|
||||||
|
- This may require a custom DBus script (BlueZ exposes `org.bluez.MediaPlayer1` on
|
||||||
|
both the sink and source sides — commands received on the source side need to be
|
||||||
|
forwarded to the target/sink side).
|
||||||
|
- [ ] Document: what exact BlueZ/PulseAudio commands, what worked, what didn't, latency
|
||||||
|
numbers, codec info, audio quality observations.
|
||||||
|
|
||||||
|
**Estimated time:** 2-3 hours of hands-on Pi tinkering.
|
||||||
|
**Deliverable:** A markdown file in `poc/` with raw terminal logs, commands, and findings.
|
||||||
|
|
||||||
|
**Go/No-go decision after M1:** If audio passes through with acceptable latency (< ~300ms
|
||||||
|
additional) and AVRCP relay is feasible via DBus scripts, proceed to M2. If the onboard
|
||||||
|
Bluetooth is too flaky or latency is unusable, re-evaluate USB dongle or shelve.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase M2: `yaris-mediacenter` service scaffold
|
||||||
|
**Goal:** Package the M1 proof-of-concept into a proper Python service that auto-starts
|
||||||
|
alongside `yaris-obd`, with persistent configuration.
|
||||||
|
|
||||||
|
- [ ] Create `modules/app_mediacenter.py`:
|
||||||
|
- `BluetoothBridge` class managing the dual-role Bluetooth session.
|
||||||
|
- Config loaded from `config/mediacenter.json` (device names, PIN codes, auto-connect
|
||||||
|
MAC addresses, codec preferences).
|
||||||
|
- Subprocess management of `bluetoothctl`, `pactl`, `parec`/`pacat` or direct DBus
|
||||||
|
calls via `pydbus`/`dasbus` to avoid shelling out.
|
||||||
|
- Health check: are both connections alive? Reconnect logic on disconnect.
|
||||||
|
- [ ] PulseAudio loopback management:
|
||||||
|
- Create/destroy the loopback module programmatically.
|
||||||
|
- Handle dynamically appearing/disappearing sources and sinks (phone connects, radio
|
||||||
|
connects — the loopback needs to adapt).
|
||||||
|
- [ ] AVRCP relay via DBus:
|
||||||
|
- Listen for `org.bluez.MediaPlayer1.PropertiesChanged` on the source side (radio).
|
||||||
|
- Forward `Play`, `Pause`, `Next`, `Previous`, `FastForward`, `Rewind` to the target
|
||||||
|
side (phone).
|
||||||
|
- Relay track metadata (Title, Artist, Album, Duration) from the phone to the radio
|
||||||
|
side — the radio's display can show "now playing" info.
|
||||||
|
- [ ] systemd unit: `deploy/yaris-mediacenter.service`
|
||||||
|
- Requires `bluetooth.service` and `pulseaudio.service` (user-mode pulseaudio for
|
||||||
|
the `yaris` user).
|
||||||
|
- `Wants=yaris-obd.service` (co-located, not strictly dependent).
|
||||||
|
- [ ] Install script: `deploy/install_mediacenter.sh`
|
||||||
|
- Installs required apt packages (BlueZ, PulseAudio, pulseaudio-module-bluetooth,
|
||||||
|
Python DBus bindings).
|
||||||
|
- Configures PulseAudio for the `yaris` user (systemd user service).
|
||||||
|
- Enables Bluetooth discoverable/connectable mode permanently.
|
||||||
|
- [ ] UI: minimal status indicator on the existing `/status` page:
|
||||||
|
- Phone connected? (MAC, device name, codec)
|
||||||
|
- Radio connected? (MAC, device name, codec)
|
||||||
|
- Audio flowing? (PulseAudio loopback state)
|
||||||
|
- Latency estimate.
|
||||||
|
|
||||||
|
**Deliverable:** Running service on the Pi, phone → Pi → radio audio chain verified.
|
||||||
|
**Dependencies:** M1 must be complete (Bluetooth stack proven).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase M3: Pairing & auto-connect UX
|
||||||
|
**Goal:** Make pairing practical — not a manual `bluetoothctl` sequence every time
|
||||||
|
the car starts.
|
||||||
|
|
||||||
|
- [ ] **Phone pairing (persistent):**
|
||||||
|
- Pi always discoverable as "yaris-mediacenter" (or configurable name).
|
||||||
|
- First-time phone pairing is manual (phone scans, finds Pi, pairs — standard).
|
||||||
|
- Pi remembers paired phones via BlueZ's trusted-device store.
|
||||||
|
- Auto-connect: when a trusted phone comes in range, Pi automatically accepts
|
||||||
|
the A2DP connection. No user action needed after the first pairing.
|
||||||
|
|
||||||
|
- [ ] **Radio pairing (persistent):**
|
||||||
|
- If the car radio supports Bluetooth scanning, the radio finds "yaris-media"
|
||||||
|
(separate name from phone side — the two A2DP roles need distinct names).
|
||||||
|
- Pair once, Pi remembers the radio's MAC.
|
||||||
|
- Auto-connect: on service start, Pi actively connects to the radio's MAC as
|
||||||
|
an A2DP source (the radio is the "speaker" from the Pi's perspective).
|
||||||
|
|
||||||
|
- [ ] **Reconnection ordering:**
|
||||||
|
- On ignition-on (Pi boots): mediacenter starts → connects to radio first
|
||||||
|
(A2DP source) → then enables discoverability for the phone (A2DP sink).
|
||||||
|
- If radio isn't in range yet (radio boots slower than the Pi), retry with
|
||||||
|
exponential backoff.
|
||||||
|
- If phone disconnects (user walks away), audio pipeline gracefully pauses,
|
||||||
|
reconnects when phone returns.
|
||||||
|
|
||||||
|
- [ ] **Settings page** (new section on existing `/settings`):
|
||||||
|
- Radio MAC address (manual entry or scan-and-select).
|
||||||
|
- Phone-side Bluetooth device name.
|
||||||
|
- Radio-side Bluetooth device name.
|
||||||
|
- Auto-connect toggle (on/off).
|
||||||
|
- Preferred codec (auto / SBC / AAC if supported).
|
||||||
|
|
||||||
|
- [ ] **Quick-access control widget:**
|
||||||
|
- A small "Now Playing" card on the dashboard showing track metadata
|
||||||
|
(Title, Artist) when audio is flowing.
|
||||||
|
- Play/pause/skip buttons (passenger-safe, not for driver use while moving).
|
||||||
|
|
||||||
|
**Deliverable:** Phone and radio pair once, reconnect automatically every drive.
|
||||||
|
**Dependencies:** M2 service scaffold complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase M4: Audio quality & latency tuning
|
||||||
|
**Goal:** Make it sound good and feel responsive.
|
||||||
|
|
||||||
|
- [ ] **Codec selection:**
|
||||||
|
- Phone → Pi: negotiate the best available codec (AAC preferred on iPhone,
|
||||||
|
aptX if the USB dongle supports it, SBC as fallback).
|
||||||
|
- Pi → Radio: most car radios only support SBC. SBC bitpool tuning (higher
|
||||||
|
bitpool = better quality, more bandwidth).
|
||||||
|
- Document negotiated codecs per connection in the status endpoint.
|
||||||
|
|
||||||
|
- [ ] **Latency measurement & reduction:**
|
||||||
|
- Measure end-to-end latency (phone speaker → Pi → radio speaker) with a
|
||||||
|
clap test or audio sync video.
|
||||||
|
- Tune PulseAudio buffer sizes (`latency_msec` in `module-loopback`).
|
||||||
|
- If latency > 500ms, consider:
|
||||||
|
- Smaller Bluetooth packet sizes.
|
||||||
|
- Lower PulseAudio fragment sizes.
|
||||||
|
- Direct ALSA loopback (bypass PulseAudio entirely) as a fallback path.
|
||||||
|
|
||||||
|
- [ ] **Audio glitch handling:**
|
||||||
|
- Detect buffer underruns (audio dropouts), log them.
|
||||||
|
- Automatic recovery: if the loopback module dies, recreate it.
|
||||||
|
- Graceful handling of temporary Bluetooth interference.
|
||||||
|
|
||||||
|
- [ ] **Volume passthrough:**
|
||||||
|
- Phone volume → Pi's A2DP sink volume → Pi's A2DP source volume → Radio volume.
|
||||||
|
- Keep the chain linear: phone volume controls the full path, radio volume
|
||||||
|
is a secondary hardware trim.
|
||||||
|
- AVRCP absolute volume if the radio supports it.
|
||||||
|
|
||||||
|
**Deliverable:** Measured latency numbers, acceptable audio quality.
|
||||||
|
**Dependencies:** M3 connection stability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase M5: Hands-free calling (HFP) relay (stretch goal)
|
||||||
|
**Goal:** If the car radio has Bluetooth but only supports HFP (hands-free calling),
|
||||||
|
not A2DP (music), the Pi can bridge phone calls too. If the radio has NO Bluetooth
|
||||||
|
at all, this does nothing — the Pi would need a mic/speaker connected directly,
|
||||||
|
which is a different scope.
|
||||||
|
|
||||||
|
- [ ] Pi acts as HFP Audio Gateway (AG) for the radio.
|
||||||
|
- [ ] Pi acts as HFP Hands-Free (HF) for the phone.
|
||||||
|
- [ ] Call audio relay: same PulseAudio loopback pattern.
|
||||||
|
- [ ] Call control relay: answer, hang up, dial.
|
||||||
|
- [ ] Phonebook access relay (PBAP) — the radio can show incoming caller ID.
|
||||||
|
|
||||||
|
**This phase is explicitly optional** — most users will use A2DP music streaming,
|
||||||
|
and HFP relay adds significant complexity (SCO audio routing, AT command handling,
|
||||||
|
call state machine). Only build if M1-M4 are all solid and the user explicitly
|
||||||
|
requests it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase M6: Spotify Connect / media source integration (stretch goal)
|
||||||
|
**Goal:** Instead of the phone streaming Bluetooth audio, run a lightweight Spotify
|
||||||
|
client directly on the Pi so the phone is only a remote control, not the audio
|
||||||
|
source. This eliminates the phone→Pi Bluetooth hop entirely.
|
||||||
|
|
||||||
|
- [ ] Install `spotifyd` or `librespot` on the Pi (headless Spotify Connect receiver).
|
||||||
|
- [ ] Route Spotify audio directly to the A2DP source (Pi → Radio), no loopback needed.
|
||||||
|
- [ ] Phone uses the Spotify app as a remote (Spotify Connect protocol — built into
|
||||||
|
the Spotify app, no custom code needed).
|
||||||
|
- [ ] Fallback: if the user isn't playing Spotify (e.g., YouTube, podcasts, navigation
|
||||||
|
voice), fall back to the Bluetooth A2DP relay from M1-M3.
|
||||||
|
|
||||||
|
**Trade-off vs. M1-M3 approach:**
|
||||||
|
- **Pro:** One fewer Bluetooth hop = less latency, better audio quality, lower battery
|
||||||
|
drain on the phone (phone just sends control commands, not an audio stream).
|
||||||
|
- **Con:** Only works for Spotify. Any other audio app (Apple Music, YouTube, podcast
|
||||||
|
apps, navigation) still needs the Bluetooth relay.
|
||||||
|
- **Con:** `spotifyd`/`librespot` requires a Spotify Premium account and the Pi needs
|
||||||
|
internet access (phone hotspot uplink via wlan1 — already built in Phase 22).
|
||||||
|
|
||||||
|
**Recommendation:** Build M1-M3 (Bluetooth relay) first, since it works for ALL audio.
|
||||||
|
Add M6 later as an optimization for Spotify specifically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions (to resolve during M1)
|
||||||
|
|
||||||
|
| # | Question | Impact |
|
||||||
|
|---|----------|--------|
|
||||||
|
| Q1 | Does the 2013 Yaris radio have Bluetooth at all? If so, which profiles? | Defines what the Pi needs to broadcast (A2DP vs HFP only) |
|
||||||
|
| Q2 | Can the Pi's BCM43438 (or a USB dongle) maintain two simultaneous Bluetooth connections (phone + radio) without audio dropouts? | Determines if USB dongle is required |
|
||||||
|
| Q3 | What is the real end-to-end latency (phone → Pi → radio)? | Determines if this is usable for video/navigation or music-only |
|
||||||
|
| Q4 | Does `dtoverlay=disable-bt` permanently block the onboard Bluetooth, or can it be re-enabled while keeping the SIM868 on mini-UART? | Determines if USB dongle is mandatory |
|
||||||
|
| Q5 | Does the car radio support AVRCP metadata display (track title/artist on the radio screen)? | Determines if Phase M2 metadata relay is worth building |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Impact
|
||||||
|
|
||||||
|
- **New apt packages:** `pulseaudio`, `pulseaudio-module-bluetooth`, `bluez-tools`,
|
||||||
|
Python `pydbus` or `dasbus`.
|
||||||
|
- **New systemd units:** `yaris-mediacenter.service` (possibly a user unit for
|
||||||
|
PulseAudio).
|
||||||
|
- **New config files:** `config/mediacenter.json`.
|
||||||
|
- **Existing services:** no changes to `yaris-obd.service`, GPS, OBD, WiFi, or
|
||||||
|
uplink paths. Mediacenter is an independent service that happens to run on the
|
||||||
|
same Pi.
|
||||||
|
- **WiFi/Bluetooth coexistence:** if using a USB dongle on a USB 3.0 port, be aware
|
||||||
|
of USB 3.0 2.4 GHz interference (this is a known hardware issue with Pi 4; Pi 3
|
||||||
|
only has USB 2.0, so not a concern here).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| Audio latency too high for video/nav | Medium | High | Phase M1 validates early; M6 (Spotify Connect) bypasses it for music |
|
||||||
|
| Bluetooth coexistence with WiFi hotspot causes dropouts | Medium | Medium | USB dongle + 5 GHz WiFi on wlan1 (already available) |
|
||||||
|
| `disable-bt` overlay blocks all Bluetooth | High (confirmed) | Low | USB dongle is the planned path anyway |
|
||||||
|
| AVRCP relay via DBus is unreliable or incomplete | Medium | Medium | Fall back to phone-side controls only; M1 validates |
|
||||||
|
| Car radio has no Bluetooth at all | ? | High | M1 clarifies; if no BT radio, this project needs a different approach (FM transmitter? aux cable? replace the head unit?) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Go/No-Go Checklist
|
||||||
|
|
||||||
|
Before starting Phase M2 (committed code):
|
||||||
|
- [ ] M1 research spike complete with real hardware.
|
||||||
|
- [ ] Latency measured and deemed acceptable by owner.
|
||||||
|
- [ ] Audio quality confirmed acceptable.
|
||||||
|
- [ ] Both A2DP sink + source roles confirmed working simultaneously.
|
||||||
|
- [ ] Owner explicit approval to proceed past M1.
|
||||||
Reference in New Issue
Block a user