Skip to content

Latest commit

 

History

History
1933 lines (1678 loc) · 76.4 KB

API_CONTRACT.md

File metadata and controls

1933 lines (1678 loc) · 76.4 KB

Home Assistant API Contract

This document captures the app-to-Home Assistant DJConnect contract used by DJConnectCore.

Identity

Every app installation needs a stable device_id. Home Assistant should treat the Apple client as an app client identified by client_type, not as an ESP emulator.

The suffix should stay stable across app launches. Explicit user pairing reset clears the bearer token and creates a fresh local install identity.

{
  "device_id": "djconnect-ios-8F3A2C91B45D",
  "device_name": "DJConnect iPhone",
  "client_type": "ios",
  "firmware": "3.2.22",
  "app_version": "3.2.22",
  "protocol_version": "3.2.22",
  "platform": "ios"
}

app_version is the Apple bundle release version. protocol_version is the explicit Home Assistant compatibility contract; firmware remains populated with that same protocol version for backward-compatible server validation.

Use client_type for DJConnect client identity:

  • ios
  • macos
  • watchos
  • esp32

Do not use device_type for client identity.

Auth Headers

JSON requests:

Authorization: Bearer <djconnect_bearer_token>
X-DJConnect-Device-ID: <device_id>
Content-Type: application/json

Voice upload:

Authorization: Bearer <djconnect_bearer_token>
X-DJConnect-Device-ID: <device_id>
Content-Type: audio/wav

Ask DJ clients may include optional context hints on status payloads and raw voice uploads:

X-DJConnect-Mood: 0-100
X-DJConnect-DJ-Style: warm_radio_dj
X-DJConnect-Music-DNA-Key: <backend-normalized Music DNA key hint>

The same values may appear in JSON status payloads as mood, dj_style, and music_dna_key. Home Assistant owns Music DNA and may normalize or ignore the client-provided Music DNA key. Clients must not store long-term Music DNA locally.

Pairing

POST /api/djconnect/v1/pair
Content-Type: application/json
X-DJConnect-Device-ID: <device_id>

Payload:

{
  "device_id": "djconnect-macos-8F3A2C91B45D",
  "device_name": "DJConnect Mac",
  "client_type": "macos",
  "firmware": "3.2.22",
  "app_version": "3.2.22",
  "platform": "macos",
  "pair_code": "123456",
  "pairing_code": "123456",
  "pairing_token": "123456"
}

watchOS is a companion-only client. The Watch uses a stable Watch identity such as djconnect-watchos-8F3A2C91B45D with client_type: "watchos" and platform: "watchos", but pairing, token ownership, transport selection, status, commands, Ask DJ history, voice upload, and push registration are mediated by the paired iPhone companion over WatchConnectivity.

Home Assistant generates the 6-digit pair code in the DJConnect setup flow. The app sends that user-entered code as pair_code, pairing_code, and pairing_token for compatibility with current Home Assistant integration builds. iOS and macOS do not wait for a Home Assistant callback and do not host any inbound callback API; they make a client-initiated POST /api/djconnect/v1/pair.

iOS pairing should primarily use the Home Assistant-generated QR/deep-link payload:

djconnect://pair?ha_url=<local-ha-url>&pair_code=<code>&client_type=ios&pair_path=/api/djconnect/v1/pair

The iOS app rejects QR/deep-link payloads unless ha_url has valid URL syntax, pair_code is exactly six digits, client_type is ios, and pair_path is /api/djconnect/v1/pair. Normal pairing should use a local http Home Assistant URL such as http://homeassistant.local:8123 or a LAN IP address. For local development only, https://*.ngrok-free.dev is whitelisted so a developer can pair against a tunneled Home Assistant dev instance. Other remote HTTPS URLs, including Nabu Casa URLs, are not valid for first pairing.

Apple Watch pairing uses the same Home Assistant-generated QR/deep-link shape with client_type=watchos:

djconnect://pair?ha_url=<local-ha-url>&pair_code=<code>&client_type=watchos&pair_path=/api/djconnect/v1/pair

The iPhone app scans or opens that payload, validates client_type=watchos and pair_path=/api/djconnect/v1/pair, then forwards the pairing details to the paired Watch over WatchConnectivity. The Watch performs POST /api/djconnect/v1/pair with its own stable djconnect-watchos-* identity. If the Watch cannot reach Home Assistant directly, the iPhone may proxy the HTTP request, but it must preserve Watch identity and must not rewrite the request as client_type: "ios". The Watch UI does not show Home Assistant URL or code entry fields.

Expected response:

{
  "success": true,
  "setup_pending": true,
  "device_token": "<djconnect bearer token>",
  "device_id": "djconnect-macos-8F3A2C91B45D",
  "client_type": "macos",
  "ha_local_url": "http://192.168.1.13:8123",
  "ha_remote_url": "https://example.ui.nabu.casa",
  "remote_supported": true,
  "device_language": "nl",
  "language": "nl"
}

The app also accepts bearer_token or token for compatibility, but device_token is preferred while the Home Assistant route keeps that field name. After successful pairing, the app stores only the returned DJConnect bearer token in app-private storage and persists ha_local_url, optional ha_remote_url, device_id, and client_type. App-to-HA runtime traffic uses ha_local_url first after local pairing, then ha_remote_url when local access is unavailable and remote is supported. Do not use the older ha_url field.

Pairing is two-phase. A successful POST /api/djconnect/v1/pair means Home Assistant accepted the client identity and code. The app stores the returned token and then polls authenticated status until Home Assistant has completed the setup flow. While Home Assistant still returns not_configured, route missing, or equivalent setup-pending responses, the app shows a waiting state instead of declaring runtime pairing complete. Only a successful authenticated status response transitions the app to paired runtime UI.

Local WebSocket Fast Path

Apple clients may use Home Assistant's native /api/websocket only as an opt-in local latency optimization for DJConnect actions. HTTP remains the canonical transport, and remote/Nabu Casa sessions stay HTTP-only unless a client has explicitly proven HA WebSocket auth for that URL class.

Production Apple clients do not store or embed a long-lived Home Assistant token. When the user has enabled the local WebSocket fast path, the app should re-evaluate it when the app starts, becomes active, resumes, or pairing changes: if the client is paired and the selected Home Assistant URL is the local URL, the app calls POST /api/djconnect/v1/websocket/session using the existing DJConnect bearer token. Home Assistant returns a short-lived HA WebSocket access token such as:

{
  "success": true,
  "access_token": "<short-lived-ha-websocket-token>",
  "expires_at": "2026-07-09T10:30:00Z",
  "commands": ["djconnect/command", "djconnect/track_insight"]
}

The Apple client keeps this token in memory only, refreshes it before expiry, and never logs it. The request body must not contain a HA access token. The paired DJConnect device_token authenticates only this DJConnect HTTP endpoint; it must not be used as HA WebSocket auth.

Clients then authenticate /api/websocket with the short-lived HA WebSocket token, request djconnect/capabilities, and only send routes advertised by the server. The paired DJConnect device_token is required only inside DJConnect WebSocket payloads after HA auth succeeds. Current fast-path route names are djconnect/command, djconnect/ask_dj/message, djconnect/ask_dj/history, djconnect/ask_dj/history/clear, djconnect/ask_dj/history/state, djconnect/music_dna/profile, djconnect/music_dna/settings, djconnect/music_dna/clear, djconnect/music_discovery/feed, djconnect/music_discovery/refresh, djconnect/music_discovery/play, djconnect/music_discovery/feedback, djconnect/track_insight, and djconnect/vibecast. If capability detection is missing, stale, refused, or does not list the needed route, the client must make the normal HTTP request instead.

Music DNA export and import are intentionally absent from the WebSocket route list. Even if profile/settings/clear routes are advertised, clients must use authenticated HTTP for /api/djconnect/v1/music_dna/export and /api/djconnect/v1/music_dna/import so backup files remain exact server-built envelopes rather than reconstructed client state.

Any WebSocket auth failure, timeout, disconnect, protocol error, decoding error, or route failure falls back to exactly one HTTP request for the same user action. Clients must not clear pairing or surface scary transport errors when HTTP succeeds. Diagnostics may include the selected transport, connection state, last capability refresh time, advertised route names, and a redacted last error; they must not include tokens, private Home Assistant URLs, entity IDs, player IDs, prompts, raw Track Insight JSON, or internal backend URLs.

Inbound API

iOS and macOS do not host a Home Assistant-callable inbound API and do not advertise a pairable discovery service. Pairing and runtime traffic are always client-to-Home Assistant.

