dacp_client.c 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. /**
  2. * DACP client — sends control commands to AirPlay source device.
  3. *
  4. * Works with both AirPlay 1 and AirPlay 2. The client's DACP service
  5. * is discovered via mDNS using the DACP-ID from the RTSP handshake.
  6. * Commands are fire-and-forget HTTP GETs dispatched on a dedicated
  7. * worker task so callers (button task) never block on network I/O.
  8. *
  9. * Thread safety: dacp_set_session/clear may be called from the RTSP
  10. * server task while dacp_send_* are called from the button task.
  11. * A mutex protects the session state.
  12. */
  13. #include "dacp_client.h"
  14. #include "esp_http_client.h"
  15. #include "esp_log.h"
  16. #include "mdns.h"
  17. #include "freertos/FreeRTOS.h"
  18. #include "freertos/queue.h"
  19. #include "freertos/semphr.h"
  20. #include <stdio.h>
  21. #include <string.h>
  22. #include <strings.h>
  23. static const char *TAG = "dacp";
  24. #define DACP_ID_MAX 32
  25. #define ACTIVE_REMOTE_MAX 32
  26. #define MDNS_TIMEOUT_MS 1500
  27. #define MDNS_RETRY_COUNT 1
  28. #define MDNS_RETRY_DELAY_MS 500
  29. #define CMD_PATH_MAX 80
  30. #define CMD_QUEUE_LEN 4
  31. #define HTTP_TIMEOUT_MS 800
  32. #define WORKER_STACK 4096
  33. static SemaphoreHandle_t s_mutex;
  34. static bool s_initialized;
  35. static char s_dacp_id[DACP_ID_MAX];
  36. static char s_active_remote[ACTIVE_REMOTE_MAX];
  37. static uint32_t s_client_ip;
  38. static uint16_t s_dacp_port; // Discovered via mDNS
  39. static bool s_session_valid;
  40. static bool
  41. s_discovery_failed; // True if mDNS discovery failed for this session
  42. static QueueHandle_t s_cmd_queue;
  43. static TaskHandle_t s_worker_handle;
  44. // Special sentinel posted to the command queue to trigger mDNS discovery
  45. // on the worker task (avoids spawning a new task per session).
  46. static const char CMD_DISCOVER[] = "\x01";
  47. // Discover the DACP port via mDNS. Called WITHOUT the mutex held since
  48. // mDNS queries can block for several seconds. Copies the DACP-ID under
  49. // the lock, performs discovery lock-free, then stores the result.
  50. static void discover_dacp_port(void) {
  51. char dacp_id_local[DACP_ID_MAX];
  52. xSemaphoreTake(s_mutex, portMAX_DELAY);
  53. // Skip if port is already known or discovery already failed
  54. if (s_dacp_port != 0 || s_discovery_failed) {
  55. xSemaphoreGive(s_mutex);
  56. return;
  57. }
  58. strlcpy(dacp_id_local, s_dacp_id, sizeof(dacp_id_local));
  59. xSemaphoreGive(s_mutex);
  60. if (dacp_id_local[0] == '\0') {
  61. return;
  62. }
  63. uint16_t found_port = 0;
  64. for (int attempt = 0; attempt < MDNS_RETRY_COUNT && found_port == 0;
  65. attempt++) {
  66. if (attempt > 0) {
  67. vTaskDelay(pdMS_TO_TICKS(MDNS_RETRY_DELAY_MS));
  68. ESP_LOGI(TAG, "DACP mDNS retry %d/%d", attempt + 1, MDNS_RETRY_COUNT);
  69. }
  70. // Query mDNS for _dacp._tcp services
  71. mdns_result_t *results = NULL;
  72. esp_err_t err =
  73. mdns_query_ptr("_dacp", "_tcp", MDNS_TIMEOUT_MS, 8, &results);
  74. if (err != ESP_OK || !results) {
  75. ESP_LOGW(TAG, "mDNS DACP query failed or no results (err=%s)",
  76. esp_err_to_name(err));
  77. continue;
  78. }
  79. // Find the result matching our DACP-ID.
  80. // iOS advertises as "iTunes_Ctrl_<DACPID>" or just "<DACPID>".
  81. for (mdns_result_t *r = results; r; r = r->next) {
  82. ESP_LOGI(TAG, "mDNS DACP service: instance='%s' port=%u",
  83. r->instance_name ? r->instance_name : "(null)", r->port);
  84. if (!r->instance_name || r->port == 0) {
  85. continue;
  86. }
  87. // Match: exact, or instance contains the DACP-ID as a substring
  88. if (strcasecmp(r->instance_name, dacp_id_local) == 0 ||
  89. strcasestr(r->instance_name, dacp_id_local) != NULL) {
  90. found_port = r->port;
  91. ESP_LOGI(TAG, "Matched DACP service: '%s' port %u", r->instance_name,
  92. found_port);
  93. break;
  94. }
  95. }
  96. mdns_query_results_free(results);
  97. }
  98. // Store discovered port under the lock
  99. xSemaphoreTake(s_mutex, portMAX_DELAY);
  100. s_dacp_port = found_port;
  101. if (found_port == 0) {
  102. s_discovery_failed = true;
  103. }
  104. xSemaphoreGive(s_mutex);
  105. if (found_port == 0) {
  106. ESP_LOGW(TAG, "DACP service not found for ID '%s' after %d attempts",
  107. dacp_id_local, MDNS_RETRY_COUNT);
  108. }
  109. }
  110. // Execute a DACP HTTP request synchronously. Only called from the worker task.
  111. static void execute_dacp_request(const char *path) {
  112. if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) != pdTRUE) {
  113. ESP_LOGW(TAG, "DACP mutex timeout for '%s'", path);
  114. return;
  115. }
  116. if (!s_session_valid || s_active_remote[0] == '\0') {
  117. xSemaphoreGive(s_mutex);
  118. ESP_LOGD(TAG, "DACP: no active session, skipping '%s'", path);
  119. return;
  120. }
  121. // Copy session state under the lock, then release before any I/O
  122. char active_remote_copy[ACTIVE_REMOTE_MAX];
  123. uint32_t client_ip_copy = s_client_ip;
  124. uint16_t port_copy = s_dacp_port;
  125. strlcpy(active_remote_copy, s_active_remote, sizeof(active_remote_copy));
  126. xSemaphoreGive(s_mutex);
  127. // If port not yet discovered, skip — the eager discovery task will
  128. // populate it. Never block the worker on mDNS.
  129. if (port_copy == 0) {
  130. ESP_LOGD(TAG, "DACP: port not ready, skipping '%s'", path);
  131. return;
  132. }
  133. // Build URL: http://<ip>:<port>/ctrl-int/1/<path>
  134. char url[128];
  135. uint8_t *ip = (uint8_t *)&client_ip_copy;
  136. snprintf(url, sizeof(url), "http://%u.%u.%u.%u:%u/ctrl-int/1/%s", ip[0],
  137. ip[1], ip[2], ip[3], port_copy, path);
  138. // Fire-and-forget HTTP GET
  139. esp_http_client_config_t config = {
  140. .url = url,
  141. .timeout_ms = HTTP_TIMEOUT_MS,
  142. .disable_auto_redirect = true,
  143. };
  144. esp_http_client_handle_t client = esp_http_client_init(&config);
  145. if (!client) {
  146. ESP_LOGW(TAG, "Failed to init HTTP client");
  147. return;
  148. }
  149. esp_http_client_set_header(client, "Active-Remote", active_remote_copy);
  150. esp_http_client_set_method(client, HTTP_METHOD_GET);
  151. esp_err_t err = esp_http_client_perform(client);
  152. if (err != ESP_OK) {
  153. ESP_LOGW(TAG, "DACP request failed: %s (%s)", path, esp_err_to_name(err));
  154. // Connection failed — invalidate port so next command re-discovers
  155. xSemaphoreTake(s_mutex, portMAX_DELAY);
  156. s_dacp_port = 0;
  157. xSemaphoreGive(s_mutex);
  158. } else {
  159. int status = esp_http_client_get_status_code(client);
  160. ESP_LOGI(TAG, "DACP: %s -> %d", path, status);
  161. if (status == 403 || status == 401) {
  162. ESP_LOGW(TAG, "DACP auth rejected — Active-Remote may be wrong");
  163. }
  164. }
  165. esp_http_client_cleanup(client);
  166. }
  167. // Worker task: drains the command queue and executes HTTP requests.
  168. // A sentinel command (CMD_DISCOVER) triggers mDNS discovery inline,
  169. // serialising discovery with HTTP commands and avoiding extra tasks.
  170. static void dacp_worker_task(void *pvParameters) {
  171. (void)pvParameters;
  172. char path[CMD_PATH_MAX];
  173. while (1) {
  174. if (xQueueReceive(s_cmd_queue, path, portMAX_DELAY) == pdTRUE) {
  175. if (path[0] == CMD_DISCOVER[0]) {
  176. discover_dacp_port();
  177. } else {
  178. execute_dacp_request(path);
  179. }
  180. }
  181. }
  182. }
  183. // Enqueue a command for the worker task. Returns immediately (non-blocking).
  184. static void send_dacp_request(const char *path) {
  185. if (!s_cmd_queue) {
  186. return;
  187. }
  188. char buf[CMD_PATH_MAX];
  189. strlcpy(buf, path, sizeof(buf));
  190. // Non-blocking: drop if queue is full (better than blocking caller)
  191. if (xQueueSend(s_cmd_queue, buf, 0) != pdTRUE) {
  192. ESP_LOGD(TAG, "DACP queue full, dropping '%s'", path);
  193. }
  194. }
  195. // ============================================================================
  196. // Public API
  197. // ============================================================================
  198. void dacp_init(void) {
  199. if (s_initialized) {
  200. return;
  201. }
  202. s_mutex = xSemaphoreCreateMutex();
  203. s_cmd_queue = xQueueCreate(CMD_QUEUE_LEN, CMD_PATH_MAX);
  204. xTaskCreate(dacp_worker_task, "dacp_wk", WORKER_STACK, NULL, 4,
  205. &s_worker_handle);
  206. s_initialized = true;
  207. ESP_LOGI(TAG, "DACP client initialized");
  208. }
  209. void dacp_set_session(const char *dacp_id, const char *active_remote,
  210. uint32_t client_ip) {
  211. xSemaphoreTake(s_mutex, portMAX_DELAY);
  212. const char *id = dacp_id ? dacp_id : "";
  213. const char *remote = active_remote ? active_remote : "";
  214. // If same DACP-ID, just update the active-remote token and IP in place.
  215. // No need to re-discover — the port doesn't change within a session.
  216. bool same_session = (strcmp(s_dacp_id, id) == 0 && s_session_valid);
  217. if (same_session) {
  218. strlcpy(s_active_remote, remote, sizeof(s_active_remote));
  219. s_client_ip = client_ip;
  220. xSemaphoreGive(s_mutex);
  221. return;
  222. }
  223. // New session — reset everything
  224. strlcpy(s_dacp_id, id, sizeof(s_dacp_id));
  225. strlcpy(s_active_remote, remote, sizeof(s_active_remote));
  226. s_client_ip = client_ip;
  227. s_dacp_port = 0;
  228. s_discovery_failed = false;
  229. s_session_valid = (s_dacp_id[0] != '\0' && s_active_remote[0] != '\0');
  230. bool valid = s_session_valid;
  231. xSemaphoreGive(s_mutex);
  232. if (valid) {
  233. ESP_LOGI(TAG, "DACP session: id=%s", id);
  234. // Kick off discovery on the worker task so the port is ready before
  235. // the user presses a button. Posting to front so it runs before any
  236. // queued commands; non-blocking drop is fine if queue is full.
  237. char sentinel[CMD_PATH_MAX] = {CMD_DISCOVER[0]};
  238. xQueueSendToFront(s_cmd_queue, sentinel, 0);
  239. }
  240. }
  241. void dacp_clear_session(void) {
  242. if (!s_initialized) {
  243. return;
  244. }
  245. xSemaphoreTake(s_mutex, portMAX_DELAY);
  246. s_dacp_id[0] = '\0';
  247. s_active_remote[0] = '\0';
  248. s_dacp_port = 0;
  249. s_session_valid = false;
  250. s_discovery_failed = false;
  251. xSemaphoreGive(s_mutex);
  252. ESP_LOGD(TAG, "DACP session cleared");
  253. }
  254. void dacp_send_playpause(void) {
  255. send_dacp_request("playpause");
  256. }
  257. void dacp_send_next(void) {
  258. send_dacp_request("nextitem");
  259. }
  260. void dacp_send_prev(void) {
  261. send_dacp_request("previtem");
  262. }
  263. void dacp_send_volume_up(void) {
  264. send_dacp_request("volumeup");
  265. }
  266. void dacp_send_volume_down(void) {
  267. send_dacp_request("volumedown");
  268. }
  269. void dacp_send_volume(float volume_percent) {
  270. if (volume_percent < 0.0f) {
  271. volume_percent = 0.0f;
  272. }
  273. if (volume_percent > 100.0f) {
  274. volume_percent = 100.0f;
  275. }
  276. char path[64];
  277. snprintf(path, sizeof(path), "setproperty?dmcp.volume=%.0f", volume_percent);
  278. send_dacp_request(path);
  279. }
  280. bool dacp_is_active(void) {
  281. if (!s_initialized) {
  282. return false;
  283. }
  284. bool active = false;
  285. if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
  286. active = s_session_valid;
  287. xSemaphoreGive(s_mutex);
  288. }
  289. return active;
  290. }
  291. bool dacp_probe_service(void) {
  292. if (!s_initialized) {
  293. return false;
  294. }
  295. char dacp_id_copy[DACP_ID_MAX];
  296. if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) != pdTRUE) {
  297. return false;
  298. }
  299. if (s_dacp_id[0] == '\0') {
  300. xSemaphoreGive(s_mutex);
  301. return false;
  302. }
  303. strlcpy(dacp_id_copy, s_dacp_id, sizeof(dacp_id_copy));
  304. xSemaphoreGive(s_mutex);
  305. mdns_result_t *results = NULL;
  306. esp_err_t err = mdns_query_ptr("_dacp", "_tcp", 2000, 8, &results);
  307. if (err != ESP_OK || !results) {
  308. return false;
  309. }
  310. bool found = false;
  311. for (mdns_result_t *r = results; r; r = r->next) {
  312. if (r->instance_name &&
  313. (strcasecmp(r->instance_name, dacp_id_copy) == 0 ||
  314. strcasestr(r->instance_name, dacp_id_copy) != NULL)) {
  315. found = true;
  316. break;
  317. }
  318. }
  319. mdns_query_results_free(results);
  320. return found;
  321. }