3c0eb75465
STA config: channel=0 (all-channel scan), WIFI_ALL_CHANNEL_SCAN, WIFI_CONNECT_AP_BY_SIGNAL, failure_retry_cnt=5. Disconnect handler: retry on every disconnect event, including during initial association — previously the first failure at boot caused an immediate abort and a ~30s timeout before IP was acquired. Validated on hardware: connects reliably on ch1, RSSI -32, IP in ~2.5s.
814 lines
32 KiB
C
814 lines
32 KiB
C
/*
|
||
* 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) {
|
||
/* Retry on EVERY disconnect, including failures during the initial boot
|
||
* attempt: on a mesh the first association frequently fails (wrong node
|
||
* picked / channel roam) and must be retried, otherwise the boot connect
|
||
* just times out. Previously the initial loop gave up on first failure. */
|
||
s_connected = false;
|
||
ESP_LOGW(TAG, "WiFi disconnected/assoc failed, reconnecting...");
|
||
vTaskDelay(pdMS_TO_TICKS(1500));
|
||
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, ®s[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,
|
||
/* Mesh-friendly association. A fixed .channel breaks on mesh APs
|
||
* (Freebox/Orange "Les cils") that roam channels via band-steering
|
||
* and DFS: if the node isn't on that channel at boot, association
|
||
* silently fails. So scan ALL channels (channel=0) and connect to
|
||
* the STRONGEST node broadcasting our SSID. The PLIP talks HTTP to
|
||
* the gateway, not ESP-NOW, so there is no co-channel constraint. */
|
||
.channel = 0,
|
||
.scan_method = WIFI_ALL_CHANNEL_SCAN,
|
||
.sort_method = WIFI_CONNECT_AP_BY_SIGNAL,
|
||
.failure_retry_cnt = 5,
|
||
},
|
||
};
|
||
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;
|
||
}
|