Files
ESP32_ZACUS/plip_voice/main/net.c
T
clement aa7ae277ed
CI / platformio (pull_request) Failing after 4m0s
CI / platformio (push) Failing after 14m58s
feat(plip): voice loop + DTMF + ring cadence
- hook polarity active-HIGH + auto-resync (was LOW)
- ring cadence FT 1.5s ON / 3.5s OFF
- DTMF Goertzel decoder (dtmf.c/h) + rotary debounce
- LISTEN half-duplex: capture → /v1/voice/reply → play
- WAV playback buffered PSRAM + mono→stereo upmix
- SPIFFS mount at boot for pre-loaded greetings
- ES8388: DAC digital vol + mic PGA + GPIO INPUT_OUTPUT
- turn_client multipart + 90s timeout + fixed routing
- debug endpoints: vol/dacvol/offhook/getfile/hookmon
2026-06-15 21:12:33 +02:00

802 lines
31 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* net.c — WiFi STA + minimal HTTP server for the PLIP voice annex.
*
* WiFi credentials: read from NVS namespace "wifi" (keys "ssid"/"pwd")
* first; fall back to Kconfig compile-time defaults if NVS is empty.
* This mirrors the pattern used in the idf_zacus master (main.c:load_wifi_creds).
*
* HTTP endpoints (Phase B):
* GET /status → { "ip":"...", "playing":false, "off_hook":false }
* POST /game/scenario → store Runtime 3 IR to SPIFFS (same as box3_voice)
* POST /game/file?path=... → write an arbitrary file to SPIFFS (Phase B proof)
*
* The httpd handle is exposed via net_httpd_handle() so other modules
* (hook_client, cmd_exec) can register additional routes.
*/
#include "net.h"
#include "dialer.h"
#include <inttypes.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
#include "audio.h"
#include "cmd_exec.h"
#include "phone.h"
#include "es8388.h"
#include "slic.h"
#include "board_config.h"
#include "driver/gpio.h"
#include "esp_err.h"
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_spiffs.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "cJSON.h"
#define TAG "net"
#define WIFI_CONNECT_TIMEOUT_MS 30000
#define WIFI_POLL_MS 250
#define SPIFFS_LABEL "storage"
#define SPIFFS_BASE "/spiffs"
#define MAX_BODY (256 * 1024)
static volatile bool s_connected = false;
static httpd_handle_t s_httpd = NULL;
static bool s_spiffs_ok = false;
/* ── NVS WiFi cred loader (mirrors idf_zacus main.c) ─────────────────────── */
static esp_err_t load_wifi_creds(char *ssid, size_t ssid_len,
char *pwd, size_t pwd_len)
{
nvs_handle_t h;
esp_err_t err = nvs_open("wifi", NVS_READONLY, &h);
if (err != ESP_OK) return err;
size_t sl = ssid_len, pl = pwd_len;
err = nvs_get_str(h, "ssid", ssid, &sl);
err |= nvs_get_str(h, "pwd", pwd, &pl);
nvs_close(h);
if (err != ESP_OK || ssid[0] == '\0') return ESP_ERR_NOT_FOUND;
return ESP_OK;
}
/* ── WiFi event handler ───────────────────────────────────────────────────── */
static void wifi_event_handler(void *arg, esp_event_base_t base,
int32_t event_id, void *data)
{
if (base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
if (!s_connected) return; /* still in initial connect loop */
ESP_LOGW(TAG, "WiFi disconnected, reconnecting...");
s_connected = false;
vTaskDelay(pdMS_TO_TICKS(2000));
esp_wifi_connect();
} else if (base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *ev = (ip_event_got_ip_t *)data;
ESP_LOGI(TAG, "WiFi connected — IP: " IPSTR, IP2STR(&ev->ip_info.ip));
s_connected = true;
}
}
/* ── SPIFFS lazy mount ────────────────────────────────────────────────────── */
static void ensure_spiffs(void)
{
if (s_spiffs_ok) return;
esp_vfs_spiffs_conf_t conf = {
.base_path = SPIFFS_BASE,
.partition_label = SPIFFS_LABEL,
.max_files = 8,
.format_if_mount_failed = true,
};
esp_err_t ret = esp_vfs_spiffs_register(&conf);
if (ret == ESP_OK || ret == ESP_ERR_INVALID_STATE) {
s_spiffs_ok = true;
ESP_LOGI(TAG, "SPIFFS mounted at %s", SPIFFS_BASE);
} else {
ESP_LOGE(TAG, "SPIFFS mount failed: %s", esp_err_to_name(ret));
}
}
/* ── HTTP helpers ─────────────────────────────────────────────────────────── */
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");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
return httpd_resp_sendstr(req, body);
}
/* ── GET /status ──────────────────────────────────────────────────────────── */
static esp_err_t handle_status(httpd_req_t *req)
{
char buf[128];
esp_netif_ip_info_t ip_info = {0};
esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (sta) esp_netif_get_ip_info(sta, &ip_info);
snprintf(buf, sizeof(buf),
"{\"board\":\"plip\",\"ip\":\"" IPSTR "\",\"playing\":false,\"off_hook\":false}",
IP2STR(&ip_info.ip));
return send_json(req, "200 OK", buf);
}
/* ── POST /game/scenario ──────────────────────────────────────────────────── */
static esp_err_t handle_scenario_post(httpd_req_t *req)
{
if (req->content_len <= 0 || req->content_len > MAX_BODY)
return send_json(req, "413 Payload Too Large", "{\"error\":\"too large\"}");
ensure_spiffs();
char *body = malloc((size_t)req->content_len + 1);
if (!body) return send_json(req, "500 Internal Server Error", "{\"error\":\"oom\"}");
int total = 0;
while (total < (int)req->content_len) {
int got = httpd_req_recv(req, body + total, req->content_len - total);
if (got <= 0) { free(body); return ESP_FAIL; }
total += got;
}
body[total] = '\0';
const char *path = SPIFFS_BASE "/scenario.json";
FILE *f = fopen(path, "wb");
if (!f) {
free(body);
return send_json(req, "500 Internal Server Error", "{\"error\":\"write failed\"}");
}
fwrite(body, 1, (size_t)total, f);
fclose(f);
free(body);
char resp[128];
snprintf(resp, sizeof(resp),
"{\"status\":\"ok\",\"board\":\"plip\",\"bytes\":%d}", total);
ESP_LOGI(TAG, "scenario stored: %d bytes", total);
return send_json(req, "200 OK", resp);
}
/* ── POST /game/file?path=... (Phase B proof: write file to SPIFFS) ─────── */
/* Streaming chunk size for /game/file — keeps heap usage under 4 KB. */
#define FILE_RECV_CHUNK 2048
static esp_err_t handle_file_post(httpd_req_t *req)
{
/* Extract ?path= query parameter */
char query[256] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char fpath[128] = {0};
if (httpd_query_key_value(query, "path", fpath, sizeof(fpath)) != ESP_OK) {
return send_json(req, "400 Bad Request", "{\"error\":\"missing path param\"}");
}
if (req->content_len <= 0 || req->content_len > MAX_BODY)
return send_json(req, "413 Payload Too Large", "{\"error\":\"too large\"}");
ensure_spiffs();
/* Build full SPIFFS path. */
char full[160];
if (fpath[0] == '/') {
snprintf(full, sizeof(full), "%s%s", SPIFFS_BASE, fpath);
} else {
snprintf(full, sizeof(full), "%s/%s", SPIFFS_BASE, fpath);
}
FILE *f = fopen(full, "wb");
if (!f) {
ESP_LOGW(TAG, "fopen %s failed: errno=%d", full, errno);
return send_json(req, "500 Internal Server Error", "{\"error\":\"write failed\"}");
}
/* Stream body to file using a small stack buffer — no large malloc needed. */
char chunk[FILE_RECV_CHUNK];
int remaining = (int)req->content_len;
size_t written = 0;
while (remaining > 0) {
int to_recv = (remaining < FILE_RECV_CHUNK) ? remaining : FILE_RECV_CHUNK;
int got = httpd_req_recv(req, chunk, to_recv);
if (got <= 0) {
fclose(f);
return ESP_FAIL;
}
size_t fw = fwrite(chunk, 1, (size_t)got, f);
if ((int)fw != got) {
ESP_LOGW(TAG, "fwrite short: %zu/%d (disk full?)", fw, got);
fclose(f);
return send_json(req, "500 Internal Server Error", "{\"error\":\"write failed\"}");
}
written += fw;
remaining -= got;
}
fclose(f);
char resp[256];
snprintf(resp, sizeof(resp),
"{\"status\":\"ok\",\"path\":\"%s\",\"bytes\":%zu}", full, written);
ESP_LOGI(TAG, "file stored: %s (%zu bytes)", full, written);
return send_json(req, "200 OK", resp);
}
/* ── POST /voice/capture ──────────────────────────────────────────────────── */
#define CAP_MAX_MS_DEFAULT 5000
#define CAP_SILENCE_MS_DEFAULT 800
/* Max capture duration: 10 s (no large buffer needed — streaming approach). */
#define CAP_MAX_ALLOWED_MS 10000
/*
* Streaming WAV capture handler.
*
* Memory budget: only two small buffers on stack:
* - WAV header : 44 bytes
* - mono frame : 320 × 2 = 640 bytes
* The audio driver uses an additional 1280-byte scratch buf for the stereo I2S read.
* Total heap usage during capture: ~2 KB (vs 128+ KB for buffer-at-once approach).
*
* The response uses chunked transfer encoding via httpd_resp_send_chunk().
* We send the WAV header first (with data_size = 0xFFFFFFFF as placeholder since
* length is unknown), then PCM frames, then finalize with httpd_resp_send_chunk(NULL,0).
* Most WAV players / ffmpeg handle the placeholder length gracefully.
*/
static esp_err_t handle_voice_capture(httpd_req_t *req)
{
/* Parse optional query params: max_ms, silence_ms */
char query[128] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char val[16] = {0};
int max_ms = CAP_MAX_MS_DEFAULT;
int silence_ms = CAP_SILENCE_MS_DEFAULT;
if (httpd_query_key_value(query, "max_ms", val, sizeof(val)) == ESP_OK) {
int v = atoi(val);
if (v > 0 && v <= CAP_MAX_ALLOWED_MS) max_ms = v;
else if (v > CAP_MAX_ALLOWED_MS) max_ms = CAP_MAX_ALLOWED_MS;
}
memset(val, 0, sizeof(val));
if (httpd_query_key_value(query, "silence_ms", val, sizeof(val)) == ESP_OK) {
int v = atoi(val);
if (v > 0 && v <= 5000) silence_ms = v;
}
ESP_LOGI(TAG, "POST /voice/capture max_ms=%d silence_ms=%d (streaming)", max_ms, silence_ms);
/* Start capture (enables I2S RX, disables TX). */
if (audio_capture_begin(max_ms, silence_ms) != 0) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "capture init failed");
return ESP_FAIL;
}
/* Send HTTP headers. */
httpd_resp_set_type(req, "audio/wav");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
/* WAV header with placeholder data_size (0xFFFFFFFF = unknown length). */
const uint32_t cap_rate = 16000;
const uint16_t cap_ch = 1;
const uint16_t cap_bits = 16;
const uint32_t byte_rate = cap_rate * cap_ch * (cap_bits / 8);
const uint16_t block_align = (uint16_t)(cap_ch * (cap_bits / 8));
const uint32_t data_size = 0xFFFFFFFFu; /* placeholder, updated below if possible */
const uint32_t riff_size = 0xFFFFFFFFu;
uint16_t fmt_pcm = 1;
uint32_t fmt_size = 16;
uint8_t hdr[44];
memcpy(hdr + 0, "RIFF", 4); memcpy(hdr + 4, &riff_size, 4);
memcpy(hdr + 8, "WAVE", 4); memcpy(hdr + 12, "fmt ", 4);
memcpy(hdr + 16, &fmt_size, 4); memcpy(hdr + 20, &fmt_pcm, 2);
memcpy(hdr + 22, &cap_ch, 2); memcpy(hdr + 24, &cap_rate, 4);
memcpy(hdr + 28, &byte_rate, 4); memcpy(hdr + 32, &block_align, 2);
memcpy(hdr + 34, &cap_bits, 2);
memcpy(hdr + 36, "data", 4); memcpy(hdr + 40, &data_size, 4);
esp_err_t ret = httpd_resp_send_chunk(req, (const char *)hdr, sizeof(hdr));
if (ret != ESP_OK) { audio_capture_end(); return ESP_FAIL; }
/* VAD state (mirrors audio_capture_wav logic). */
const int64_t vad_onset_sq = (int64_t)328 * 328;
const int64_t vad_silence_sq = (int64_t)100 * 100;
const int max_frames = max_ms / 20;
const int silence_frames = silence_ms / 20;
bool voice_started = false;
int silent_count = 0;
int total_frames = 0;
size_t total_pcm_bytes = 0;
/* Per-frame mono buffer: 320 × int16_t = 640 bytes on stack. */
int16_t mono_frame[320];
for (int f = 0; f < max_frames; f++) {
int64_t rms_sq = 0;
int n = audio_capture_read_frame(mono_frame, 320, &rms_sq);
if (n < 0) { ESP_LOGW(TAG, "capture: read error at frame %d", f); break; }
if (n == 0) continue; /* timeout, retry */
/* Send PCM chunk. */
size_t frame_bytes = (size_t)n * sizeof(int16_t);
ret = httpd_resp_send_chunk(req, (const char *)mono_frame, (ssize_t)frame_bytes);
if (ret != ESP_OK) { ESP_LOGW(TAG, "capture: send_chunk failed"); break; }
total_pcm_bytes += frame_bytes;
total_frames = f + 1;
/* VAD. */
if (!voice_started) {
if (rms_sq >= vad_onset_sq) {
voice_started = true;
silent_count = 0;
ESP_LOGI(TAG, "capture: voice onset frame %d rms²=%"PRId64, f, rms_sq);
}
} else {
if (rms_sq < vad_silence_sq) {
if (++silent_count >= silence_frames) {
ESP_LOGI(TAG, "capture: VAD end frame %d", f);
break;
}
} else {
silent_count = 0;
}
}
}
audio_capture_end();
/* Finalize chunked transfer. */
httpd_resp_send_chunk(req, NULL, 0);
float dur = (float)total_pcm_bytes / (float)(cap_rate * (cap_bits / 8) * cap_ch);
ESP_LOGI(TAG, "capture done: %d frames %.2fs %zu PCM bytes sent", total_frames, dur, total_pcm_bytes);
return ESP_OK;
}
/* ── GET /debug/regs (ES8388 register dump for ADC diagnosis) ─────────────── */
static esp_err_t handle_debug_regs(httpd_req_t *req)
{
/* Read key ES8388 registers to diagnose ADC path. */
uint8_t regs[0x40] = {0};
for (int i = 0; i < 0x40; i++) {
es8388_read_reg((uint8_t)i, &regs[i]);
}
char buf[1024];
/* All register names use Espressif official numbering (ADCCONTROL1=reg0x09, etc.) */
int n = snprintf(buf, sizeof(buf),
"{\"CTL1\":\"0x%02X\",\"CTL2\":\"0x%02X\",\"CHIPPOWER\":\"0x%02X\","
"\"ADCPOWER\":\"0x%02X\",\"DACPOWER\":\"0x%02X\","
"\"ADCCONTROL1\":\"0x%02X\",\"ADCCONTROL2\":\"0x%02X\",\"ADCCONTROL3\":\"0x%02X\","
"\"ADCCONTROL4\":\"0x%02X\",\"ADCCONTROL5\":\"0x%02X\",\"ADCCONTROL6\":\"0x%02X\","
"\"ADCCONTROL7\":\"0x%02X\",\"ADCCONTROL8\":\"0x%02X\",\"ADCCONTROL9\":\"0x%02X\","
"\"DACCTL1\":\"0x%02X\",\"DACCTL2\":\"0x%02X\",\"DACCTL3\":\"0x%02X\","
"\"DACCTL21\":\"0x%02X\",\"DACCTL24\":\"0x%02X\",\"DACCTL25\":\"0x%02X\","
"\"MASTERMODE\":\"0x%02X\",\"CHIPCTL3\":\"0x%02X\",\"CHIPLP\":\"0x%02X\"}",
regs[0x00], regs[0x01], regs[0x02],
regs[0x03], regs[0x04],
regs[0x09], regs[0x0A], regs[0x0B],
regs[0x0C], regs[0x0D], regs[0x0E],
regs[0x0F], regs[0x10], regs[0x11],
regs[0x17], regs[0x18], regs[0x19],
regs[0x2B], regs[0x2E], regs[0x2F],
regs[0x08], regs[0x06], regs[0x05]);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
return httpd_resp_send(req, buf, n);
}
/* ── POST /game/cmd (WiFi-direct CMD endpoint) ───────────────────────────── */
#define MAX_CMD_BYTES 512
static esp_err_t handle_cmd_post(httpd_req_t *req)
{
if (req->content_len <= 0 || req->content_len > MAX_CMD_BYTES)
return send_json(req, "400 Bad Request", "{\"error\":\"body must be 1..512 bytes\"}");
char body[MAX_CMD_BYTES + 1];
int total = 0;
while (total < (int)req->content_len) {
int got = httpd_req_recv(req, body + total, req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
return send_json(req, "400 Bad Request", "{\"error\":\"recv failed\"}");
}
total += got;
}
body[total] = '\0';
ESP_LOGI(TAG, "POST /game/cmd (%d B): %.*s", total, total < 80 ? total : 80, body);
esp_err_t err = cmd_exec_handle(body, (size_t)total);
if (err == ESP_ERR_INVALID_ARG)
return send_json(req, "400 Bad Request", "{\"error\":\"malformed cmd json or missing op\"}");
return send_json(req, "200 OK", "{\"ok\":true}");
}
/* ── GET /debug/ring (trigger ring tone over HTTP) ──────────────────────── */
static esp_err_t handle_debug_ring(httpd_req_t *req)
{
phone_ring_start();
ESP_LOGI(TAG, "debug/ring: ring started");
return send_json(req, "200 OK", "{\"ok\":true,\"ringing\":true}");
}
/* ── GET /debug/ringstop (stop ring tone over HTTP) ─────────────────────── */
static esp_err_t handle_debug_ringstop(httpd_req_t *req)
{
phone_ring_stop();
ESP_LOGI(TAG, "debug/ringstop: ring stopped");
return send_json(req, "200 OK", "{\"ok\":true,\"ringing\":false}");
}
/* ── GET /debug/slic (read GPIO levels of all 4 SLIC pins) ──────────────── */
static esp_err_t handle_debug_slic(httpd_req_t *req)
{
char buf[192];
/* gpio_get_level reads the actual pad level even for output pins. */
snprintf(buf, sizeof(buf),
"{\"rm\":%d,\"fr\":%d,\"shk\":%d,\"pd\":%d,\"offhook\":%s,\"ringing\":%s}",
gpio_get_level(PLIP_SLIC_RM), gpio_get_level(PLIP_SLIC_FR),
gpio_get_level(PLIP_SLIC_SHK), gpio_get_level(PLIP_SLIC_PD),
slic_is_offhook() ? "true" : "false",
slic_is_ringing() ? "true" : "false");
ESP_LOGI(TAG, "debug/slic: %s", buf);
return send_json(req, "200 OK", buf);
}
/* ── GET /debug/hookmon (fast-sample the hook GPIO, log transitions) ─────────
* Samples CONFIG_PLIP_HOOK_GPIO every 2 ms for ~6 s and records every level
* transition with a timestamp. Catches both slow hook toggles AND fast rotary
* pulses (~60-100 ms) that the 0.7 s HTTP poll of /debug/slic cannot see.
* The user performs the physical action (lift/hang/dial) during the window. */
#define HOOKMON_SAMPLE_MS 2
#define HOOKMON_WINDOW_MS 6000
#define HOOKMON_MAX_EDGES 64
static esp_err_t handle_debug_hookmon(httpd_req_t *req)
{
const int gpio = CONFIG_PLIP_HOOK_GPIO;
int last = gpio_get_level(gpio);
const int initial = last;
int64_t t0 = esp_timer_get_time();
int edges_t[HOOKMON_MAX_EDGES];
int edges_l[HOOKMON_MAX_EDGES];
int n_edges = 0;
int lo = last, hi = last; /* track min/max seen */
ESP_LOGI(TAG, "hookmon: start, gpio=%d initial=%d (sample %dms / window %dms)",
gpio, initial, HOOKMON_SAMPLE_MS, HOOKMON_WINDOW_MS);
for (;;) {
int64_t now = esp_timer_get_time();
int dt = (int)((now - t0) / 1000);
if (dt >= HOOKMON_WINDOW_MS) break;
int lvl = gpio_get_level(gpio);
if (lvl < lo) lo = lvl;
if (lvl > hi) hi = lvl;
if (lvl != last) {
if (n_edges < HOOKMON_MAX_EDGES) {
edges_t[n_edges] = dt;
edges_l[n_edges] = lvl;
n_edges++;
}
last = lvl;
}
vTaskDelay(pdMS_TO_TICKS(HOOKMON_SAMPLE_MS));
}
/* Build JSON: { gpio, initial, final, lo, hi, edges:[{t,l},...], count } */
char buf[1024];
int off = 0;
off += snprintf(buf + off, sizeof(buf) - off,
"{\"gpio\":%d,\"initial\":%d,\"final\":%d,\"lo\":%d,\"hi\":%d,"
"\"count\":%d,\"edges\":[",
gpio, initial, last, lo, hi, n_edges);
for (int i = 0; i < n_edges && off < (int)sizeof(buf) - 32; i++) {
off += snprintf(buf + off, sizeof(buf) - off, "%s{\"t\":%d,\"l\":%d}",
i ? "," : "", edges_t[i], edges_l[i]);
}
off += snprintf(buf + off, sizeof(buf) - off, "]}");
ESP_LOGI(TAG, "hookmon: done, %d edges, lo=%d hi=%d", n_edges, lo, hi);
return send_json(req, "200 OK", buf);
}
/* ── GET /debug/dacvol?a=N (set ES8388 DIGITAL DAC volume, live tuning) ─────── */
static esp_err_t handle_debug_dacvol(httpd_req_t *req)
{
char query[32] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char astr[8] = {0};
if (httpd_query_key_value(query, "a", astr, sizeof(astr)) != ESP_OK) {
return send_json(req, "400 Bad Request", "{\"error\":\"missing a param (atten steps, 0=0dB)\"}");
}
int a = atoi(astr);
if (a < 0) a = 0;
if (a > 192) a = 192;
esp_err_t r = es8388_set_dac_volume((uint8_t)a);
char resp[80];
snprintf(resp, sizeof(resp), "{\"ok\":%s,\"atten_steps\":%d,\"db\":-%.1f}",
(r == ESP_OK) ? "true" : "false", a, a * 0.5);
ESP_LOGI(TAG, "debug/dacvol: atten=%d (-%.1f dB)", a, a * 0.5);
return send_json(req, "200 OK", resp);
}
/* ── GET /debug/offhook?on=1 (force hook state, bypass flaky contact) ───────── */
static esp_err_t handle_debug_offhook(httpd_req_t *req)
{
char query[24] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char on[4] = {0};
int v = 1; /* default: force off-hook */
if (httpd_query_key_value(query, "on", on, sizeof(on)) == ESP_OK) v = atoi(on);
phone_force_offhook(v != 0);
char resp[64];
snprintf(resp, sizeof(resp), "{\"ok\":true,\"forced_offhook\":%s}", v ? "true" : "false");
ESP_LOGI(TAG, "debug/offhook: forced %s", v ? "off-hook" : "on-hook");
return send_json(req, "200 OK", resp);
}
/* ── GET /debug/getfile?path=/x.wav (read a SPIFFS file back for diagnosis) ── */
static esp_err_t handle_debug_getfile(httpd_req_t *req)
{
char query[160] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char fpath[96] = {0};
if (httpd_query_key_value(query, "path", fpath, sizeof(fpath)) != ESP_OK) {
return send_json(req, "400 Bad Request", "{\"error\":\"missing path param\"}");
}
ensure_spiffs();
char full[160];
if (fpath[0] == '/') snprintf(full, sizeof(full), "%s%s", SPIFFS_BASE, fpath);
else snprintf(full, sizeof(full), "%s/%s", SPIFFS_BASE, fpath);
FILE *f = fopen(full, "rb");
if (!f) return send_json(req, "404 Not Found", "{\"error\":\"not found\"}");
httpd_resp_set_type(req, "application/octet-stream");
char chunk[1024];
size_t r;
while ((r = fread(chunk, 1, sizeof(chunk), f)) > 0) {
if (httpd_resp_send_chunk(req, chunk, r) != ESP_OK) { fclose(f); return ESP_FAIL; }
}
fclose(f);
httpd_resp_send_chunk(req, NULL, 0); /* terminate */
return ESP_OK;
}
/* ── GET /debug/vol?v=N (set ES8388 output volume 0..100, live tuning) ─────── */
static esp_err_t handle_debug_vol(httpd_req_t *req)
{
char query[32] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char vstr[8] = {0};
if (httpd_query_key_value(query, "v", vstr, sizeof(vstr)) != ESP_OK) {
return send_json(req, "400 Bad Request", "{\"error\":\"missing v param (0..100)\"}");
}
int v = atoi(vstr);
if (v < 0) v = 0;
if (v > 100) v = 100;
esp_err_t r = es8388_set_volume((uint8_t)v);
char resp[64];
snprintf(resp, sizeof(resp), "{\"ok\":%s,\"volume\":%d}", (r == ESP_OK) ? "true" : "false", v);
ESP_LOGI(TAG, "debug/vol: set volume=%d (%s)", v, (r == ESP_OK) ? "ok" : "err");
return send_json(req, "200 OK", resp);
}
/* ── GET /debug/dial?number=NNNN (push digits into the dialer) ───────────── */
static esp_err_t handle_debug_dial(httpd_req_t *req)
{
char query[64] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char number[16] = {0};
if (httpd_query_key_value(query, "number", number, sizeof(number)) != ESP_OK) {
return send_json(req, "400 Bad Request", "{\"error\":\"missing number param\"}");
}
int pushed = 0;
for (int i = 0; number[i] != '\0' && i < 12; i++) {
if (number[i] >= '0' && number[i] <= '9') {
dialer_push_digit(number[i] - '0');
pushed++;
}
}
char resp[64];
snprintf(resp, sizeof(resp), "{\"ok\":true,\"pushed\":%d,\"number\":\"%s\"}",
pushed, dialer_current());
ESP_LOGI(TAG, "debug/dial: pushed %d digits -> \"%s\"", pushed, dialer_current());
return send_json(req, "200 OK", resp);
}
/* ── Public API ───────────────────────────────────────────────────────────── */
bool net_is_connected(void)
{
return s_connected;
}
httpd_handle_t net_httpd_handle(void)
{
return s_httpd;
}
esp_err_t net_init(void)
{
/* WiFi credentials: NVS first, then Kconfig compile-time defaults. */
char ssid[64] = CONFIG_PLIP_WIFI_SSID;
char pwd[64] = CONFIG_PLIP_WIFI_PASSWORD;
char nvs_ssid[64] = {0}, nvs_pwd[64] = {0};
if (load_wifi_creds(nvs_ssid, sizeof(nvs_ssid),
nvs_pwd, sizeof(nvs_pwd)) == ESP_OK) {
strncpy(ssid, nvs_ssid, sizeof(ssid) - 1);
strncpy(pwd, nvs_pwd, sizeof(pwd) - 1);
ESP_LOGI(TAG, "WiFi creds from NVS: SSID=\"%s\"", ssid);
} else {
ESP_LOGI(TAG, "WiFi creds from Kconfig: SSID=\"%s\"", ssid);
}
/* Network stack init. */
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL, NULL));
wifi_config_t wcfg = {
.sta = {
.threshold.authmode = (strlen(pwd) == 0)
? WIFI_AUTH_OPEN : WIFI_AUTH_WPA2_PSK,
.channel = CONFIG_PLIP_WIFI_CHANNEL,
},
};
strncpy((char *)wcfg.sta.ssid, ssid, sizeof(wcfg.sta.ssid) - 1);
strncpy((char *)wcfg.sta.password, pwd, sizeof(wcfg.sta.password) - 1);
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wcfg));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "WiFi STA started, connecting to '%s' (ch=%d)...",
ssid, CONFIG_PLIP_WIFI_CHANNEL);
/* Wait for IP with timeout. */
const uint32_t deadline = xTaskGetTickCount() +
pdMS_TO_TICKS(WIFI_CONNECT_TIMEOUT_MS);
while (!s_connected && xTaskGetTickCount() < deadline) {
vTaskDelay(pdMS_TO_TICKS(WIFI_POLL_MS));
}
if (!s_connected) {
ESP_LOGW(TAG, "WiFi connect timeout — continuing offline");
return ESP_ERR_TIMEOUT;
}
/* Start HTTP server. */
httpd_config_t hcfg = HTTPD_DEFAULT_CONFIG();
hcfg.server_port = 80;
hcfg.max_uri_handlers = 16;
hcfg.stack_size = 8192;
esp_err_t ret = httpd_start(&s_httpd, &hcfg);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "httpd_start: %s", esp_err_to_name(ret));
return ret;
}
static const httpd_uri_t uri_status = {
.uri = "/status", .method = HTTP_GET,
.handler = handle_status, .user_ctx = NULL,
};
static const httpd_uri_t uri_scenario = {
.uri = "/game/scenario", .method = HTTP_POST,
.handler = handle_scenario_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_file = {
.uri = "/game/file", .method = HTTP_POST,
.handler = handle_file_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_cmd = {
.uri = "/game/cmd", .method = HTTP_POST,
.handler = handle_cmd_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_capture = {
.uri = "/voice/capture", .method = HTTP_POST,
.handler = handle_voice_capture, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_regs = {
.uri = "/debug/regs", .method = HTTP_GET,
.handler = handle_debug_regs, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_dial = {
.uri = "/debug/dial", .method = HTTP_GET,
.handler = handle_debug_dial, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_vol = {
.uri = "/debug/vol", .method = HTTP_GET,
.handler = handle_debug_vol, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_getfile = {
.uri = "/debug/getfile", .method = HTTP_GET,
.handler = handle_debug_getfile, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_dacvol = {
.uri = "/debug/dacvol", .method = HTTP_GET,
.handler = handle_debug_dacvol, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_offhook = {
.uri = "/debug/offhook", .method = HTTP_GET,
.handler = handle_debug_offhook, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_ring = {
.uri = "/debug/ring", .method = HTTP_GET,
.handler = handle_debug_ring, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_ringstop = {
.uri = "/debug/ringstop", .method = HTTP_GET,
.handler = handle_debug_ringstop, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_slic = {
.uri = "/debug/slic", .method = HTTP_GET,
.handler = handle_debug_slic, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_hookmon = {
.uri = "/debug/hookmon", .method = HTTP_GET,
.handler = handle_debug_hookmon, .user_ctx = NULL,
};
httpd_register_uri_handler(s_httpd, &uri_status);
httpd_register_uri_handler(s_httpd, &uri_scenario);
httpd_register_uri_handler(s_httpd, &uri_file);
httpd_register_uri_handler(s_httpd, &uri_cmd);
httpd_register_uri_handler(s_httpd, &uri_capture);
httpd_register_uri_handler(s_httpd, &uri_debug_regs);
httpd_register_uri_handler(s_httpd, &uri_debug_dial);
httpd_register_uri_handler(s_httpd, &uri_debug_vol);
httpd_register_uri_handler(s_httpd, &uri_debug_getfile);
httpd_register_uri_handler(s_httpd, &uri_debug_dacvol);
httpd_register_uri_handler(s_httpd, &uri_debug_offhook);
httpd_register_uri_handler(s_httpd, &uri_debug_ring);
httpd_register_uri_handler(s_httpd, &uri_debug_ringstop);
httpd_register_uri_handler(s_httpd, &uri_debug_slic);
httpd_register_uri_handler(s_httpd, &uri_debug_hookmon);
ESP_LOGI(TAG, "httpd up on :80 (GET /status, GET /debug/regs, GET /debug/dial, GET /debug/ring, GET /debug/ringstop, GET /debug/slic, POST /game/scenario, POST /game/file, POST /game/cmd, POST /voice/capture)");
return ESP_OK;
}