watchOS remains iPhone-mediated. The Watch itself never exposes a LAN endpoint or direct HA remote/local contract; when Watch identity metadata is needed it keeps client_type: "watchos" and the paired iPhone handles transport. The active Watch runtime is not a Home Assistant callback endpoint; it is a WatchConnectivity request proxy from Watch to iPhone to Home Assistant.

APNs Push Registration

Apple clients register APNs device tokens with Home Assistant through the authenticated Home Assistant endpoint:

POST /api/djconnect/v1/push/register
Authorization: Bearer <device_token>
Content-Type: application/json

macOS clients must send client_type: "macos" and a matching device_id in the form djconnect-macos-XXXXXXXXXXXX, where the suffix is the first 12 alphanumeric characters of the stable app install/client ID. iOS and watchOS use the same contract with their own client_type and device ID prefixes.

Expected macOS payload:

{
  "client_type": "macos",
  "device_id": "djconnect-macos-8F3A2C91B45D",
  "push_token": "<apns-device-token>",
  "push_environment": "sandbox",
  "app_bundle_id": "dev.djconnect.mac",
  "app_version": "3.2.22",
  "locale": "nl-NL",
  "categories": ["ask_dj"]
}

The app registers after APNs returns a device token and Home Assistant auth is available. It retries registration when the APNs token, environment, bundle ID, app version, locale, Home Assistant pairing, or local registration state changes. Registration failures must be logged without bearer tokens, APNs tokens, or bootstrap_proof values.

Home Assistant should expose Apple push bootstrap context in successful pairing and status/command envelopes when it is available:

{
  "ha_install_id": "<stable-ha-install-id>",
  "integration_version": "<djconnect-hacs-version>",
  "pairing_session_id": "<optional-pairing-session-or-challenge-id>"
}

ha_install_id is stable per DJConnect Home Assistant installation and is not a secret. HA should persist it across restarts and integration reloads. Apple clients cache these fields from /pair, /status, and command envelopes and use them only as bootstrap context for the trusted issuer request.

Clients first call push/register with the APNs token and no bootstrap proof. Home Assistant and clients must not invent djcboot_ proofs: the central DJConnect API only accepts trusted proofs that central issued or registered beforehand. If Home Assistant returns missing_bootstrap_proof, the Apple client asks its configured trusted pairing issuer for a short-lived central-issued bootstrap_proof, then retries push/register exactly once with the same APNs and device metadata plus that proof. The Apple client must not call central /v1/pairing/bootstrap-proof with an embedded issuer secret and must not contain DJCONNECT_PAIRING_ISSUER_SECRET, DJCONNECT_RELAY_SECRET, or an APNs provider key.

POST <trusted-issuer-base-url>/v1/pairing/bootstrap-proof
Content-Type: application/json

Expected macOS trusted issuer payload:

{
  "ha_install_id": "<stable-ha-install-id>",
  "integration": "djconnect_hacs",
  "integration_version": "<djconnect-hacs-version>",
  "client_type": "macos",
  "device_id": "djconnect-macos-8F3A2C91B45D",
  "pairing_session_id": "<optional-pairing-session-or-challenge-id>",
  "app_bundle_id": "dev.djconnect.mac",
  "push_environment": "sandbox"
}

Expected trusted issuer response:

{
  "success": true,
  "bootstrap_proof": "djcboot_<short-lived-proof>",
  "expires_at": "2026-07-08T12:10:00Z"
}

The trusted issuer must use any central issuer secret server-side only. It must not receive the APNs token. The Apple client validates success == true, a non-empty djcboot_ proof, and a short future expiry, then keeps the proof only in memory for the one retry. HA owns any resulting djci_... install token.

Home Assistant may respond with push_supported, push_registered, push_environment, last_push_error, and the bootstrap context fields above. Expected recoverable failures include missing_bootstrap_proof, invalid_bootstrap_proof, bootstrap_proof_unavailable, missing_install_token, and push_relay_unavailable; normal Ask DJ traffic must continue even when push is disabled or best-effort.

missing_bootstrap_proof means Home Assistant needs a central-issued proof to mint or recover its per-install relay token. invalid_bootstrap_proof, bootstrap_proof_expired, bootstrap_proof_used, install_id_mismatch, and bootstrap_rate_limited are terminal for that registration attempt. Central trusted issuer errors invalid_client_type, invalid_app_bundle_id, and invalid_push_environment are also terminal for the attempt. Clients log a privacy-safe status, do not loop, and wait for a later APNs/token/pairing trigger. bootstrap_proof_unavailable means the trusted issuer or Home Assistant path cannot currently obtain a trusted proof; clients mark APNs push as disabled/best-effort, avoid tight retries, and continue normal Ask DJ, history, and status flows. not_configured, 401, and 403 are stale-pairing signals: clients clear the local pairing state and local Ask DJ cache for that Home Assistant install.

Apple clients must surface the current APNs push registration state in their About/Connection UI on iOS, macOS, and watchOS. The user-facing state is derived from the stored push_supported, push_registered, push_environment, and last_push_error values and must remain privacy-safe:

  • Registered (Sandbox|Production) when push_registered is true.
  • Not active when no registration result is known yet.
  • Not supported when Home Assistant reports push_supported: false.
  • Action needed when a sanitized push error is stored.

This UI must never show APNs tokens, bootstrap proofs, install tokens, bearer tokens, request bodies, or other secret material.

Music Discovery Reminder Push

Home Assistant may send a daily APNs reminder when new Music Discovery recommendations are ready. The push is a wake/open hint only; Apple clients must render Music Discovery content only from the backend Music Discovery response, not from APNs custom payload fields.

Music Discovery is a recommendation feed, not a listening-history view. Apple clients render the backend-provided sections[] in order and do not hardcode section IDs. Current backend sections include new_for_you and accepted_recommendations; clients must not require older IDs such as because_you_like or recent_vibe. Clients must not render raw recently played tracks, top tracks, Music DNA data, or previous UI cache as Discovery cards unless those cards are explicitly present in sections[].items[].

{
  "event_type": "music_discovery_ready",
  "open_target": "music_discovery",
  "refresh_target": "music_discovery",
  "deeplink": "djconnect://music-discovery",
  "title": "DJConnect",
  "body": "Je nieuwe aanbevelingen staan klaar!"
}

Expected client behavior:

  • Display the notification body Je nieuwe aanbevelingen staan klaar!.
  • On tap or deeplink, navigate directly to Ontdek / Music Discovery.
  • On receive and on tap, refresh Music Discovery. Prefer websocket command djconnect/music_discovery/refresh when advertised; otherwise use POST /api/djconnect/v1/music_discovery/refresh.
  • If refresh is rate-limited or temporarily unavailable, load the current feed through GET /api/djconnect/v1/music_discovery.
  • Include the paired DJConnect bearer token and canonical device_id, client_type, optional client_id, optional device_token, and optional music_dna_key with refresh/feed requests.
  • Render recommendations exclusively from backend response sections[].items[] using the backend item id, kind, title, subtitle, optional image_url, reason, reason_sources, confidence, and uri. Preserve backend section and item order. Do not render push-provided recommendation titles, artwork, sections, or reasons.
  • Treat revision and cache.hit as backend metadata; they must not be used to reconstruct local Discovery items.
  • If the backend returns enabled: false and reason: "music_dna_disabled", show the existing disabled/empty Ontdek UI without local fallback recommendations.
  • Coalesce quick receive/tap races for the same Home Assistant install and Music DNA key for a short window, currently 8 seconds.

Unpairing or logout calls:

POST /api/djconnect/v1/push/unregister
Authorization: Bearer <device_token>

The Apple app does not implement ESP-only device management routes. iOS, macOS, and watchOS do not host Home Assistant-callable pairing, command, DJ response, or forget callbacks and do not advertise themselves as pairable devices.

Demo Mode is not part of the Home Assistant API contract. It is local sample state for App Store review and UI inspection, and must not create HA devices, entities, tokens, or backend traffic. Demo Mode may show and play a local sample DJ announcement, but that audio/text is not a backend response and must not be treated as successful HA voice validation.

Status

POST /api/djconnect/v1/status

Minimum payload:

{
  "device_id": "djconnect-ios-8F3A2C91B45D",
  "device_name": "DJConnect iPhone",
  "client_type": "ios",
  "ha_pairing_status": "paired",
  "firmware": "3.2.22",
  "app_version": "3.2.22",
  "state": "online",
  "status": "online",
  "battery_percent": 85,
  "language": "nl",
  "theme": "dark",
  "log_level": "info",
  "ha_local_url": "http://192.168.1.13:8123"
}

