An always-on daemon that bridges the MediaDash Android companion app and the llizard CarThing UI over Bluetooth Low Energy. It uses Redis as the contract between BLE and the UI, enabling connection health monitoring, media metadata synchronization, album art transfers, podcast data management, lyrics display, and playback commands to flow through well-known Redis keys.
- Overview
- Architecture
- BLE Protocol
- Redis Schema
- Album Art Protocol
- Command Processing
- Configuration
- Build and Deployment
- Testing
- Troubleshooting
MediaDash BLE Client acts as a bridge between:
- Phone (MediaDash Android app) - Sends media state, album art, podcast data, and lyrics via BLE notifications
- CarThing UI (llizardgui-host) - Reads state from Redis and enqueues commands
All data flows through Redis, providing a clean separation between BLE complexity and the UI layer.
Target Platform: ARM Linux (Spotify CarThing - ARMv7, Cortex-A7)
BLE Stack: TinyGo Bluetooth (tinygo.org/x/bluetooth)
Database: Redis 6+ for state synchronization
- Intelligent BLE device scanning with service UUID filtering
- Rate-limited BLE writes to prevent ATT error 0x0e (resource exhaustion)
- Activity-based connection health monitoring
- Command retry queue with exponential backoff
- Chunked album art transfers with base64/binary protocol support
- Real-time playback position tracking (TimeTracker)
- Podcast data lazy loading with compact BLE format
- Lyrics synchronization support
┌──────────────────────────────────────┐
│ MediaDash BLE Client │
│ │
┌─────────────────┐ BLE │ ┌────────────┐ ┌─────────────┐ │ Redis ┌─────────────────┐
│ │ Notify │ │ │ │ │ │ Keys │ │
│ Android │──────────>│ │ BLE I/O │───>│ Redis Store │──┼──────────>│ CarThing UI │
│ MediaDash │ │ │ │ │ │ │ │ (llizardgui) │
│ │<──────────│ │ │<───│ │<─┼───────────│ │
└─────────────────┘ BLE │ └────────────┘ └─────────────┘ │ Redis └─────────────────┘
Write │ │ Queue
(Commands) │ ┌────────────────────────────────┐ │
│ │ Album Art Handler │ │
│ │ - Chunked reassembly │ │
│ │ - CRC32 validation │ │
│ │ - Disk + Redis caching │ │
│ └────────────────────────────────┘ │
└──────────────────────────────────────┘
golang_ble_client/
├── cmd/
│ └── mediadash-client/
│ └── main.go # Entry point, signal handling, status monitoring
├── internal/
│ ├── ble/
│ │ ├── client.go # Core BLE client, scanning, connections
│ │ └── errorlog.go # Crash/error logging to persistent files
│ ├── redis/
│ │ └── store.go # Redis operations, key mappings, type definitions
│ ├── albumart/
│ │ ├── handler.go # Transfer reassembly, disk caching
│ │ ├── validation.go # Security validation, format detection
│ │ ├── retry.go # Retry manager with exponential backoff
│ │ └── migration.go # Cache migration utilities
│ ├── config/
│ │ ├── loader.go # Embedded config loading
│ │ └── config.json # BLE UUIDs, Redis keys, settings
│ ├── settings/
│ │ └── handler.go # Runtime settings hot-reload
│ └── debug/
│ └── debug.go # Debug logging utilities
├── scripts/
│ └── test-commands.sh # Interactive command testing
├── config.json # Root config (embedded at build)
└── build-deploy.sh # Build and deployment automation
The core BLE client manages:
- Device Scanning: Filters for MediaDash service UUID, falls back to name-based matching
- Characteristic Subscriptions: Media state, album art, podcast info, lyrics
- Rate-Limited Writes: Configurable intervals (default 15ms) to prevent ATT errors
- Connection Health: Activity-based monitoring, automatic reconnection
- TimeTracker: Real-time position updates between Android notifications
- Flow Control: Separate notification buffers for media state (200) and album art (4096)
Provides typed helpers for all Redis operations:
StoreMediaState()- Track, artist, album, position, duration, playing stateDequeuePlaybackCommand()- Pop commands from queue with timeoutPublishBLEStatus()- Connection status for UI health indicatorsStorePodcastList(),StoreRecentEpisodes(),StorePodcastEpisodes()- Podcast dataStoreLyricsChunk(),GetLyrics()- Lyrics with timestamp supportStoreAlbumArtCache(),GetAlbumArtCache()- Album art blob caching
Manages album art transfers with:
- Chunked Reassembly: Handles both legacy JSON/base64 and binary protocols
- CRC32 Validation: Per-chunk integrity checking
- Disk Caching:
/var/mediadash/album_art_cache/with WebP format - Redis Mirroring: Syncs disk cache to Redis for quick lookups
- Retry Manager: Automatic retry with selective chunk re-requests
- Format Validation: JPEG, PNG, WebP detection via magic bytes
Service UUID: 0000a0d0-0000-1000-8000-00805f9b34fb
| Characteristic | UUID | Direction | Description |
|---|---|---|---|
| Media State | 0000a0d1-... |
Android → Client | JSON media metadata notifications |
| Playback Control | 0000a0d2-... |
Client → Android | JSON playback commands |
| Album Art Request | 0000a0d3-... |
Client → Android | Request specific album art (recovery) |
| Album Art Data | 0000a0d4-... |
Android → Client | Chunked album art (binary/base64) |
| Podcast Info | 0000a0d5-... |
Bidirectional | Podcast data with compact format |
| Lyrics Request | 0000a0d6-... |
Client → Android | Request lyrics for track |
| Lyrics Data | 0000a0d7-... |
Android → Client | Chunked lyrics with timestamps |
| Settings | 0000a0d8-... |
Bidirectional | Configuration sync |
| Time Sync | 0000a0d9-... |
Bidirectional | Clock synchronization |
{
"isPlaying": true,
"playbackState": "playing",
"trackTitle": "Song Name",
"artist": "Artist Name",
"album": "Album Name",
"duration": 240000,
"position": 120000,
"volume": 75,
"albumArtHash": "1234567890",
"mediaChannel": "Spotify"
}Note: Duration and position are in milliseconds from Android, converted to seconds for Redis storage.
All BLE writes use rate limiting to prevent ATT error 0x0e (resource exhaustion):
func (c *Client) rateLimitedWrite(char *bluetooth.DeviceCharacteristic, data []byte) (int, error) {
// Minimum 15ms between writes (configurable)
// Token bucket pattern with write mutex
}| Key | Type | Description | Example |
|---|---|---|---|
media:track |
String | Current track title | "Bohemian Rhapsody" |
media:artist |
String | Current artist | "Queen" |
media:album |
String | Current album | "A Night at the Opera" |
media:playing |
String | Playback state | "true" or "false" |
media:duration |
String | Duration in seconds | "354" |
media:progress |
String | Position in seconds | "120" |
media:album_art_path |
String | Path to cached art | "/var/mediadash/album_art_cache/1234567890.webp" |
media:controlled_channel |
String | Active media app | "Spotify" |
media:channels |
JSON | Available media apps | {"channels":["Spotify","YouTube Music"],...} |
| Key | Type | Description |
|---|---|---|
system:ble_connected |
String | "true" or "false" |
system:ble_name |
String | Connected device name |
ble:status:connected |
String | Detailed connection status |
ble:status:device_name |
String | Device name |
ble:status:device_address |
String | MAC address |
ble:status:rssi |
String | Signal strength |
ble:status:connection_quality |
String | "excellent", "good", "fair", "poor" |
ble:status:last_update |
String | Unix timestamp (ms) |
ble:status:commands_processed |
String | Total commands sent |
ble:status:commands_failed |
String | Failed command count |
ble:status:scanning |
String | "true" if scanning |
| Key | Type | Description |
|---|---|---|
system:playback_cmd_q |
List | Playback commands (JSON) |
system:podcast_request_q |
List | Podcast data requests (JSON) |
system:ble_reconnect_request |
String | Timestamp to trigger reconnect |
| Key Pattern | Type | Description |
|---|---|---|
mediadash:albumart:cache:<hash> |
Binary | Cached album art data |
mediadash:albumart:cache:<hash>:meta |
JSON | Metadata (size, timestamp) |
mediadash:albumart:request |
JSON | Pending request |
| Key | Type | Description |
|---|---|---|
podcast:list |
JSON | Channel list (compact format) |
podcast:recent_episodes |
JSON | Recent episodes across all podcasts |
podcast:episodes:<hash> |
JSON | Paginated episodes for specific podcast |
podcast:channel_count |
String | Total podcast count |
| Key | Type | Description |
|---|---|---|
lyrics:data |
JSON | Complete lyrics with timestamps |
lyrics:hash |
String | CRC32 hash of "artist|track" |
lyrics:synced |
String | "true" if has timestamps |
lyrics:enabled |
String | Feature toggle |
Both Android and Go client use identical CRC32 hashing:
func GenerateAlbumArtHash(artist, album string) string {
// Normalize: lowercase and trim whitespace
artistLower := strings.ToLower(strings.TrimSpace(artist))
albumLower := strings.ToLower(strings.TrimSpace(album))
// Composite string with pipe separator
composite := artistLower + "|" + albumLower
// CRC32-IEEE checksum as DECIMAL string (not hex!)
hash := crc32.ChecksumIEEE([]byte(composite))
return fmt.Sprintf("%d", hash) // e.g., "3462671303"
}Critical: The hash is a decimal string (8-10 digits), not hexadecimal.
16-byte header + raw image data per chunk:
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | uint32 | hash (CRC32 as uint32, little-endian) |
| 4 | 2 | uint16 | chunkIndex (0-based) |
| 6 | 2 | uint16 | totalChunks |
| 8 | 2 | uint16 | dataLength |
| 10 | 4 | uint32 | dataCRC32 (chunk data checksum) |
| 14 | 2 | uint16 | reserved |
| 16+ | N | bytes | raw image data (max 496 bytes) |
{
"hash": "3462671303",
"chunkIndex": 0,
"totalChunks": 15,
"data": "base64EncodedImageData...",
"crc32": 12345678
}- Track Change: Android sends media state with
albumArtHash - Cache Check: Client checks disk cache for existing art
- Cache Hit: Update
media:album_art_pathimmediately - Cache Miss: Wait for Android to proactively send chunks
- Chunk Reception: Validate CRC32, store in transfer buffer
- Completion: Reassemble chunks, validate format, cache to disk and Redis
- Recovery: On failure, request retransmission via Album Art Request characteristic
{
"action": "play|pause|next|previous|seek|volume|toggle|stop",
"value": 0,
"timestamp": 1234567890
}| Action | Value | Description |
|---|---|---|
play |
- | Start playback |
pause |
- | Pause playback |
toggle |
- | Toggle play/pause |
next |
- | Next track |
previous |
- | Previous track |
seek |
ms | Seek to position |
volume |
0-100 | Set volume level |
stop |
- | Stop playback |
{
"action": "request_podcast_list",
"timestamp": 1234567890
}
{
"action": "request_recent_episodes",
"timestamp": 1234567890
}
{
"action": "request_podcast_episodes",
"podcastId": "abc12345",
"offset": 0,
"limit": 50,
"timestamp": 1234567890
}
{
"action": "play_episode",
"episodeHash": "def67890",
"timestamp": 1234567890
}{
"action": "request_lyrics",
"artist": "Artist Name",
"track": "Track Name",
"timestamp": 1234567890
}Redis Queue → Validation → BLE Conversion → Rate Limiting → Android GATT
↓ (on failure)
Retry Queue → Exponential Backoff → Retry (max 3 attempts)
- Max Retries: 3 attempts
- Initial Delay: 2 seconds
- Backoff: Exponential (2s, 4s, 6s)
- Retryable Errors: ATT failures, connection issues, rate limit violations
- Non-Retryable: Validation failures, JSON errors
Configuration is embedded via //go:embed from internal/config/config.json:
{
"ble": {
"deviceName": "MediaDash",
"serviceUUID": "0000a0d0-0000-1000-8000-00805f9b34fb",
"mediaStateCharacteristicUUID": "0000a0d1-0000-1000-8000-00805f9b34fb",
"playbackControlCharacteristicUUID": "0000a0d2-0000-1000-8000-00805f9b34fb",
"albumArtRequestCharacteristicUUID": "0000a0d3-0000-1000-8000-00805f9b34fb",
"albumArtDataCharacteristicUUID": "0000a0d4-0000-1000-8000-00805f9b34fb",
"podcastInfoCharacteristicUUID": "0000a0d5-0000-1000-8000-00805f9b34fb",
"lyricsRequestCharacteristicUUID": "0000a0d6-0000-1000-8000-00805f9b34fb",
"lyricsDataCharacteristicUUID": "0000a0d7-0000-1000-8000-00805f9b34fb",
"rateLimiting": {
"writeIntervalMs": 15
},
"connectionMonitoring": {
"healthCheckIntervalMinutes": 1,
"activityTimeoutMinutes": 1
}
},
"redis": {
"address": "127.0.0.1:6379",
"keyMap": {
"trackTitle": "media:track",
"artistName": "media:artist",
"albumName": "media:album",
"isPlaying": "media:playing",
"durationMs": "media:duration",
"progressMs": "media:progress",
"albumArtPath": "media:album_art_path",
"bleConnected": "system:ble_connected",
"bleName": "system:ble_name",
"playbackCommandQueue": "system:playback_cmd_q"
}
},
"albumArt": {
"cacheDirectory": "/var/mediadash/album_art_cache",
"ble": {
"chunkSize": 512
}
}
}- Go 1.21+
- Redis 6+ (on CarThing or localhost for testing)
- CarThing device with llizardOS
go build -o bin/mediadash-client ./cmd/mediadash-client
./bin/mediadash-clientGOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -o bin/mediadash-client ./cmd/mediadash-client./build-deploy.sh build # Build ARM binary
./build-deploy.sh deploy # Deploy to CarThing
./build-deploy.sh build-deploy # Build and deploy
./build-deploy.sh status # Check deployment status
./build-deploy.sh logs # View client logs
./build-deploy.sh --native # Build for local machine- IP:
172.16.42.2(USB network) - User:
root - Password:
llizardos
# Build ARM binary
GOOS=linux GOARCH=arm GOARM=7 go build -o bin/mediadash-client ./cmd/mediadash-client
# Copy to CarThing
scp bin/mediadash-client root@172.16.42.2:/usr/bin/
# SSH and verify
ssh root@172.16.42.2
sv status mediadash-clientOn llizardOS, the client runs as a runit service:
# Service control
sv start mediadash-client
sv stop mediadash-client
sv restart mediadash-client
sv status mediadash-client
# View logs
tail -f /var/log/mediadash-client/current# Start Redis
sv start redis
# Check status
sv status redis
# Test connection
redis-cli ping./scripts/test-commands.shThis interactive script provides a menu for:
- Queuing playback commands
- Monitoring queue status
- Viewing Redis state
- Testing podcast and lyrics commands
# Queue a play command
redis-cli LPUSH system:playback_cmd_q '{"action":"play","timestamp":'$(date +%s)'}'
# Queue a seek command
redis-cli LPUSH system:playback_cmd_q '{"action":"seek","value":60000,"timestamp":'$(date +%s)'}'
# Check media state
redis-cli GET media:track
redis-cli GET media:artist
redis-cli GET media:playing
redis-cli GET media:progress
# Check BLE connection
redis-cli GET system:ble_connected
redis-cli GET ble:status:connection_quality
# Check album art
redis-cli GET media:album_art_path
redis-cli KEYS "mediadash:albumart:cache:*"
# Check podcast data
redis-cli GET podcast:list
redis-cli GET podcast:recent_episodes# Enable verbose lyrics logging
./bin/mediadash-client -debug-lyrics- Verify Android app is running with BLE enabled
- Check Bluetooth adapter:
hciconfig hciconfig hci0 up
- Verify service UUID matches between Android and client config
- Check logs for scanning errors:
journalctl -u mediadash-client -f
- Verify Redis connection:
redis-cli ping
- Check command queue:
redis-cli LRANGE system:playback_cmd_q 0 -1
- Monitor BLE status:
redis-cli GET ble:status:connected redis-cli GET ble:status:commands_processed redis-cli GET ble:status:commands_failed
- Check rate limiting in logs (15ms minimum interval)
- Verify cache directory exists:
ls -la /var/mediadash/album_art_cache/
- Check Redis cache:
redis-cli KEYS "mediadash:albumart:cache:*" - Verify hash algorithm - must be decimal CRC32, not hex
- Check for transfer errors in logs (chunk validation, CRC32 mismatch)
- Increase write interval in config (try 20ms or 25ms)
- Restart Android app to clear BLE resources
- Disable Android battery optimization for MediaDash
- Check for notification buffer drops in metrics
- Verify TimeTracker initialization in logs
- Check Android is sending position updates
- Monitor progress key:
watch -n1 "redis-cli GET media:progress" - Verify playback state:
redis-cli GET media:playing
- Check notification buffer utilization:
# Diagnostic info includes buffer stats redis-cli GET ble:status:last_update - Verify stale transfers are being cleaned up
- Check retry queue size in logs
COMMAND_PROCESSING.md- Detailed command queue protocolALBUM_ART_PROTOCOL_FIXES.md- Album art compatibility fixesPERFORMANCE_OPTIMIZATIONS.md- BLE performance tuningOPTIMIZATION_SUMMARY.md- Summary of optimization changesLLM_OVERVIEW.md- Token-optimized architecture summaryCLAUDE.md- Development guidelines for Claude Code
See parent project for license information.