diff --git a/FEATURES.md b/FEATURES.md index 0eaadb4..92cb64f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e9b3f86..8bd8438 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 522bee7..c452737 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -46,6 +46,11 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> + + diff --git a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt index e3785ef..2d938df 100644 --- a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt +++ b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt @@ -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) { + val url = "$baseUrl/api/local-video/file?path=${video.path}" + val filename = video.path.substringAfterLast("/") + + // 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 + } + + val request = OneTimeWorkRequestBuilder() + .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() + + WorkManager.getInstance(getApplication()).enqueue(request) + viewModelScope.launch { - val app = getApplication() - val dir = app.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return@launch - val localFile = File(dir, 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 - } - - try { - val result = withContext(Dispatchers.IO) { - val request = Request.Builder() - .url("$baseUrl/api/local-video/file?path=${video.path}") - .get() - .build() - - val response = okHttp.newCall(request).execute() - if (!response.isSuccessful) { - return@withContext "Download failed: HTTP ${response.code}" - } - - 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}") - } + _downloadEvents.emit("⏳ Downloading: $filename (continues in background)") } } diff --git a/android/app/src/main/java/com/yarisobd/app/DownloadWorker.kt b/android/app/src/main/java/com/yarisobd/app/DownloadWorker.kt new file mode 100644 index 0000000..8c9cbc9 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/DownloadWorker.kt @@ -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()) + } + } +} diff --git a/android/app/src/main/java/com/yarisobd/app/YarisApp.kt b/android/app/src/main/java/com/yarisobd/app/YarisApp.kt index 16b0c9c..a4f7828 100644 --- a/android/app/src/main/java/com/yarisobd/app/YarisApp.kt +++ b/android/app/src/main/java/com/yarisobd/app/YarisApp.kt @@ -7,6 +7,7 @@ class YarisApp : Application() { override fun onCreate() { super.onCreate() instance = this + DownloadWorker.createChannel(this) } companion object { diff --git a/poc/yaris-mediacenter-plan.md b/poc/yaris-mediacenter-plan.md new file mode 100644 index 0000000..8c95897 --- /dev/null +++ b/poc/yaris-mediacenter-plan.md @@ -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= 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.