Pairing, status, command, and Ask DJ responses may include the 3.2 backend and transport summary:

{
  "ha_local_url": "http://192.168.1.13:8123",
  "ha_remote_url": "https://example.ui.nabu.casa",
  "remote_supported": true,
  "music_backend": "music_assistant",
  "music_backend_name": "Music Assistant",
  "music_backend_available": true,
  "music_backend_revision": 4,
  "music_backend_capabilities": {
    "supports_search": true,
    "supports_queue": true,
    "supports_outputs": true,
    "supports_favorites": false,
    "supports_recently_played": true,
    "supports_top_items": false
  },
  "music_target_player": {
    "id": "media_player.mass_woonkamer",
    "name": "Woonkamer"
  },
  "music_backend_error": null
}

music_backend_error may also be a safe object from Home Assistant, for example {"code":"supports_recently_played","message":"The selected music backend does not provide recent listening history."}. Apple clients display the user-facing message when present and keep the compact code as fallback diagnostic context.

iOS/macOS use ha_local_url first after successful local pairing, then ha_remote_url when local access is unreachable and remote is supported. watchOS requests remain mediated by iPhone.

VibeCast

GET /api/djconnect/v1/vibecast

Home Assistant may return artist shout-out artwork through proxied DJConnect image URL fields. Clients must render only those proxied DJConnect URLs and must not follow direct external catalog, Wikipedia, Spotify, or artist-search URLs.

{
  "revision": 42,
  "ttl_seconds": 60,
  "poll_after_seconds": 20,
  "context": {
    "artist_image_url": "/api/djconnect/v1/proxy/images/artist.jpg"
  },
  "items": []
}

The Apple clients currently use context.artist_image_url for the artist shout-out render state. If the field is missing or points outside the DJConnect proxy path allowlist, existing text-only VibeCast rendering remains unchanged. Polling and cache freshness stay based on revision, ttl_seconds, and poll_after_seconds.

Commands

POST /api/djconnect/v1/command

Command payloads are focused on playback commands and client identity. Do not send partial status snapshots in /api/djconnect/v1/command; use /api/djconnect/v1/status as the authoritative source for client status and settings mirrored into Home Assistant entities.

Examples:

{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"status"}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"devices"}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"queue","limit":100}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"playlists","limit":100}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"pause"}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"play"}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"next"}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"previous"}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"seek_relative","value":15000}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"seek_relative","value":-15000}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"set_volume","value":35}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"set_shuffle","value":true}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"set_repeat","value":"context"}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"start_liked_proxy","play":true}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"start_playlist","value":"spotify:playlist:...","play":true}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"play_context_at","value":{"context_uri":"spotify:playlist:...","offset_uri":"spotify:track:..."},"play":true}
{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"set_output","value":"Living Room","play":true}

The Apple app treats playback-changing commands such as play, pause, next, previous, seek_relative, set_output, playlist starts, and queue context starts as state-changing. After posting them, it immediately refreshes the rich Now Playing snapshot through the status command so button state, album art, progress, output, and volume reflect the backend source of truth.

The Apple app output selector may prepend a local Geen/None no-output choice. It must not synthesize local iPhone standaard or Mac standaard outputs, because those are not backend playback devices. When Geen is selected, the app blocks playback-start commands until the user chooses a real backend output.

Home Assistant owns Spotify source and liked/default playlist configuration. Clients must not show, document, or send user-configurable spotify_source or liked_proxy_playlist_uri options in new setup, settings, or command flows. Playback remains backend-mediated through generic DJConnect commands; older Home Assistant integrations may defensively tolerate previous values, but current clients should not rely on them.

Command responses are transport/command success first and playback-state second. A response with success:true and playback.has_playback:false means the Home Assistant command route worked but Spotify has no active playback; it is not an app error state. In that case the playback snapshot is valid but empty, and playback fields may be null or empty strings, including progress_ms, duration_ms, volume_percent, device.volume_percent, title, track_name, artist, album_name, uri, context_uri, queue_context, and artwork URLs. Clients must keep those fields optional and must not fail decoding because no playback is active.

seek_relative uses an integer value in milliseconds. Positive values seek forward in the current track; negative values seek backward. Home Assistant should clamp the target position to the current track duration and return a normal command/status response. ESP clients may omit this UI feature.

When the playback backend is unavailable, clients keep the pairing token and show playback as temporarily unavailable with recovery copy that says DJConnect will retry automatically. Clients should throttle repeated user-facing notices for backend_unavailable so command retries and automatic recovery refreshes do not churn the UI. Recovery refreshes should avoid unnecessary collection loads while probing backend availability. When later status/command responses report the backend healthy again, clients should clear recoverable backend/Spotify authorization messages and return the DJ request panel to its default microphone instruction.

Queue

The app loads queue data with:

{"device_id":"djconnect-ios-8F3A2C91B45D","client_type":"ios","command":"queue","limit":100}

Preferred success shape:

{
  "success": true,
  "queue": {
    "items": [
      {
        "title": "Song title",
        "artist": "Artist name",
        "album": "Album name",
        "uri": "spotify:track:...",
        "duration_ms": 213000,
        "album_image_url": "https://..."
      }
    ],
    "context": "spotify:playlist:..."
  }
}

Compatibility rules:

  • queue.items may be empty and that is not an error.
  • The Apple app requests up to 100 queue items with limit:100. Home Assistant should return up to that many real backend queue items and must not pad the response with repeated copies of the current track.
  • Home Assistant may return queue as either { "items": [...] } or a flat array; older/debug responses may return flat items. The app accepts all three forms.
  • Queue context may be returned as queue.context, top-level context_uri, or top-level contextUri.
  • Album art may be returned as album_image_url, media_image_url, image_url, or entity_picture.
  • Queue row playback sends command:"play_context_at" with the item URI and, when known, context_uri.
  • The app includes offset_uri only for Spotify contexts that support offsets: playlist, album, and show contexts. Artist contexts are sent without offset_uri because Spotify rejects offsets for artist playback.
  • When context is absent, the app keeps the row disabled and asks the user to refresh Now Playing and the queue; Home Assistant should return a queue context whenever queue row playback is supported.

Home Assistant URL Stability

After successful local pairing, the app stores the Home Assistant installation metadata returned by the integration: ha_local_url, optional ha_remote_url, remote support, client identity, protocol version, and music-backend summary. The app must not use ha_remote_url for first pairing. Runtime calls use local first, remote second for iOS/macOS, and offline when neither endpoint is reachable.

Fresh installs should present http://homeassistant.local:8123 as the default Home Assistant URL. This is only a UI default; runtime app-to-HA requests must still use the paired ha_local_url returned by Home Assistant after pairing.

Ask DJ Text

POST /api/djconnect/v1/ask_dj/message
Content-Type: application/json

The Apple clients send free-form Ask DJ text to Home Assistant. Home Assistant owns intent classification, current playback lookup, output-device lookup, Spotify mutations, DJ response generation, and optional TTS. The app must not hardcode Ask DJ intent families client-side, parse visible response text to infer actions, or attach artwork/media from previous bubbles. Clients render only the text, messages, images, links, sources, audio, items, and actions returned by the current backend response.

iOS, macOS, and watchOS surface DJ requests through Ask DJ. Now Playing does not expose a separate DJ verzoek block on Apple clients. rbpi does not have a separate rich DJ request UI, and ESP32 remains outside this Ask DJ rich chat UI contract.

Minimum payload:

{
  "device_id": "djconnect-ios-8F3A2C91B45D",
  "device_name": "Peter's iPhone",
  "client_id": "djconnect-ios-8F3A2C91B45D",
  "client_type": "ios",
  "text": "Voeg dit nummer toe aan mijn favorieten",
  "audio_response": "auto",
  "dj_style": "warm_radio_dj",
  "music_dna_key": "djconnect_ios_djconnect-ios-8F3A2C91B45D",
  "metadata": {
    "trigger": "manual"
  }
}

Clients should include the selected mood as a numeric mood value from 0 to 100 whenever it is available. Home Assistant may use this as request context for Ask DJ generation, recommendations, Track Insight, Music DNA refreshes, and playback commands that accept mood-aware behavior. Apple clients persist the current selected mood locally and do not infer mood from visible response text. On Apple clients, users change the selected mood from Now Playing only; Ask DJ and Track Insight use the shared mood for request context and visual styling but do not expose duplicate mood controls.

