docs: FEAT-05 DTC sync shipped — multi-device selector, portal DTC page, Pi snapshot upload

This commit is contained in:
Mathieu Moran
2026-08-12 02:20:48 -04:00
parent ed2b3aacfc
commit bc29a01337
7 changed files with 617 additions and 59 deletions
+45
View File
@@ -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.
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
+2 -2
View File
@@ -92,8 +92,8 @@ dependencies {
// implementation(libs.room.ktx)
// ksp(libs.room.compiler)
// Background work — will be used in 28.3
// implementation(libs.workmanager)
// Background work
implementation(libs.workmanager)
// Images
implementation(libs.coil.compose)
+5
View File
@@ -46,6 +46,11 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
</manifest>
@@ -1,11 +1,12 @@
package com.yarisobd.app
import android.app.Application
import android.content.Context
import android.os.Environment
import android.webkit.MimeTypeMap
import androidx.lifecycle.AndroidViewModel
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.dto.VideoFile
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.asStateFlow
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.converter.kotlinx.serialization.asConverterFactory
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import kotlinx.serialization.json.Json
import java.io.File
@@ -109,57 +106,35 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
}
fun downloadVideo(video: VideoFile) {
viewModelScope.launch {
val app = getApplication<YarisApp>()
val dir = app.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return@launch
val localFile = File(dir, video.path.substringAfterLast("/"))
val url = "$baseUrl/api/local-video/file?path=${video.path}"
val filename = video.path.substringAfterLast("/")
// Skip if already downloaded and size matches
if (localFile.exists() && localFile.length() == video.size) {
_downloadEvents.emit("✓ Already downloaded: ${localFile.name}")
return@launch
// Check if already downloaded
val dir = getDownloadDir()
val localFile = File(dir, filename)
if (localFile.exists() && localFile.length() == video.size && video.size > 0) {
viewModelScope.launch {
_downloadEvents.emit("✓ Already downloaded: $filename")
}
return
}
try {
val result = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("$baseUrl/api/local-video/file?path=${video.path}")
.get()
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
.setInputData(
workDataOf(
DownloadWorker.KEY_PATH to video.path,
DownloadWorker.KEY_URL to url,
DownloadWorker.KEY_FILENAME to filename,
DownloadWorker.KEY_FILE_SIZE to video.size,
)
)
.addTag("download_${video.path}")
.build()
val response = okHttp.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext "Download failed: HTTP ${response.code}"
}
WorkManager.getInstance(getApplication()).enqueue(request)
val body = response.body ?: return@withContext "Download failed: Empty body"
val total = body.contentLength()
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}")
}
viewModelScope.launch {
_downloadEvents.emit("⏳ Downloading: $filename (continues in background)")
}
}
@@ -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() {
super.onCreate()
instance = this
DownloadWorker.createChannel(this)
}
companion object {
+344
View File
@@ -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.