Android companion app for the llizard CarThing UI system. Bridges Android media playback to the Spotify CarThing device via Bluetooth Low Energy (BLE).
Janus acts as a BLE GATT server that exposes media playback state, album art, and podcast browsing to Mercury, the BLE client daemon running on the CarThing. It monitors the Android device's active media sessions (Spotify, YouTube Music, etc.) and makes that state available over BLE, while also providing a built-in podcast player with lazy-loading episode browsing.
Architecture: Android Phone (Janus/GATT Server) ← BLE → CarThing (Mercury/GATT Client) → Redis → llizard
Janus is part of a three-component system for bringing media control to the Spotify CarThing:
| Component | Platform | Role |
|---|---|---|
| Janus | Android | BLE GATT server exposing media state from phone |
| Mercury | CarThing (Go) | BLE client daemon that bridges phone ↔ Redis |
| llizard | CarThing (C/raylib) | Native GUI that displays media from Redis |
- BLE GATT Server: Advertises as "Janus" and serves media state over BLE
- Universal Media Control: Monitors any Android media app via NotificationListenerService
- Media Channel Selection: Switch which media app to control (Spotify, YouTube Music, Podcasts, etc.)
- Podcast Player: Built-in Media3/ExoPlayer podcast player with subscription management
- Podcast Browsing: Lazy-loading podcast browser with A-Z list, recent episodes, and paginated per-podcast episode lists
- Album Art Transfer: Optimized binary chunked transfer protocol (WebP, 250x250px)
- Compact BLE Format: ~55% reduction in payload size for podcast data
- Synced Lyrics: Fetches time-synced lyrics from LRCLIB API with caching
- Playback Commands: Bidirectional control (play, pause, seek, volume, skip, toggle)
- Time Sync: Syncs phone time to CarThing on connection
- Foreground Service: Maintains BLE connection in background
- Android 8.0+ (API level 26)
- BLE Hardware (Bluetooth Low Energy)
- Notification Listener Permission (for universal media monitoring)
- Storage Permission (for podcast caching)
- Android Studio Hedgehog (2023.1.1) or later
- JDK 17
- Android SDK 34
- Kotlin 1.9.22
- Clone the repository:
git clone https://github.com/pautown/janus-android.git
cd janus-android- Open in Android Studio or build via command line:
./gradlew assembleDebug- Install to device:
./gradlew installDebugOr build release APK:
./gradlew assembleRelease
# APK output: app/build/outputs/apk/release/app-release-unsigned.apk┌─────────────────────────────────────────────────────────┐
│ UI Layer (Compose) │
│ MainActivity, MainViewModel, PodcastPage, PlayerPage │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Domain Layer │
│ MediaState, PlaybackCommand, CompactBleModels │
│ PodcastInfoResponse, PodcastListResponse │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Data Layer │
│ MediaRepository, PodcastRepository │
│ MediaSessionListener, PodcastPlayerService │
│ Room Database (podcasts, episodes) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ BLE Layer │
│ GattServerService, GattServerManager │
│ AlbumArtTransmitter, NotificationThrottler │
└─────────────────────────────────────────────────────────┘
- GattServerService: Foreground service hosting the BLE GATT server. Manages the lifecycle, observes media state changes, and coordinates characteristic updates. Uses Hilt for dependency injection.
- GattServerManager: Core BLE management - opens GATT server, sets up characteristics, handles advertising, manages device connections, and sends notifications. Singleton scoped.
- AlbumArtTransmitter: Handles chunked binary album art transmission with flow control. Tracks in-flight transfers per device and uses notification callbacks for pacing.
- NotificationThrottler: Rate-limits BLE notifications with configurable minimum interval (10ms default) to prevent buffer overflow.
- BleConstants: Protocol constants including UUIDs, chunk sizes (496 bytes), header size (16 bytes), and image dimensions (250x250).
- MediaRepository/MediaRepositoryImpl: Provides current media state, album art chunks, and processes playback commands. Bridges MediaControllerManager and PodcastRepository.
- MediaControllerManager: Manages the active Android MediaController for external apps (Spotify, YouTube Music, etc.). Tracks playback state, processes metadata changes, and prepares album art chunks.
- MediaSessionListener: NotificationListenerService that monitors all Android media sessions. Implements auto-switching when a new app starts playing and channel selection.
- PlaybackSourceTracker: Tracks which media source (internal podcast vs external app) is active. Enables proper resume functionality and play/pause routing.
- PodcastRepository: Manages podcast subscriptions, episodes, and feed parsing using Room database. Supports iTunes API search and RSS feed subscriptions.
- AlbumArtCache: LRU cache for prepared album art chunks, keyed by hash.
- AlbumArtFetcher: Fetches album art from MediaMetadata or URLs, resizes to 250x250, encodes as WebP.
- LyricsManager: Manages lyrics fetching, caching (LRU, 50 entries), and BLE transmission. Converts lyrics to chunked format.
- SettingsManager: DataStore-backed settings (e.g., lyrics enabled toggle).
- PodcastPlayerService: Media3 MediaSessionService hosting ExoPlayer for podcast playback. Handles audio focus and background playback.
- PodcastPlayerManager: High-level podcast playback API. Manages playlist, playback controls, and syncs state to MediaControllerManager for BLE exposure.
- EpisodeDownloadManager: Handles podcast episode downloads for offline playback.
- MediaState: Current playback state serialized to JSON (track, artist, album, duration, position, volume, albumArtHash, mediaChannel).
- PlaybackCommand: Commands from CarThing with validation. Supports playback controls, podcast browsing, and media channel selection.
- CompactBleModels: Optimized data models with short field names for minimal BLE bandwidth. Includes hash generation functions.
- PodcastInfoResponse: Legacy full podcast response and new lazy-loading response types (PodcastListResponse, RecentEpisodesResponse, PodcastEpisodesResponse).
- LyricsState/CompactLyricsResponse: Lyrics models with timestamps for synced lyrics display.
- AlbumArtChunk: Binary chunk model with serialization to 16-byte header + data format.
- AppModule: Provides BluetoothManager, BluetoothAdapter, AudioManager, coroutine dispatchers, and application scope.
- BleModule: Provides GattServerManager and related BLE components.
- MediaModule: Provides MediaRepository, MediaControllerManager, and related media components.
- PodcastModule: Provides Room database, DAOs, PodcastRepository, and RSS parser.
- MainActivity: Entry point with permission handling for Bluetooth and notifications.
- MainScreen: Compose-based main UI with connection status, now playing, and navigation.
- MainViewModel: UI state management, service control, and podcast observation.
- PodcastPage/PodcastViewModel: Podcast subscription management and browsing.
- PodcastPlayerPage/PodcastPlayerViewModel: Podcast playback controls and progress.
0000a0d0-0000-1000-8000-00805f9b34fb (Janus Service)
| Characteristic | UUID | Properties | Description |
|---|---|---|---|
| Media State | 0000a0d1-0000-1000-8000-00805f9b34fb |
Read, Notify | Current media playback state (JSON) |
| Playback Control | 0000a0d2-0000-1000-8000-00805f9b34fb |
Write, Write No Response | Commands from CarThing (JSON) |
| Album Art Request | 0000a0d3-0000-1000-8000-00805f9b34fb |
Write, Write No Response | Request album art by hash (JSON) |
| Album Art Data | 0000a0d4-0000-1000-8000-00805f9b34fb |
Read, Notify | Album art chunks (binary) |
| Podcast Info | 0000a0d5-0000-1000-8000-00805f9b34fb |
Read, Notify | Podcast data (JSON, chunked) |
| Lyrics Request | 0000a0d6-0000-1000-8000-00805f9b34fb |
Write, Write No Response | Request lyrics for track (JSON) |
| Lyrics Data | 0000a0d7-0000-1000-8000-00805f9b34fb |
Read, Notify | Synced lyrics (JSON, chunked) |
| Settings | 0000a0d8-0000-1000-8000-00805f9b34fb |
Read, Notify | Configuration settings (JSON) |
| Time Sync | 0000a0d9-0000-1000-8000-00805f9b34fb |
Read, Notify | Unix timestamp for time sync |
JSON structure sent to CarThing on media changes:
{
"isPlaying": true,
"playbackState": "playing",
"trackTitle": "Song Title",
"artist": "Artist Name",
"album": "Album Name",
"duration": 240000,
"position": 45000,
"volume": 75,
"albumArtHash": "1234567890",
"mediaChannel": "Spotify"
}The mediaChannel field indicates which app is being controlled (e.g., "Spotify", "YouTube Music", "Podcasts").
Commands sent from CarThing:
// Basic playback controls
{"action": "play"}
{"action": "pause"}
{"action": "toggle"}
{"action": "next"}
{"action": "previous"}
{"action": "stop"}
{"action": "seek", "value": 60000}
{"action": "volume", "value": 80}
// Podcast playback (by episode hash - recommended)
{"action": "play_episode", "episodeHash": "a1b2c3d4"}
// Legacy podcast playback (by index - deprecated)
{"action": "play_podcast_episode", "podcastId": "abc123", "episodeIndex": 5}
// Podcast data requests (lazy loading)
{"action": "request_podcast_list"}
{"action": "request_recent_episodes", "limit": 30}
{"action": "request_podcast_episodes", "podcastId": "abc123", "offset": 0, "limit": 15}
// Media channel selection (switch which app to control)
{"action": "request_media_channels"}
{"action": "select_media_channel", "channel": "Spotify"}Request lyrics for current track:
{"action": "get", "artist": "Artist Name", "track": "Track Title"}
{"action": "get", "hash": "abc12345"}
{"action": "clear", "hash": "abc12345"}On client connection, Janus sends a Unix timestamp (seconds since epoch) as a UTF-8 string for CarThing time synchronization.
Binary chunk format (16-byte header + up to 496 bytes data):
Offset Size Type Field
------ ---- ---- -----
0 4 uint32 hash (CRC32 of artist+album, little-endian)
4 2 uint16 chunkIndex (0-based, little-endian)
6 2 uint16 totalChunks (little-endian)
8 2 uint16 dataLength (bytes in this chunk, little-endian)
10 4 uint32 dataCRC32 (CRC32 of chunk data, little-endian)
14 2 uint16 reserved (0)
16+ N bytes raw WebP image data (max 496 bytes)
Protocol details:
- Album art resized to 250x250px, WebP format, quality 75
- Maximum notification size: 512 bytes (16 header + 496 data)
- Chunks sent with 10ms minimum interval between notifications
- CarThing requests art by CRC32 hash to avoid redundant transfers
- Album art hash = CRC32(artist + album) as decimal string
- Request format:
{"hash": "1234567890"}
Three response types for lazy-loading podcast browsing:
Header: [0x01][chunk_index][total_chunks] + JSON payload
{
"p": [
{"h": "abc12345", "n": "Podcast Name", "c": 150}
],
"np": {"h": "abc12345", "t": "Episode Title", "i": 5}
}Header: [0x02][chunk_index][total_chunks] + JSON payload
{
"e": [
{"h": "a1b2c3d4", "p": "def67890", "c": "Podcast Name", "t": "Episode Title", "d": 3600, "u": 1704499200, "i": 0}
],
"t": 30
}Fields: h=episode hash, p=podcast hash, c=channel/podcast name, t=title, d=duration (seconds), u=pubDate (unix timestamp seconds), i=index (for backward compat)
Header: [0x03][chunk_index][total_chunks] + JSON payload
{
"h": "abc12345",
"n": "Podcast Name",
"t": 150,
"o": 0,
"m": true,
"e": [
{"h": "a1b2c3d4", "t": "Episode Title", "d": 3600, "u": 1704499200}
]
}Fields: h=podcast/episode hash, n=name, t=total count or title, o=offset, m=has more, e=episodes, d=duration (seconds), u=pubDate (unix timestamp seconds)
Header: [0x04][chunk_index][total_chunks] + binary payload
Binary format for media channel list:
2 bytes: uint16 count (big-endian)
For each channel:
1 byte: length of name
N bytes: UTF-8 name
Example channels: "Spotify", "YouTube Music", "Podcasts"
Lyrics are sent in chunked JSON format. Each chunk has a 3-byte header followed by JSON:
Header: [lyrics_chunk_index][ble_packet_index][total_ble_packets]
{
"h": "abc12345",
"s": true,
"n": 50,
"c": 0,
"m": 3,
"l": [
{"t": 15000, "l": "First line of lyrics"},
{"t": 18500, "l": "Second line of lyrics"}
]
}Fields:
h: Hash (CRC32 of artist|track)s: Synced (true if has timestamps)n: Total line countc: Chunk index (0-based)m: Max chunks (total)l: Array of lyrics linest: Timestamp in milliseconds (0 if unsynced)l: Lyrics text
Clear notification sends empty lines array with n=0.
Settings are broadcast as JSON when they change:
{"lyricsEnabled": true}Clients can read current settings or subscribe to changes via notifications
To minimize BLE bandwidth usage, podcast data uses a compact JSON format with abbreviated field names and optimized data types.
Original Format (~180 bytes per episode):
{
"podcastId": "com.example.podcast.feed.123",
"podcastTitle": "The Example Podcast Show",
"title": "Episode 42: Understanding the Universe",
"duration": 3600000,
"publishDate": "Jan 15, 2024",
"pubDate": 1705305600000,
"episodeIndex": 0
}Compact Format (~80 bytes per episode, 55% smaller):
{
"h": "a1b2c3d4",
"c": "The Example Podcast Show",
"t": "Episode 42: Understanding the Universe",
"d": 3600,
"i": 0
}| Original | Compact | Notes |
|---|---|---|
podcastId + pubDate |
h |
CRC32 hash (8 chars) |
podcastTitle |
c |
Channel name |
title |
t |
Episode title |
duration |
d |
Seconds (not ms) |
episodeIndex |
i |
Index for playback |
podcastHash |
h |
Podcast ID hash |
name |
n |
Podcast name |
count |
c |
Episode count |
total |
t |
Total count |
offset |
o |
Pagination offset |
more |
m |
Has more pages |
episodes |
e |
Episode array |
podcasts |
p |
Podcast array |
nowPlaying |
np |
Currently playing |
Episode hash encodes feedUrl|pubDate|duration as CRC32 (uses seconds, not milliseconds):
fun generateEpisodeHash(feedUrl: String, pubDate: Long, duration: Long): String {
val pubDateSec = pubDate / 1000
val durationSec = duration / 1000
val input = "$feedUrl|$pubDateSec|$durationSec"
val crc = CRC32()
crc.update(input.toByteArray())
return String.format("%08x", crc.value) // "a1b2c3d4"
}Podcast hash uses the podcast ID directly if short, otherwise CRC32:
fun generatePodcastHash(podcastId: String): String {
if (podcastId.length <= 8) return podcastId
val crc = CRC32()
crc.update(podcastId.toByteArray())
return String.format("%08x", crc.value)
}Album art hash encodes artist|album as CRC32:
fun generateAlbumArtHash(artist: String, album: String): String {
val input = "$artist|$album"
val crc = CRC32()
crc.update(input.toByteArray())
return crc.value.toString() // Decimal string: "1234567890"
}Lyrics hash encodes artist|track (lowercase, trimmed) as CRC32.
Janus monitors all active Android media sessions and allows the CarThing to switch which app it controls.
- MediaSessionListener monitors Android's MediaSessionManager for active sessions
- When a new app starts playing, Janus auto-switches to control it
- CarThing can request the list of available channels via
request_media_channels - CarThing can select a specific channel via
select_media_channel
| Channel | Source | Description |
|---|---|---|
| Spotify | External | Spotify app media session |
| YouTube Music | External | YouTube Music app media session |
| Podcasts | Internal | Janus built-in podcast player |
| (other apps) | External | Any app with active MediaSession |
When an external app starts playing:
- MediaSessionListener detects the new playing session
- If different from current controlled app, auto-switches to it
- Media state updates to reflect new source
mediaChannelfield in MediaState updates
// Request available channels
{"action": "request_media_channels"}
// Response (Type 4 binary on Podcast Info characteristic)
// Channels: ["Spotify", "YouTube Music", "Podcasts"]
// Select specific channel
{"action": "select_media_channel", "channel": "Spotify"}When selecting a channel:
- Currently playing app is paused (if different from selected)
- Selected app becomes the active controller
- Playback commands route to selected app
-
A-Z Podcast List (Type 1 Response)
- Shows all subscribed podcasts sorted alphabetically
- Displays: podcast name, episode count
- No episodes loaded initially → minimal bandwidth
-
Recent Episodes (Type 2 Response)
- Cross-podcast chronological feed
- Displays: podcast name, episode title, duration
- Limited to N most recent episodes (default 30)
-
Per-Podcast Episodes (Type 3 Response)
- Episodes for specific podcast, paginated
- Displays: episode title, duration
- Loads 15 episodes per page, on-demand
CarThing (Mercury) Janus (Android)
│ │
├─ request_podcast_list ─────────>│
│<─ Type 1: Podcast List ─────────┤
│ (names only, no episodes) │
│ │
├─ request_podcast_episodes ─────>│
│ podcastId="abc123" │
│ offset=0, limit=15 │
│<─ Type 3: Episodes 0-14 ────────┤
│ │
├─ request_podcast_episodes ─────>│
│ podcastId="abc123" │
│ offset=15, limit=15 │
│<─ Type 3: Episodes 15-29 ───────┤
Traditional approach (send all episodes upfront):
- 10 podcasts × 100 episodes × 180 bytes = 180 KB
Lazy loading approach (send list + on-demand episodes):
- 10 podcasts × 80 bytes = 800 bytes
- 1 podcast × 15 episodes × 80 bytes = 1.2 KB
- Total: ~2 KB (99% reduction for initial load)
- Grant Bluetooth permissions
- Grant Notification Listener permission (Settings → Apps → Janus → Notification access)
- Grant Post Notifications permission (Android 13+)
- Tap Start BLE Service in the app
- Ensure Mercury daemon is running on CarThing
- CarThing will auto-discover and connect to "Janus" BLE advertisement
- Connection status shows in app UI
- Navigate to Podcasts tab
- Search for podcasts or add RSS feed URL
- Tap Subscribe to add to library
- Subscribed podcasts appear in My Podcasts section
- Podcasts are automatically exposed to CarThing via BLE
- Play media on any Android app (Spotify, YouTube Music, etc.)
- Media state automatically syncs to CarThing
- Control playback from CarThing UI
- Album art transfers on-demand when requested
- Kotlin 1.9.22
- AndroidX Core KTX 1.12.0
- AndroidX Lifecycle 2.7.0
- Jetpack Compose (BOM 2024.12.01)
- Hilt 2.50
- Retrofit 2.9.0
- OkHttp 4.12.0
- Kotlinx Serialization 1.6.2
- Media3 (ExoPlayer) 1.2.1
- AndroidX Media 1.7.0
- Room 2.6.1
- RSS Parser 6.0.7
- Coil (image loading) 2.5.0
<!-- BLE -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Media monitoring -->
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" />
<!-- Networking (podcast fetching, lyrics) -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />Janus is designed to work with Mercury, the BLE client daemon on the CarThing. Mercury:
- Scans for "Janus" BLE advertisement
- Connects as GATT client
- Subscribes to Media State, Album Art, and Lyrics characteristics
- Sends playback commands via Playback Control characteristic
- Requests podcast data via Podcast Info characteristic
- Stores all state in Redis for consumption by llizard UI plugins
Use nRF Connect app on another Android device to inspect GATT characteristics:
- Install nRF Connect for Mobile
- Scan for "Janus" device
- Connect and explore service
0000a0d0-... - Read/subscribe to characteristics
- Write commands to Playback Control characteristic
The app uses structured logging with semantic tags for easy filtering:
# BLE operations
adb logcat -s GattServerManager:V GattServerService:V AlbumArtTransmitter:V
# Album art transfers (detailed)
adb logcat -s ALBUMART:V
# Podcast operations
adb logcat -s PODCAST:I PodcastAudio:D
# Lyrics fetching and transmission
adb logcat -s LYRICS:D LyricsManager:D
# Media channels
adb logcat -s MEDIA_CHANNELS:I
# Media controller and session
adb logcat -s MediaControllerManager:D MediaSessionListener:D
# Playback source tracking
adb logcat -s PlaybackSourceTracker:D
# Combined useful filter
adb logcat -s GattServerManager:D GattServerService:D ALBUMART:I PODCAST:I LYRICS:I MEDIA_CHANNELS:I MediaControllerManager:DLog prefixes used in verbose output:
═══Start/end of major operations───Section separators📥Incoming requests📤Outgoing responses✅Success⚠️Warnings❌Errors📦Cache operations📡Network/BLE transmission
app/src/main/kotlin/com/mediadash/android/
├── MediaDashApplication.kt # Hilt application entry point
├── ble/
│ ├── BleConstants.kt # UUIDs, sizes, protocol constants
│ ├── GattServerService.kt # Foreground service (Hilt-injected)
│ ├── GattServerManager.kt # GATT server lifecycle & operations
│ ├── AlbumArtTransmitter.kt # Binary chunk transmission
│ └── NotificationThrottler.kt # Rate limiting
├── data/
│ ├── local/
│ │ ├── PodcastDatabase.kt # Room database
│ │ ├── PodcastDao.kt # Podcast DAO
│ │ ├── EpisodeDao.kt # Episode DAO (in PodcastDao.kt)
│ │ ├── PodcastEntity.kt # Room entities
│ │ └── SettingsManager.kt # DataStore preferences
│ ├── media/
│ │ ├── MediaControllerManager.kt # External app control
│ │ ├── MediaSessionListener.kt # Session monitoring
│ │ ├── PlaybackSourceTracker.kt # Active source tracking
│ │ ├── AlbumArtCache.kt # LRU cache
│ │ ├── AlbumArtFetcher.kt # Image fetching & processing
│ │ └── LyricsManager.kt # Lyrics fetch & cache
│ ├── remote/
│ │ ├── ITunesApiService.kt # iTunes podcast search
│ │ ├── RssFeedParser.kt # RSS feed parsing
│ │ ├── LyricsApiService.kt # LRCLIB API client
│ │ └── OPMLParser.kt # OPML import support
│ └── repository/
│ ├── MediaRepository.kt # Interface
│ ├── MediaRepositoryImpl.kt # Implementation
│ └── PodcastRepository.kt # Podcast data access
├── di/
│ ├── AppModule.kt # Core dependencies
│ ├── BleModule.kt # BLE dependencies
│ ├── MediaModule.kt # Media dependencies
│ └── PodcastModule.kt # Podcast dependencies
├── domain/
│ ├── model/
│ │ ├── MediaState.kt # Playback state model
│ │ ├── PlaybackCommand.kt # Command model
│ │ ├── AlbumArtChunk.kt # Binary chunk model
│ │ ├── Podcast.kt # Podcast & episode models
│ │ ├── PodcastInfoResponse.kt # BLE response types
│ │ ├── CompactBleModels.kt # Optimized BLE models
│ │ ├── LyricsState.kt # Lyrics models
│ │ └── ConnectionStatus.kt # BLE connection states
│ └── usecase/
│ └── ProcessPlaybackCommandUseCase.kt
├── media/
│ ├── PodcastPlayerService.kt # Media3 service
│ ├── PodcastPlayerManager.kt # Playback management
│ └── EpisodeDownloadManager.kt # Offline downloads
└── ui/
├── MainActivity.kt # Entry point
├── MainViewModel.kt # Main screen state
├── theme/Theme.kt # Material 3 theme
├── composables/ # Reusable Compose components
│ ├── MainScreen.kt
│ ├── NowPlayingCard.kt
│ ├── ConnectionStatusCard.kt
│ └── ...
├── podcast/
│ ├── PodcastPage.kt
│ └── PodcastViewModel.kt
└── player/
├── PodcastPlayerPage.kt
└── PodcastPlayerViewModel.kt
-
Add action constant to
PlaybackCommand.kt:const val ACTION_MY_COMMAND = "my_command"
-
Add to
VALID_ACTIONSset in the same file -
Handle in
GattServerService.observeCommands():PlaybackCommand.ACTION_MY_COMMAND -> { Log.i("MY_TAG", "Processing my command") handleMyCommand(command) }
-
For data requests, implement handler method and use
gattServerManager.notify*()to respond -
For playback commands, delegate to
ProcessPlaybackCommandUseCasewhich routes toMediaRepository
-
Add UUID constant to
BleConstants.kt:val MY_CHARACTERISTIC_UUID: UUID = UUID.fromString("0000a0da-0000-1000-8000-00805f9b34fb")
-
Add characteristic property in
GattServerManager.kt:private var myCharacteristic: BluetoothGattCharacteristic? = null
-
Create characteristic in
setupService():myCharacteristic = BluetoothGattCharacteristic( BleConstants.MY_CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ ).apply { addDescriptor(createCCCD()) } service.addCharacteristic(myCharacteristic)
-
Add notify method for sending data:
suspend fun notifyMyData(data: MyData) { val characteristic = myCharacteristic ?: return val server = gattServer ?: return // ... serialize and send }
IMPORTANT: BLE UUIDs and data formats must match Mercury exactly. Any changes require coordinated updates on both sides.
Protocol changes checklist:
- Update
BleConstants.kt(Janus) - Update
ble/constants.go(Mercury) - Update binary format handling in both projects
- Update this README documentation
- Test with nRF Connect before integration testing
- llizard: Native CarThing GUI (raylib/raygui)
- Mercury: CarThing BLE client daemon (bridges Janus ↔ Redis)
See repository for license details.
Note: Janus requires a Spotify CarThing device running llizard with Mercury. It will not function as a standalone media player without BLE connectivity to the CarThing.