audio_response may be auto, always, or never; Apple text chat defaults to auto. Missing audio_url is a normal successful response for informational text answers. Replay UI is shown only when assistant_message.audio_url or top-level audio_url is present.

Ask DJ responses may include structured mood metadata either top-level or on assistant_message / history messages:

{
  "dj_text": "Dit past bij de energie van nu.",
  "mood": 72,
  "mood_context": {
    "value": 72,
    "zone": "energy"
  },
  "assistant_message": {
    "id": "server-assistant-...",
    "role": "assistant",
    "text": "Dit past bij de energie van nu.",
    "mood": 72
  }
}

Clients use this metadata for visual rendering only. If a response or assistant message contains its own mood value, that value wins for the rendered assistant bubble. Otherwise clients use the current selected client mood when available. If no structured mood is known, clients keep the default Ask DJ assistant gradient. Mood zones are interpreted as 0...24 = chill, 25...59 = groove, 60...84 = energy, and 85...100 = party. Clients must not parse Dutch or English response strings to infer mood.

Apple clients include stable identity fields in Ask DJ message and command payloads: device_id, device_name, client_id, and client_type. client_id currently matches device_id and is included for compatibility with backends that key history, follow-up state, or diagnostics by client.

metadata is optional and backend-owned. Clients may use it to signal context triggers without adding client-side intent logic. The planned morning startup flow sends metadata.trigger == "morning_startup" with text such as Goedemorgen or Good morning when the app starts in the morning and no active playback is known. Home Assistant should treat this as a normal Ask DJ request and may answer with text, media, audio, recommendations, or follow-up actions. Clients must not automatically start music solely because this trigger is sent.

The Home Assistant integration should support at least these Ask DJ intent families in addition to general informational questions and playback control:

  • favorite_current_track: like/save the current track. Dutch examples: Voeg dit nummer toe aan mijn favorieten, zet dit nummer bij mijn favorieten, like dit nummer, sla deze track op, bewaar dit nummer. English examples: add this song to my favorites, like this track, save this song, add the current track to liked songs.
  • output_devices_info: answer questions about known playback outputs without changing playback. Dutch examples: Welke speakers zijn er?, Welke output devices zijn beschikbaar?, Waar kan ik muziek afspelen?. English examples: which speakers are available?, what output devices do I have?.
  • current_output_info: answer where music is currently playing without changing playback. Dutch examples: Waarop wordt nu muziek gespeeld?, Op welke speaker speelt dit?, Waar speelt de muziek nu?. English examples: where is music playing now?, which speaker is active?.
  • personalized_mood_playback: interpret fuzzy mood/energy requests and start or queue music that matches the user's current state and known preferences. Dutch examples: Ik voel me moe en geprikkeld, zet wat rustige muziek op die ik fijn vind, Doe iets ontspannends, ik ben overprikkeld, Zet iets op waar ik rustig van word, Ik wil even kalme muziek zonder vocals. English examples: I am tired and overstimulated, play relaxing music I will enjoy, play something calming that I usually like, put on something low energy.
  • change_music_context: interpret broad requests to hear something else as a playback-changing intent. Dutch examples: Ik wil wat anders horen, Doe maar iets anders, Zet iets anders op, Verras me met iets heel anders, Ik ben dit zat, draai wat anders. English examples: I want to hear something else, play something different, put on something else, surprise me with something completely different, I am tired of this, play something else.
  • personal_music_profile_analysis: describe the user's listening profile over a requested period without changing playback. Dutch examples: Omschrijf eens waar ik zoal naar luisterde de afgelopen maand, Wat zegt mijn muziek van de laatste twee weken over mijn stemming?, Welke genres luister ik de laatste tijd veel?, Maak een profiel van mijn muzieksmaak dit jaar. English examples: describe what I have been listening to over the last month, what does my music from the last two weeks say about my mood?, which genres have I been listening to lately?, make a profile of my music taste this year.
  • personal_music_recommendations: recommend music from the user's known listening profile without changing playback unless the user explicitly asks to play, queue, or save something. Dutch examples: Geef me muziek aanbevelingen op basis van mijn luisterprofiel, Wat zou ik nu leuk vinden om te luisteren?, Raad me iets nieuws aan dat past bij mijn smaak, Welke artiesten of albums moet ik eens proberen?, Geef me vijf nummers die passen bij wat ik de laatste tijd luister. English examples: recommend music based on my listening profile, what should I listen to now?, recommend something new that fits my taste, which artists or albums should I try?, give me five tracks that match what I have been listening to lately.
  • dj_announcement_request: generate a radio-style DJ announcement for the current or next track without changing playback. Dutch examples: Geef me een leuke aankondiging voor het volgende nummer, Kondig het volgende nummer alvast aan, Doe een radio intro voor wat er nu speelt, Zeg iets leuks over dit nummer. English examples: give me a fun announcement for the next song, do a radio-style intro for what is playing now, say something fun about this track.
  • track_context_info: answer rich informational questions about the current track, artist, release, genre, trivia, samples, concerts, releases, or musical connections without changing playback. Dutch examples: Vertel iets over dit nummer, Wanneer kwam dit uit?, Waar komt deze artiest vandaan?, Welke samples hoor ik?, Heeft deze artiest binnenkort concerten in Nederland?, Waarom koos je dit nummer?, Wat is de connectie met het vorige nummer?. English examples: tell me about this song, what year was this released?, where is this artist from?, what samples are used?, does this artist have concerts in the Netherlands?, why did you choose this track?.
  • track_insight: answer Track Insight questions about the current track without changing playback. Dutch examples: Geef Track Insight voor dit nummer, Analyseer deze track, Hoe is deze track opgebouwd?, Welke instrumenten hoor je hierin?, Hoe zit intro, couplet, refrein en breakdown in elkaar?. English examples: Give me a Track Insight of this song, How is this track built up?, Why does this track work so well?.

For favorite_current_track, Home Assistant should use the current playback context if the request uses deictic language such as dit nummer, deze track, this song, or current track. It should support adding and removing the current Spotify track from liked songs. Current favorite state is backend-owned: clients may display playback.is_liked, playback.favorite_status, or Ask DJ action metadata, but must show an inactive neutral favorite button when status is unknown. Direct clients use POST /api/djconnect/v1/command with {"command":"set_current_track_favorite","value":true} to add and {"command":"set_current_track_favorite","value":false} to remove. Older save_current_track may still work, but new clients should use set_current_track_favorite. It should return a normal DJ text response after the mutation succeeds. If no current track exists, return success:false or a clear dj_text explaining that nothing is playing.

For output-device information, Home Assistant should return a textual summary in dj_text and may include structured output playback_actions when it wants Apple clients to offer direct speaker activation from Ask DJ. Clients render output actions as vertical rows with the speaker name on the left and an Actief/Activeer button on the right. Do not treat output-info questions as playback-transfer commands unless the user selects an explicit output action or otherwise asks to switch speakers.

For personalized_mood_playback, Home Assistant should combine the user's described mood, current time/context, playback history, likes/skips, Music DNA, and available output device. This intent may start playback or add to the queue. It should avoid brittle keyword-only routing: phrases such as moe, geprikkeld, overprikkeld, rustig, ontspannen, calming, overstimulated, and low energy should be interpreted semantically. If no preferred output is active, use the current/preferred DJConnect output or return a clear DJ response asking the user to choose a speaker.

For change_music_context, Home Assistant should treat broad phrases like Ik wil wat anders horen as an explicit request to change playback, not merely as an informational recommendation question. It should use current playback, recent listening, skips/likes, Music DNA, mood, and output context to pick something meaningfully different while still fitting the user. "Different" may mean a different artist, genre, era, energy level, playlist, or album context; avoid simply restarting the same track, replaying the current artist by default, or making a tiny queue-only change unless that is clearly requested. If no output is active, use the current/preferred DJConnect output or ask the user to choose one. Return DJ text explaining the switch and include playback metadata or audio_url when available.

For personal_music_profile_analysis, Home Assistant should answer questions about the user's listening patterns over a user-provided or inferred period without mutating playback. It should use Music DNA, stored recent tracks, likes/skips where available, playlist/queue choices, timestamps, moods, and current playback context. If the user asks afgelopen x periode, parse periods such as vandaag, deze week, afgelopen twee weken, afgelopen maand, laatste 90 dagen, or dit jaar; if no period is given, default to a recent window such as the last 30 days and mention that choice.

