// plip_virtual — implementation. See plip_virtual.h for the contract. // // Threading: the ring tone runs in its own task (synthesised sine bursts on // the shared speaker I2S channel, French cadence 1.5 s on / 3.5 s off); hook // reports run in a tiny worker so the button/touch paths never block on // HTTP. The UI callback fires from whichever context transitions the state // (httpd, button task, LVGL touch) — the UI does its own locking. #include "plip_virtual.h" #include #include #include "cJSON.h" #include "esp_http_client.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" #include "freertos/task.h" #include "board_config.h" #ifndef CONFIG_ZACUS_MASTER_URL #define CONFIG_ZACUS_MASTER_URL "http://10.2.5.42" #endif #define TAG "plip_virtual" #define RING_FREQ_HZ 440.0f #define RING_ON_MS 1500 /* French cadence */ #define RING_OFF_MS 3500 #define RING_AMPLITUDE 14000.0f #define HOOK_QUEUE_DEPTH 4 #define DIAL_MAX 20 typedef struct { char state[8]; /* "off" | "on" */ char reason[32]; /* "pickup" | "hangup" | "ring_timeout" | "dial:" */ } hook_event_t; static esp_codec_dev_handle_t s_spk; static volatile plip_hook_state_t s_state = PLIP_HOOK_ON; static volatile bool s_ring_stop = false; static TaskHandle_t s_ring_task = NULL; static QueueHandle_t s_hook_queue = NULL; static plip_state_cb_t s_state_cb = NULL; static char s_dialed[DIAL_MAX + 1] = ""; static void set_state(plip_hook_state_t st) { s_state = st; if (s_state_cb) s_state_cb(st); } /* ---------- hook reporting (mirrors PLIP's zacus_hook_client) ---------- */ static void hook_report(const char *state, const char *reason) { if (!s_hook_queue) return; hook_event_t ev; strlcpy(ev.state, state, sizeof(ev.state)); strlcpy(ev.reason, reason, sizeof(ev.reason)); if (xQueueSend(s_hook_queue, &ev, 0) != pdTRUE) { ESP_LOGW(TAG, "hook queue full, dropping (%s/%s)", state, reason); } } static void hook_worker_task(void *arg) { (void) arg; hook_event_t ev; char url[160]; snprintf(url, sizeof(url), "%s/voice/hook", CONFIG_ZACUS_MASTER_URL); for (;;) { if (xQueueReceive(s_hook_queue, &ev, portMAX_DELAY) != pdTRUE) continue; char body[96]; snprintf(body, sizeof(body), "{\"state\":\"%s\",\"reason\":\"%s\"}", ev.state, ev.reason); esp_http_client_config_t cfg = { .url = url, .method = HTTP_METHOD_POST, .timeout_ms = 3000, }; esp_http_client_handle_t client = esp_http_client_init(&cfg); if (!client) continue; esp_http_client_set_header(client, "Content-Type", "application/json"); esp_http_client_set_post_field(client, body, strlen(body)); esp_err_t err = esp_http_client_perform(client); if (err == ESP_OK) { ESP_LOGI(TAG, "hook POST %s -> %d (%s/%s)", url, esp_http_client_get_status_code(client), ev.state, ev.reason); } else { ESP_LOGW(TAG, "hook POST failed: %s (%s/%s)", esp_err_to_name(err), ev.state, ev.reason); } esp_http_client_cleanup(client); } } /* ---------- ring tone ---------- */ static void ring_burst(int duration_ms) { const int total = AUDIO_SAMPLE_RATE * duration_ms / 1000; int16_t buffer[256]; int idx = 0; while (idx < total && !s_ring_stop) { int chunk = (total - idx < 256) ? (total - idx) : 256; for (int i = 0; i < chunk; i++) { float t = (float) (idx + i) / (float) AUDIO_SAMPLE_RATE; buffer[i] = (int16_t) (RING_AMPLITUDE * sinf(2.0f * (float) M_PI * RING_FREQ_HZ * t)); } if (s_spk) { esp_codec_dev_write(s_spk, buffer, chunk * sizeof(int16_t)); } idx += chunk; } } static void ring_task(void *arg) { int duration_ms = (int) (intptr_t) arg; int elapsed = 0; ESP_LOGI(TAG, "ring start (%d ms)", duration_ms); while (elapsed < duration_ms && !s_ring_stop) { ring_burst(RING_ON_MS); elapsed += RING_ON_MS; if (elapsed >= duration_ms || s_ring_stop) break; for (int w = 0; w < RING_OFF_MS && !s_ring_stop; w += 100) { vTaskDelay(pdMS_TO_TICKS(100)); } elapsed += RING_OFF_MS; } if (s_state == PLIP_HOOK_RINGING) { set_state(PLIP_HOOK_ON); /* nobody picked up */ if (!s_ring_stop) hook_report("on", "ring_timeout"); } ESP_LOGI(TAG, "ring end (state=%d)", (int) s_state); s_ring_task = NULL; vTaskDelete(NULL); } static void ring_stop(void) { s_ring_stop = true; /* The task observes the flag within one 100 ms slice and self-deletes. */ for (int i = 0; i < 20 && s_ring_task; i++) vTaskDelay(pdMS_TO_TICKS(50)); s_ring_stop = false; } /* ---------- REST handlers ---------- */ static esp_err_t send_json(httpd_req_t *req, const char *status, const char *body) { httpd_resp_set_status(req, status); httpd_resp_set_type(req, "application/json"); return httpd_resp_sendstr(req, body); } static esp_err_t handle_ring_post(httpd_req_t *req) { int duration_ms = 4000; if (req->content_len > 0 && req->content_len < 128) { char body[128]; int got = httpd_req_recv(req, body, req->content_len); if (got > 0) { body[got] = '\0'; cJSON *root = cJSON_Parse(body); const cJSON *d = root ? cJSON_GetObjectItem(root, "duration_ms") : NULL; if (cJSON_IsNumber(d) && d->valueint > 0 && d->valueint <= 60000) { duration_ms = d->valueint; } cJSON_Delete(root); } } if (s_state == PLIP_HOOK_OFF) { return send_json(req, "409 Conflict", "{\"error\":\"off-hook\"}"); } if (s_ring_task) ring_stop(); set_state(PLIP_HOOK_RINGING); if (xTaskCreate(ring_task, "plip_ring", 4096, (void *) (intptr_t) duration_ms, 4, &s_ring_task) != pdPASS) { set_state(PLIP_HOOK_ON); return send_json(req, "500 Internal Server Error", "{\"error\":\"ring task failed\"}"); } char resp[64]; snprintf(resp, sizeof(resp), "{\"ok\":true,\"duration_ms\":%d}", duration_ms); return send_json(req, "200 OK", resp); } static esp_err_t handle_stop_post(httpd_req_t *req) { if (s_ring_task) ring_stop(); if (s_state == PLIP_HOOK_RINGING) set_state(PLIP_HOOK_ON); return send_json(req, "200 OK", "{\"ok\":true}"); } static esp_err_t handle_play_post(httpd_req_t *req) { /* Audio on the BOX-3 flows through the voice-bridge WS TTS path; a file * player duplicating that would lie about capabilities. */ return send_json(req, "501 Not Implemented", "{\"error\":\"use the voice bridge TTS path\"}"); } static esp_err_t handle_status_get(httpd_req_t *req) { char resp[128]; snprintf(resp, sizeof(resp), "{\"off_hook\":%s,\"ringing\":%s,\"playing\":false," "\"dialed\":\"%s\"}", s_state == PLIP_HOOK_OFF ? "true" : "false", s_state == PLIP_HOOK_RINGING ? "true" : "false", s_dialed); return send_json(req, "200 OK", resp); } /* ---------- public API ---------- */ void plip_virtual_pickup(void) { if (s_state != PLIP_HOOK_RINGING) return; ring_stop(); s_dialed[0] = '\0'; set_state(PLIP_HOOK_OFF); ESP_LOGI(TAG, "virtual pickup"); hook_report("off", "pickup"); } void plip_virtual_hangup(void) { if (s_state != PLIP_HOOK_OFF) return; set_state(PLIP_HOOK_ON); ESP_LOGI(TAG, "virtual hangup"); hook_report("on", "hangup"); } void plip_virtual_dial(const char *number) { if (s_state != PLIP_HOOK_OFF || !number || !*number) return; strlcpy(s_dialed, number, sizeof(s_dialed)); char reason[32]; snprintf(reason, sizeof(reason), "dial:%s", s_dialed); ESP_LOGI(TAG, "dialed \"%s\"", s_dialed); hook_report("off", reason); } bool plip_virtual_button_press(void) { switch (s_state) { case PLIP_HOOK_RINGING: plip_virtual_pickup(); return true; case PLIP_HOOK_OFF: plip_virtual_hangup(); return true; default: return false; /* on-hook, not ringing: not ours */ } } void plip_virtual_register_state_cb(plip_state_cb_t cb) { s_state_cb = cb; } plip_hook_state_t plip_virtual_state(void) { return s_state; } esp_err_t plip_virtual_init(httpd_handle_t server, esp_codec_dev_handle_t spk) { if (!server || !spk) return ESP_ERR_INVALID_ARG; s_spk = spk; s_hook_queue = xQueueCreate(HOOK_QUEUE_DEPTH, sizeof(hook_event_t)); if (!s_hook_queue) return ESP_ERR_NO_MEM; if (xTaskCreate(hook_worker_task, "plip_hook", 4096, NULL, 3, NULL) != pdPASS) { vQueueDelete(s_hook_queue); s_hook_queue = NULL; return ESP_FAIL; } static const httpd_uri_t uri_ring = { .uri = "/ring", .method = HTTP_POST, .handler = handle_ring_post, .user_ctx = NULL, }; static const httpd_uri_t uri_stop = { .uri = "/stop", .method = HTTP_POST, .handler = handle_stop_post, .user_ctx = NULL, }; static const httpd_uri_t uri_play = { .uri = "/play", .method = HTTP_POST, .handler = handle_play_post, .user_ctx = NULL, }; static const httpd_uri_t uri_status = { .uri = "/status", .method = HTTP_GET, .handler = handle_status_get, .user_ctx = NULL, }; httpd_register_uri_handler(server, &uri_ring); httpd_register_uri_handler(server, &uri_stop); httpd_register_uri_handler(server, &uri_play); httpd_register_uri_handler(server, &uri_status); ESP_LOGI(TAG, "virtual PLIP up (POST /ring /stop /play, GET /status; " "BOOT button = hook switch; master=%s)", CONFIG_ZACUS_MASTER_URL); return ESP_OK; }