Useful response material includes:

  • most-listened genres, artists, albums, labels, eras, or track clusters;
  • recurring moods and listening contexts such as focus, cooking, late evening, workouts, background listening, or high-energy sessions;
  • energy profile, such as chill versus party, vocal versus instrumental, melodic versus rhythmic, familiar versus exploratory;
  • notable changes compared with earlier memory, if enough data exists;
  • concrete examples from recent listening, while avoiding an exhaustive dump of history;
  • a short DJ-style summary of what the user's music taste currently says about their vibe.

If there is not enough local Music DNA or playback history for the requested period, return an honest DJ response explaining the gap and summarize what is available. Do not invent listening history. This intent should return text and may include optional source links or images, but it should not start, queue, like, skip, transfer, or otherwise change playback.

For personal_music_recommendations, Home Assistant should combine Music DNA, Spotify recently played, Spotify top artists/tracks, liked tracks, skips, explicit Ask DJ preferences, mood/energy settings, current playback context, and time/context signals where available. The result should be concrete, personalized recommendations rather than only a broad genre label.

Useful response material includes:

  • recommended tracks, albums, artists, playlists, labels, or eras;
  • why each recommendation fits the user's known profile;
  • a balance of familiar picks and discovery picks;
  • mood/energy fit such as focus, cooking, chill, party, or late-evening listening;
  • optional images such as album art and optional source links where available.

This intent is informational by default. It should not start playback, queue tracks, save tracks, like tracks, transfer output, or alter playback unless the user explicitly asks for an action, for example speel deze aanbevelingen, zet de eerste op, voeg ze toe aan de wachtrij, or maak hier een playlist van. If the user asks for immediate playback with a fuzzy recommendation such as zet iets op dat bij mijn smaak past, Home Assistant may route to a playback-capable recommendation flow and should return the executed action in the response metadata.

When the response contains concrete playable recommendations, Home Assistant should also return playback_actions. Apple clients render these as explicit Play Now buttons. Tapping such a button sends a follow-up command to Home Assistant, so recommendations remain informational until the user explicitly chooses one. Clients render Play Now only for playback_actions with kind equal to track, album, playlist, artist, or track_mix.

{
  "intent": "personal_music_recommendations",
  "action": "none",
  "dj_text": "Ik denk dat deze drie goed passen bij je recente progressive en melodic house profiel.",
  "playback_actions": [
    {
      "id": "spotify:track:123",
      "title": "Track Title",
      "subtitle": "Artist Name",
      "uri": "spotify:track:123",
      "context_uri": "spotify:album:456",
      "offset_uri": "spotify:track:123",
      "kind": "track",
      "image_url": "/api/djconnect/v1/proxy/image/album-456.jpg",
      "reason": "Past bij je recente voorkeur voor melodische opbouw."
    },
    {
      "id": "spotify:album:789",
      "title": "Album Title",
      "subtitle": "Artist Name",
      "uri": "spotify:album:789",
      "kind": "album"
    }
  ]
}

The follow-up command is:

{
  "device_id": "djconnect-ios-8F3A2C91B45D",
  "client_type": "ios",
  "command": "ask_dj_play_recommendation",
  "play": true,
  "value": {
    "id": "spotify:track:123",
    "title": "Track Title",
    "subtitle": "Artist Name",
    "uri": "spotify:track:123",
    "context_uri": "spotify:album:456",
    "offset_uri": "spotify:track:123",
    "kind": "track",
    "image_url": "/api/djconnect/v1/proxy/image/album-456.jpg",
    "reason": "Past bij je recente voorkeur voor melodische opbouw."
  }
}

The value is the full selected playback_action object returned by Home Assistant. Clients must not reconstruct original prompts or add local memory fields to the action payload.

If the command response returns error:"no_active_output" and action:"select_output" with speaker playback_actions, clients render those speaker actions in Ask DJ. When the user taps a speaker action whose command is ask_dj_play_request_on_output or ask_dj_play_recommendation_on_output, the client posts that action's command and full returned value unchanged to POST /api/djconnect/v1/command. The backend then sets output and replays the original request server-side.

Home Assistant owns the final Spotify playback decision. It may start a track, album, artist, or playlist directly from uri, or use context_uri plus offset_uri when Spotify requires contextual playback.

Backend follow-up and confirmation questions are also returned as playback_actions. Apple clients render them as buttons and send the selected action back to Home Assistant; the backend owns pending follow-up state, validation, and final intent execution. A generic yes/no clarification can use the same action shape:

{
  "intent": "change_music_context",
  "action": "needs_confirmation",
  "dj_text": "Wil je dat ik nu iets anders opzet?",
  "playback_actions": [
    {
      "id": "yes",
      "title": "Ja",
      "kind": "confirmation",
      "action_style": "confirmation",
      "response_value": "yes",
      "command": "ask_dj_followup_response"
    },
    {
      "id": "no",
      "title": "Nee",
      "kind": "confirmation",
      "action_style": "confirmation",
      "response_value": "no",
      "command": "ask_dj_followup_response"
    }
  ]
}

The follow-up command should preserve the returned action object where possible. If the backend returned an object-valued action value, Apple clients preserve it and can derive a textual response from response_value, text, prompt, or value inside that object when needed for display or confirmation routing:

{
  "device_id": "djconnect-ios-8F3A2C91B45D",
  "device_name": "Peter's iPhone",
  "client_id": "djconnect-ios-8F3A2C91B45D",
  "client_type": "ios",
  "command": "ask_dj_followup_response",
  "value": {
    "id": "yes",
    "title": "Ja",
    "kind": "confirmation",
    "action_style": "confirmation",
    "response_value": "yes",
    "value": {
      "text": "Ja",
      "response_value": "yes"
    }
  }
}

Output-switch actions also use playback_actions. Use kind: "output" and provide value, device_id, or response_value; device_name, title, or subtitle should contain the human-readable speaker name. active: true marks the currently active output. For plain speaker-list output actions with command:"set_output", clients call set_output with the returned value.

{
  "intent": "output_devices_info",
  "action": "none",
  "dj_text": "Dit zijn de beschikbare speakers.",
  "playback_actions": [
    {
      "id": "output-tuin",
      "kind": "output",
      "title": "Tuin",
      "device_id": "spotify-device-tuin",
      "device_name": "Tuin",
      "active": true,
      "command": "set_output"
    },
    {
      "id": "output-keuken",
      "kind": "output",
      "title": "Keuken",
      "device_id": "spotify-device-keuken",
      "device_name": "Keuken",
      "active": false,
      "command": "set_output"
    }
  ]
}

When the user taps an output action, Apple clients send:

{
  "device_id": "djconnect-ios-8F3A2C91B45D",
  "device_name": "Peter's iPhone",
  "client_id": "djconnect-ios-8F3A2C91B45D",
  "client_type": "ios",
  "command": "set_output",
  "value": {
    "id": "output-keuken",
    "kind": "output",
    "title": "Keuken",
    "device_id": "spotify-device-keuken",
    "device_name": "Keuken",
    "active": false,
    "command": "set_output",
    "response_value": "spotify-device-keuken"
  }
}

Backends should accept either previous scalar output values or the structured action object. New Apple clients prefer the structured object so Home Assistant can retain context such as kind, speaker name, music_dna_key, and backend-owned follow-up metadata.

For dj_announcement_request, Home Assistant should not mutate playback. It should read current playback and, when available, queue/next-track context, then generate a short DJ-style announcement using the configured DJ personality. It may return audio_url so Apple clients can play or replay the announcement from the Ask DJ chat.

For track_context_info, Home Assistant should enrich current playback context without mutating playback. Useful response material includes:

  • now-playing metadata: album art, title, artist, release year, genre, album, label, producer, and track/album links;
  • DJ commentary: a short, personality-rich explanation such as why the track is notable, what era it belongs to, or why it fits the current set;
  • background information: artist origin, trivia, samples used, related artists, producer/label connections, or notable remixes;
  • concert and release information: upcoming Netherlands shows, relevant festivals, recent or upcoming album/single releases, and authoritative links;
  • musical connections: why the track was chosen, relation to the previous track, energy flow, shared producer, shared label, genre lineage, or mood continuity.

Home Assistant may return this as a concise dj_text plus optional images and links. Apple clients already support multiple proxied images, multiple links, and replayable audio_url; current clients do not require a separate structured metadata object. If external artwork, concert pages, artist pages, or release pages are included, images should be proxied by Home Assistant and links should be normal http/https URLs.

For track_insight, Home Assistant should give a Track Insight analysis based on current playback metadata, known facts, available provider metadata, audio features/analysis if available, and/or carefully phrased knowledge-based inferences. This intent is informational/read-only for iOS, macOS, watchOS, Raspberry Pi, and Windows Ask DJ clients. It must never start, pause, skip, queue, save, like, transfer output, or otherwise mutate playback. ESP32 remains outside the Ask DJ chat/history UI.

Direct Track Insight requests must include the same canonical client identity as other DJConnect requests, including client_type. Apple clients send client_type:"ios", client_type:"macos", or, through the iPhone proxy, client_type:"watchos". Home Assistant should reject unsupported or missing client types with invalid_client_type; clients localize that error and keep pairing state intact.

VibeCast Feed

iOS and macOS VibeCast use the same backend-owned feed contract:

GET /api/djconnect/v1/vibecast
Authorization: Bearer <djconnect_bearer_token>
X-DJConnect-Device-ID: <device_id>
X-DJConnect-Client-ID: <device_id>
X-DJConnect-Client-Type: ios|macos
X-DJConnect-Device-Name: <device_name>
X-DJConnect-Locale: <bcp47-locale>
X-DJConnect-Timezone: <iana-timezone>
X-DJConnect-Render-Capabilities: bold,emphasis,magnify,accent,emoji_safe

The same values may also be present as query parameters for compatibility with simple Home Assistant route handlers. Home Assistant remains source of truth for current playback context; clients do not send Spotify-specific assumptions and must not require a particular music backend.

The optional local WebSocket fast path uses the route name djconnect/vibecast and returns the same JSON contract as HTTP. Clients only use it when Home Assistant advertises the capability from djconnect/capabilities; missing capability, timeout, disconnect, malformed result, or server error falls back to one HTTP request without clearing pairing.

Expected success:

{
  "enabled": true,
  "revision": 12,
  "ttl_seconds": 45,
  "poll_after_seconds": 20,
  "context": {
    "track_id": "provider-or-stable-track-id",
    "title": "Song Title",
    "artist": "Artist Name",
    "album": "Album Name",
    "music_backend": "music_assistant",
    "music_backend_name": "Music Assistant",
    "music_backend_revision": 2
  },
  "items": [
    {
      "id": "fact-1",
      "kind": "track_fact",
      "tone": "playful",
      "priority": 50,
      "display_seconds": 8,
      "placement_hint": "side",
      "text": [
        { "type": "emoji", "value": "♪ ♫ " },
        { "type": "text", "value": "Deze track leunt op " },
        { "type": "strong", "value": "ritme en ruimte" },
        { "type": "text", "value": "." }
      ],
      "source": { "kind": "generated", "confidence": "medium" }
    }
  ],
  "cache": { "hit": false }
}

Supported item kinds are track_fact, artist_fact, album_fact, genre_fact, trivia, listening_tip, mood_note, production_note, history_note, and system. Supported rich-text segment types are text, strong, emphasis, emoji, magnify, accent, and line_break. Clients render segments directly, do not parse HTML or Markdown, and degrade unknown kinds or segment types to plain text. Backends should only send emoji segments when the client advertises emoji_safe; emoji segment values are rendered inline with the same wrapping and spacing as text.

Disabled responses should be short, structured, and safe:

{
  "enabled": false,
  "reason": "no_active_playback",
  "ttl_seconds": 30,
  "poll_after_seconds": 30,
  "items": []
}

Clients handle at least feature_disabled, premium_unavailable, no_active_playback, playback_inactive, unknown_track, unsupported_backend, provider_unavailable, generative_provider_unavailable, rate_limited, cache_failure, unauthorized, invalid_client_type, client_type_mismatch, and privacy_disabled as quiet empty/degraded states. Raw provider, cache, decode, HTML, prompt, token, or private URL errors must not be returned for display.

While the VibeCast surface is visible, Apple clients poll according to poll_after_seconds and clear stale bubbles when context.track_id changes. VibeCast also activates a client-side "auto analyze on next track start" mode: the client automatically requests Track Insight for the current playing track with open:false, then repeats once per new playing track until VibeCast is closed/stopped. This is presentation support for the VibeCast stream; Home Assistant should treat those direct Track Insight calls exactly like normal read-only Track Insight requests and must not mutate playback.

Zero-conf setups must work without a central DJConnect backend. Use only Home Assistant-local config and user-provided provider keys. Possible providers include spotify_playback_context, ha_conversation, spotify_audio_features, spotify_audio_analysis, acousticbrainz, and local_audio_analyzer. Spotify audio feature/analysis endpoints may be unavailable for some apps; treat them as optional. Never store OAuth tokens, API keys, raw prompts, or raw audio in Ask DJ history or Music DNA. History may keep only compact response metadata.

The response should distinguish provider-backed values from inferred musical commentary. Do not invent exact timestamps, section labels, stem separation, exact chords, exact instrument lists, or a full transcription unless a real audio-analysis pipeline or trusted source supplies enough confidence. If no current track is available, return friendly text such as er speelt nu geen track die ik kan analyseren.

Useful response material includes:

  • instrumentation and sound palette;
  • arrangement and song structure, such as intro, build, verse, chorus, break, drop, outro, or gradual layering;
  • rhythm, groove, energy curve, and transition qualities;
  • harmony, chords, melody, motifs, or tension/release when known or safely inferable;
  • sound design and production techniques such as filtering, sidechain, reverb, delay, automation, sampling, layering, risers, or call-and-response;
  • mix/mastering impressions such as stereo width, low-end handling, dynamics, vocal placement, or how elements leave space for one another;
  • why the composition or production works emotionally or functionally in the current DJ set.

Clients render this as a text-first Ask DJ answer. Optional items[] may be shown as compact metrics/lists, for example kind: "energy" or kind: "arrangement" for structure. Clients must not add Play Now buttons for this intent and should not parse text content; use intent.intent, action, analysis.mode, analysis.sources, and items[].kind where structured behavior is needed. analysis.mode: "unavailable" and limitations[] are normal response metadata, not an error state.

Expected successful Track Insight response:

{
  "success": true,
  "text": "De track werkt door een gefocuste groove, een geleidelijke laag-opbouw en een drop die de opgebouwde spanning loslaat.",
  "dj_text": "De track werkt door een gefocuste groove, een geleidelijke laag-opbouw en een drop die de opgebouwde spanning loslaat.",
  "message": "De track werkt door een gefocuste groove, een geleidelijke laag-opbouw en een drop die de opgebouwde spanning loslaat.",
  "action": "track_insight",
  "intent": {
    "category": "informational",
    "intent": "track_insight",
    "action": "track_insight"
  },
  "analysis": {
    "mode": "measured_plus_knowledge",
    "confidence": "high",
    "track": {
      "title": "Track Title",
      "artist": "Artist Name",
      "album": "Album Title",
      "uri": "spotify:track:123"
    },
    "measured": {
      "sections": [
        {
          "label": "intro",
          "start_ms": 0,
          "duration_ms": 32000,
          "confidence": 0.7
        }
      ],
      "features": {
        "energy": 0.82,
        "danceability": 0.71
      }
    },
    "inferred": {
      "structure": "Lange intro, geleidelijke laag-opbouw, climax/drop en outro.",
      "instrumentation": "Synthpads, basdruk en subtiele percussie.",
      "melodic_build": "Herhaling bouwt spanning op.",
      "energy_curve": "Geleidelijk stijgend.",
      "mix_notes": "Brede pads met ruimte voor de kick."
    },
    "limitations": [
      "Exact section labels are inferred, not measured."
    ],
    "sources": [
      "spotify_playback_context",
      "spotify_audio_features"
    ]
  },
  "items": [
    {
      "kind": "energy",
      "title": "Energie",
      "value": "82%",
      "source": "spotify_audio_features"
    },
    {
      "kind": "arrangement",
      "title": "Opbouw",
      "value": "Lange intro, geleidelijke laag-opbouw, climax/drop, outro",
      "source": "ha_conversation"
    }
  ],
  "images": [],
  "links": [],
  "sources": [
    {
      "source": "spotify_playback_context",
      "title": "Spotify playback context",
      "kind": "source"
    }
  ],
  "playback_actions": [],
  "confirmation_actions": []
}

Expected successful favorite response:

{
  "success": true,
  "intent": "favorite_current_track",
  "action": "spotify_like_current_track",
  "text": "Ik heb Strobe toegevoegd aan je favorieten.",
  "dj_text": "Ik heb Strobe toegevoegd aan je favorieten.",
  "playback_actions": [
    {
      "id": "favorite-current-track",
      "title": "Haal uit favorieten",
      "kind": "control",
      "command": "set_current_track_favorite",
      "toggle": true,
      "toggle_state": true,
      "favorite_status": true,
      "value": false,
      "client_prompt": "haal huidig nummer uit favorieten"
    }
  ]
}

Expected successful output-info response:

{
  "success": true,
  "intent": "output_devices_info",
  "action": "none",
  "text": "Je kunt afspelen op Marantz Cinema 60, Keuken en Tuin. Marantz Cinema 60 is nu actief.",
  "dj_text": "Je kunt afspelen op Marantz Cinema 60, Keuken en Tuin. Marantz Cinema 60 is nu actief.",
  "playback_actions": [
    {
      "id": "output-marantz",
      "title": "Marantz Cinema 60",
      "kind": "output",
      "device_id": "marantz-cinema-60",
      "active": true,
      "command": "set_output"
    },
    {
      "id": "output-keuken",
      "title": "Keuken",
      "kind": "output",
      "device_id": "keuken",
      "active": false,
      "command": "set_output"
    }
  ]
}

Expected successful personalized mood response:

{
  "success": true,
  "intent": "personalized_mood_playback",
  "action": "spotify_start_personalized_context",
  "text": "Ik zet iets rustigs op met warme, bekende sounds. Geen harde drops, gewoon even landen.",
  "dj_text": "Ik zet iets rustigs op met warme, bekende sounds. Geen harde drops, gewoon even landen."
}

Expected successful DJ announcement response:

{
  "success": true,
  "intent": "dj_announcement_request",
  "action": "none",
  "text": "En daar komt-ie aan: een warme, hypnotische plaat die precies tussen focus en zweven in hangt.",
  "dj_text": "En daar komt-ie aan: een warme, hypnotische plaat die precies tussen focus en zweven in hangt.",
  "audio_url": "http://homeassistant.local:8123/api/djconnect/v1/tts/announcement-123.mp3",
  "audio_type": "mp3"
}

Expected successful track context response:

{
  "success": true,
  "intent": "track_context_info",
  "action": "none",
  "text": "Strobe kwam uit in 2009 op het album For Lack of a Better Name. De lange opbouw en warme synthlijn maken het een progressive-house klassieker; ik koos hem omdat hij mooi aansluit op de rustige energie van de vorige track.",
  "dj_text": "Strobe kwam uit in 2009 op het album For Lack of a Better Name. De lange opbouw en warme synthlijn maken het een progressive-house klassieker; ik koos hem omdat hij mooi aansluit op de rustige energie van de vorige track.",
  "images": [
    {
      "url": "http://homeassistant.local:8123/api/djconnect/v1/image_proxy/album/strobe",
      "title": "For Lack of a Better Name",
      "subtitle": "deadmau5",
      "kind": "album_art",
      "source": "spotify"
    }
  ],
  "links": [
    {
      "url": "https://example.com/artist/deadmau5/concerts",
      "title": "Concertdata",
      "subtitle": "Komende shows en festivals",
      "kind": "concerts"
    }
  ]
}

Expected successful Track Insight response:

{
  "success": true,
  "intent": {
    "category": "informational",
    "intent": "track_insight",
    "action": "track_insight"
  },
  "action": "track_insight",
  "text": "Muzikaal werkt dit nummer door de langzame spanningsopbouw: eerst een minimale puls, daarna laag voor laag synthpads, percussie en basdruk.",
  "dj_text": "Muzikaal werkt dit nummer door de langzame spanningsopbouw: eerst een minimale puls, daarna laag voor laag synthpads, percussie en basdruk.",
  "analysis": {
    "mode": "knowledge_plus_metadata",
    "confidence": "medium",
    "limitations": [
      "Exact section timestamps are unavailable."
    ],
    "sources": [
      "spotify_playback_context",
      "ha_conversation"
    ]
  },
  "playback_actions": [],
  "confirmation_actions": []
}

Ask DJ History Sync

GET /api/djconnect/v1/ask_dj/history
POST /api/djconnect/v1/ask_dj/history/clear

iOS, macOS, and watchOS sync Ask DJ chat history from Home Assistant. When Ask DJ opens, Apple clients scroll the loaded timeline to the newest message by default, including after the first async history response. The backend is the cross-device source of truth for delivered messages, clear revisions, ambient/system messages, and retention. Clients may keep a local cache for performance, but must merge server messages into that cache instead of replacing the full local list with a bounded response window.

If a history response advances clear_revision, clients should clear old cached messages but preserve a fresh local exchange that shares a client_message_id with the current request/response cycle. This avoids a status or history sync that arrives a few seconds later from erasing the answer the user just received. Once the backend returns that exchange in history, the normal server copy becomes authoritative.

History messages may be normal user/assistant messages or assistant-only system messages. System messages are rendered as subtle DJ/system bubbles and do not require a preceding user bubble.

{
  "id": "server-...",
  "role": "assistant",
  "message_kind": "system",
  "origin": "spotify_playback_context",
  "text": "Leuk feitje over OK Computer.",
  "intent": {
    "category": "informational",
    "intent": "ambient_music_fact"
  },
  "action": "none",
  "audio_url": null
}

Missing message_kind defaults to assistant. origin: spotify_playback_context represents backend-generated ambient music facts. audio_url is optional; replay UI appears only when an audio URL is present. Assistant history messages may include mood or mood_context using the same 0...100 contract as live Ask DJ responses. Apple clients preserve this metadata when merging history into the local chat cache so old assistant bubbles keep their original mood-aware styling across iOS, macOS, and watchOS. User and system messages do not need mood styling.

The backend should bound history per Home Assistant user/Music DNA key. When a history limit is reached, the backend should add an assistant-only system message and return explicit trim metadata so clients can prune their local cache without parsing display text:

{
  "success": true,
  "user_id": "peter",
  "history_revision": 42,
  "clear_revision": 0,
  "history_limit": 200,
  "history_trimmed_before": "2026-06-20T12:34:56Z",
  "history_trimmed_count": 12,
  "messages": [
    {
      "id": "server-retention-...",
      "role": "assistant",
      "message_kind": "system",
      "origin": "history_retention",
      "text": "Ask DJ heeft de limiet van 200 berichten bereikt. Oudste berichten worden verwijderd.",
      "intent": {
        "category": "system",
        "intent": "history_limit_reached"
      },
      "action": "none",
      "audio_url": null
    }
  ]
}

Clients may remove local cached messages older than history_trimmed_before. clear_revision remains the authoritative full-clear signal; if it advances, clients clear local Ask DJ history before applying returned messages.

When the user clears Ask DJ history, Apple clients call POST /api/djconnect/v1/ask_dj/history/clear. The request uses the same bearer auth and client identity as other Ask DJ endpoints, and may include music_dna_key when the client is using a scoped Music DNA/history namespace:

{
  "device_id": "djconnect-ios-8F3A2C91B45D",
  "device_name": "Peter's iPhone",
  "client_id": "djconnect-ios-8F3A2C91B45D",
  "client_type": "ios",
  "music_dna_key": "djconnect_ios_djconnect-ios-8F3A2C91B45D"
}

Expected successful clear response:

{
  "success": true,
  "history_revision": 43,
  "clear_revision": 7,
  "messages": []
}

After a clear, older messages must not reappear in later history responses for the same scope. New messages after the clear should use normal history_revision advancement and include the latest clear_revision.

Backend or proxy error bodies must never be shown raw in Ask DJ UI. Clients log technical details for diagnostics, but user-facing Ask DJ errors are limited to short localized messages such as Ask DJ niet bereikbaar or Home Assistant gaf geen antwoord.

Music DNA Profile

POST /api/djconnect/v1/music_dna/profile
POST /api/djconnect/v1/music_dna/settings
POST /api/djconnect/v1/music_dna/clear
POST /api/djconnect/v1/music_dna/export
POST /api/djconnect/v1/music_dna/import

Music DNA is server-side in Home Assistant and explicitly opt-in. Apple clients do not persist a Music DNA profile locally; the Music DNA dashboard uses /api/djconnect/v1/music_dna/profile as the source of truth whenever the screen opens. Track Insight responses must not expose or render per-track Music DNA match fields such as music_dna.match_percent, labels, or match reasons.

All Music DNA requests use the existing DJConnect bearer auth, device_id, and canonical client_type values such as ios, macos, watchos, raspberry_pi, or windows.

iOS and macOS call these endpoints directly. watchOS calls them through the paired iPhone WatchConnectivity proxy, but the forwarded payload remains the Watch identity (device_id plus client_type:"watchos"). The initial Music DNA consent prompt is reachable from both Ask DJ and the Music DNA screen; Settings on iOS, macOS, and watchOS can opt out and opt in again. Client Settings copy should reflect the current state: enabled means future listening signals may build the server-side profile, disabled means no profile is being built and any previously learned profile was already cleared by opt-out.

Profile request payload:

{
  "device_id": "djconnect-ios-8F3A2C91B45D",
  "client_type": "ios"
}

enabled:false with profile:{} means clients show an opt-in/empty state. The backend must not build Music DNA knowledge while disabled. enabled:true means the dashboard may render structured backend profile data; if the profile is empty, clients show a learning state such as Music DNA is aan, maar nog aan het leren.

Settings opt-in/opt-out payload:

{
  "device_id": "djconnect-ios-8F3A2C91B45D",
  "client_type": "ios",
  "enabled": true
}

Clients call /music_dna/settings for both opt-in and opt-out, then refresh /music_dna/profile. On opt-out, the backend is expected to clear learned Music DNA and stop further buildup. Because opt-out already clears learned Music DNA, clients should not present a separate clear-profile action while Music DNA is disabled. After a settings change, clients may keep the just-selected enabled value locally for a short grace window while /music_dna/profile catches up. This prevents a stale profile refresh from visually flipping the toggle back immediately after opt-in or opt-out; Home Assistant remains the source of truth once it returns the same enabled state or the grace window expires.

Clear payload:

{
  "device_id": "djconnect-ios-8F3A2C91B45D",
  "client_type": "ios"
}

Clients call /music_dna/clear after user confirmation and then refresh /music_dna/profile. Clear deletes learned Music DNA but preserves the opt-in setting. If enabled:true remains after clear, the backend starts again from an empty profile and clients show the enabled learning state. Clients should expose this clear action only when Music DNA is currently enabled.

Export payload:

{
  "identity": {
    "device_id": "djconnect-ios-8F3A2C91B45D",
    "client_type": "ios",
    "device_name": "DJConnect iPhone"
  },
  "device_id": "djconnect-ios-8F3A2C91B45D",
  "client_id": "djconnect-ios-8F3A2C91B45D",
  "client_type": "ios",
  "device_name": "DJConnect iPhone",
  "music_dna_key": "djconnect_ios_djconnect-ios-8F3A2C91B45D",
  "language": "nl",
  "locale": "nl",
  "app_version": "3.2.3"
}

iOS and macOS export Music DNA with HTTP POST /api/djconnect/v1/music_dna/export; clients must not use the Home Assistant WebSocket fast path for export. The backend is the source of truth and returns a complete export envelope. Apple clients save/share the exact JSON response body only after the user chooses a destination in the native share/save panel. The client must not build an export envelope from /music_dna/profile, and must not add OAuth tokens, DJConnect bearer tokens, bootstrap proofs, raw prompts, raw audio, diagnostics, Home Assistant URLs, or local cache fields.

Expected export response:

{
  "success": true,
  "format": "djconnect.music_dna.export",
  "schema_version": 1,
  "exported_at": "2026-07-04T20:21:00.123Z",
  "exported_by_client_type": "ios",
  "app_version": "3.2.3",
  "profile": {
    "success": true,
    "music_dna_key": "user:abc123",
    "enabled": true,
    "generation": 12,
    "updated_at": "2026-07-04T20:20:00Z",
    "profile": {},
    "sources": []
  }
}

The Apple filename format is djconnect-music-dna-<client_type>-<yyyyMMdd-HHmmss>.json.

Import accepts a previously exported djconnect.music_dna.export JSON file and shows a local preview before upload. The upload is only available while the Apple client is paired and connected. Backend import overwrites server Music DNA with the supplied profile data after Music DNA has been activated server-side. Import also remains authenticated HTTP-only; clients must not send backup JSON over the Home Assistant WebSocket fast path. If Home Assistant rejects the import, returns 401/403, reports not_configured, or marks pairing stale, clients must show the import/export error in the sheet and use the same pairing recovery behavior as other DJConnect endpoints.

Ask DJ history sync remains separate: /api/djconnect/v1/ask_dj/history and /api/djconnect/v1/ask_dj/history/clear do not clear Music DNA.

Expected profile response:

{
  "success": true,
  "music_dna_key": "user:abc123",
  "enabled": true,
  "generation": 2,
  "clear_requested_at": null,
  "updated_at": "2026-06-29T12:00:00+00:00",
  "profile": {
    "summary": "Warm late-night electronic taste.",
    "favorite_genres": [{"name": "ambient"}],
    "favorite_artists": [{"name": "The xx"}],
    "recent_tracks": [{"title": "Intro", "artist": "The xx"}],
    "top_tracks_by_range": {},
    "top_artists_by_range": {},
    "mood": {"value": 65, "zone": "energy", "prompt_hint": "keep it warm"},
    "time_patterns": [],
    "recommendation_signals": [],
    "blocked_artists": [],
    "blocked_items": [],
    "last_profile_refresh": "2026-06-29T12:00:00+00:00",
    "consent_updated_at": "2026-06-29T12:00:00+00:00"
  },
  "sources": [{"source": "djconnect_music_dna", "kind": "source", "title": "Music DNA"}]
}

After 401, 403, not_configured, or stale pairing errors, clients clear local Music DNA display/cache for the current Home Assistant installation.

Voice

POST /api/djconnect/v1/voice
Content-Type: audio/wav

The app uploads raw mono PCM WAV. Home Assistant owns STT, Assist, playback action, and TTS.

The app does not parse spoken intent families locally and does not need Spotify credentials or local Spotify Web API calls for voice commands. Home Assistant handles canonical current_track examples such as Welk nummer draait er nu?, Wat speelt er?, and What song is playing? by reading current playback state and returning a DJ response. Home Assistant handles canonical playback_control examples such as Stop muziek, Start muziek, Zet harder, Zet zachter, Volgende nummer, Vorig nummer, Stop music, Start music, Turn it up, Turn it down, Next song, and Previous song by mapping them to backend playback commands.

Home Assistant should route STT results into the same Ask DJ intent families as text requests, including favorite_current_track, output_devices_info, and current_output_info, personalized_mood_playback, and dj_announcement_request, track_context_info, and track_insight.

Expected response:

{
  "success": true,
  "text": "Daar gaan we.",
  "dj_text": "Daar gaan we.",
  "audio_url": "http://homeassistant.local:8123/api/djconnect/v1/tts/token.mp3",
  "audio_type": "mp3"
}

Error Semantics

backend_unavailable

Playback backend authorization is expired or unavailable. This is not an app pairing failure. Keep token and pairing state.

HTTP 426 / version_mismatch

The app and Home Assistant integration do not share the same major.minor protocol version. Keep token and pairing state. Show that the Home Assistant integration must be updated. Disable playback/output/queue/playlist/liked/voice controls, but keep Settings and pairing reset available.

The app also validates ha_version / ha_major_minor on successful status and command responses. App 3.2.x accepts HA 3.2.x only (>=3.2.0, <3.3.0).

HTTP 401/403

Pairing is stale or unauthorized. Keep token until explicit user reset.

During unauthenticated app pairing, HTTP 401/403 means Home Assistant rejected the entered pair code or setup identity. The app must stop pairing and ask the user to verify the 6-digit code shown by Home Assistant or generate a fresh setup code there.

HTTP 400 with error:"client_type_mismatch"

Home Assistant recognized the pairing code but the user selected the wrong Apple app setup flow, for example iPhone/iPad in Home Assistant while the macOS client posts client_type:"macos".

Expected response shape:

{
  "success": false,
  "error": "client_type_mismatch",
  "message": "Selected iOS setup flow does not match this macOS app.",
  "expected_client_type": "macos",
  "received_client_type": "ios"
}

Clients must stop this pairing attempt, keep the entered Home Assistant URL and pair code intact, and show a platform-specific message telling the user to pick the correct DJConnect setup flow in Home Assistant. expected_client_type and received_client_type may be written to debug logs only. Do not clear local pairing credentials or token state for this error unless the client was already paired and Home Assistant explicitly returns authenticated 401/403 stale auth for a runtime request.

HTTP 400 with error:"invalid_pair_code" remains a wrong or unrelated pairing code. HTTP 400 with error:"invalid_client_type" means the client sent an unsupported or missing client_type; clients show a localized configuration error instead of a raw backend string.

HTTP 404

Integration route is missing or setup is stale. Keep token until explicit user reset and show setup recovery.