Merge pull request 'feat(plip): ESP-IDF migration — ES8388 audio + WiFi/httpd + cmd_exec + hook/ring' (#13) from feat/plip-idf-migration into main
CI / platformio (push) Failing after 5m8s
CI / platformio (push) Failing after 5m8s
This commit was merged in pull request #13.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
# scenario_mesh is shared with box3_voice — single copy in lib/ so the
|
||||
# ESP-NOW protocol stays in sync with the master and other annexes.
|
||||
set(EXTRA_COMPONENT_DIRS ../lib/scenario_mesh)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
project(zacus-plip-voice)
|
||||
@@ -0,0 +1,24 @@
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"main.c"
|
||||
"es8388.c"
|
||||
"audio.c"
|
||||
"net.c"
|
||||
"cmd_exec.c"
|
||||
"hook_client.c"
|
||||
"phone.c"
|
||||
INCLUDE_DIRS "."
|
||||
PRIV_REQUIRES
|
||||
driver
|
||||
esp_event
|
||||
esp_http_client
|
||||
esp_http_server
|
||||
esp_netif
|
||||
esp_wifi
|
||||
json
|
||||
nvs_flash
|
||||
spiffs
|
||||
fatfs
|
||||
sdmmc
|
||||
scenario_mesh
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
menu "PLIP Voice Configuration"
|
||||
|
||||
config PLIP_WIFI_SSID
|
||||
string "WiFi SSID"
|
||||
default "zacus-net"
|
||||
help
|
||||
WiFi network SSID. Credentials can also be stored in NVS
|
||||
(namespace "wifi", keys "ssid"/"pwd") — NVS takes precedence.
|
||||
|
||||
config PLIP_WIFI_PASSWORD
|
||||
string "WiFi Password"
|
||||
default ""
|
||||
help
|
||||
WiFi password. Leave empty for open networks.
|
||||
|
||||
config PLIP_WIFI_CHANNEL
|
||||
int "WiFi channel hint (0 = auto)"
|
||||
default 11
|
||||
range 0 13
|
||||
help
|
||||
Bias the STA scan to start on this channel. Must match the
|
||||
master ESP32's connected channel for ESP-NOW co-channel
|
||||
operation. Lab network uses channel 11.
|
||||
|
||||
config PLIP_MASTER_URL
|
||||
string "Zacus Master Base URL"
|
||||
default "http://192.168.0.188"
|
||||
help
|
||||
Base URL of the Zacus master ESP32 (Freenove board). The PLIP
|
||||
reports hook transitions to <url>/voice/hook.
|
||||
|
||||
config PLIP_SPEAKER_VOLUME
|
||||
int "Default Speaker Volume (0-100)"
|
||||
default 70
|
||||
range 0 100
|
||||
help
|
||||
Default speaker output volume at boot.
|
||||
|
||||
config PLIP_HOOK_GPIO
|
||||
int "Off-hook GPIO number"
|
||||
default 4
|
||||
help
|
||||
GPIO that signals handset off-hook (active LOW via INPUT_PULLUP).
|
||||
Dev kit uses BOOT button (GPIO4 / KEY1). PCB uses Si3210 INT.
|
||||
|
||||
endmenu
|
||||
@@ -0,0 +1,443 @@
|
||||
/*
|
||||
* audio.c — I2S + ES8388 audio driver for the PLIP voice annex (Phase A/D).
|
||||
*
|
||||
* Init order:
|
||||
* 1. es8388_init() — I2C master + codec register sequence
|
||||
* 2. i2s_new_channel() — allocate TX (speaker) + RX (mic) channels
|
||||
* 3. i2s_channel_init_std_mode() — configure Philips 16-bit stereo @ 16kHz
|
||||
* 4. i2s_channel_enable()
|
||||
* 5. gpio PA_ENABLE = 1 (already done by es8388_init)
|
||||
*
|
||||
* Threading:
|
||||
* - audio_play_tone() is blocking — call from a task, not the I2S callback.
|
||||
* - audio_play_async() posts to a FreeRTOS queue; a worker task drains it.
|
||||
* - The I2S handle is published via audio_spk_handle() for cmd_exec.
|
||||
*/
|
||||
|
||||
#include "audio.h"
|
||||
#include "board_config.h"
|
||||
#include "es8388.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_vfs_fat.h"
|
||||
#include "driver/i2s_std.h"
|
||||
#include "driver/sdspi_host.h"
|
||||
#include "driver/spi_common.h"
|
||||
#include "sdmmc_cmd.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define TAG "audio"
|
||||
|
||||
/* ── Audio parameters ────────────────────────────────────────────────────── */
|
||||
#define SAMPLE_RATE PLIP_SAMPLE_RATE /* 16000 */
|
||||
#define BITS PLIP_BITS_PER_SAMPLE /* 16 */
|
||||
#define CHANNELS PLIP_CHANNELS /* 2 = stereo (ES8388 I2S) */
|
||||
|
||||
/* ── WAV header ──────────────────────────────────────────────────────────── */
|
||||
#define WAV_MIN_HEADER 44
|
||||
|
||||
typedef struct {
|
||||
uint16_t audio_format;
|
||||
uint16_t channels;
|
||||
uint32_t sample_rate;
|
||||
uint16_t bits_per_sample;
|
||||
uint32_t data_offset;
|
||||
uint32_t data_size;
|
||||
} wav_info_t;
|
||||
|
||||
/* ── Async play queue ────────────────────────────────────────────────────── */
|
||||
typedef enum { CMD_PLAY, CMD_STOP, CMD_RING } audio_cmd_kind_t;
|
||||
|
||||
typedef struct {
|
||||
audio_cmd_kind_t kind;
|
||||
char path[160];
|
||||
} audio_cmd_t;
|
||||
|
||||
static QueueHandle_t s_queue;
|
||||
static i2s_chan_handle_t s_spk_handle = NULL;
|
||||
static volatile bool s_stop_req = false;
|
||||
static bool s_sd_mounted = false;
|
||||
|
||||
/* ── SD card init (best-effort) ──────────────────────────────────────────── */
|
||||
|
||||
static void ensure_sd_mounted(void)
|
||||
{
|
||||
if (s_sd_mounted) return;
|
||||
|
||||
sdmmc_host_t host = SDSPI_HOST_DEFAULT();
|
||||
host.slot = SPI3_HOST;
|
||||
|
||||
spi_bus_config_t bus_cfg = {
|
||||
.mosi_io_num = PLIP_SD_MOSI,
|
||||
.miso_io_num = PLIP_SD_MISO,
|
||||
.sclk_io_num = PLIP_SD_SCK,
|
||||
.quadwp_io_num = -1,
|
||||
.quadhd_io_num = -1,
|
||||
.max_transfer_sz = 4000,
|
||||
};
|
||||
esp_err_t ret = spi_bus_initialize(SPI3_HOST, &bus_cfg, SDSPI_DEFAULT_DMA);
|
||||
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGW(TAG, "spi_bus_initialize: %s", esp_err_to_name(ret));
|
||||
return;
|
||||
}
|
||||
|
||||
sdspi_device_config_t slot = SDSPI_DEVICE_CONFIG_DEFAULT();
|
||||
slot.gpio_cs = PLIP_SD_CS;
|
||||
slot.host_id = SPI3_HOST;
|
||||
|
||||
esp_vfs_fat_sdmmc_mount_config_t mount_cfg = {
|
||||
.format_if_mount_failed = false,
|
||||
.max_files = 4,
|
||||
.allocation_unit_size = 16 * 1024,
|
||||
};
|
||||
sdmmc_card_t *card = NULL;
|
||||
ret = esp_vfs_fat_sdspi_mount(PLIP_SD_MOUNT, &host, &slot, &mount_cfg, &card);
|
||||
if (ret == ESP_OK) {
|
||||
s_sd_mounted = true;
|
||||
ESP_LOGI(TAG, "SD card mounted at %s", PLIP_SD_MOUNT);
|
||||
sdmmc_card_print_info(stdout, card);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "SD card mount failed: %s", esp_err_to_name(ret));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── WAV parser ──────────────────────────────────────────────────────────── */
|
||||
|
||||
static esp_err_t parse_wav_header(const uint8_t *buf, size_t len, wav_info_t *out)
|
||||
{
|
||||
if (len < WAV_MIN_HEADER) return ESP_ERR_INVALID_SIZE;
|
||||
if (memcmp(buf, "RIFF", 4) != 0 || memcmp(buf + 8, "WAVE", 4) != 0)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
|
||||
size_t pos = 12;
|
||||
bool got_fmt = false, got_data = false;
|
||||
while (pos + 8 <= len && !(got_fmt && got_data)) {
|
||||
uint32_t chunk_size;
|
||||
memcpy(&chunk_size, buf + pos + 4, 4);
|
||||
if (memcmp(buf + pos, "fmt ", 4) == 0 && chunk_size >= 16) {
|
||||
memcpy(&out->audio_format, buf + pos + 8, 2);
|
||||
memcpy(&out->channels, buf + pos + 10, 2);
|
||||
memcpy(&out->sample_rate, buf + pos + 12, 4);
|
||||
memcpy(&out->bits_per_sample,buf + pos + 22, 2);
|
||||
got_fmt = true;
|
||||
} else if (memcmp(buf + pos, "data", 4) == 0) {
|
||||
out->data_offset = (uint32_t)(pos + 8);
|
||||
out->data_size = chunk_size;
|
||||
got_data = true;
|
||||
}
|
||||
pos += 8 + chunk_size;
|
||||
if (chunk_size == 0) break;
|
||||
}
|
||||
if (!got_fmt || !got_data) return ESP_ERR_NOT_FOUND;
|
||||
if (out->audio_format != 1) return ESP_ERR_NOT_SUPPORTED; /* not PCM */
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ── Streaming helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
/* Stream raw PCM-16 data to the speaker I2S channel in chunks. */
|
||||
static void stream_pcm(const uint8_t *data, size_t byte_len)
|
||||
{
|
||||
size_t offset = 0;
|
||||
while (!s_stop_req && offset < byte_len) {
|
||||
size_t chunk = (byte_len - offset < 2048) ? (byte_len - offset) : 2048;
|
||||
size_t written = 0;
|
||||
esp_err_t ret = i2s_channel_write(s_spk_handle,
|
||||
data + offset, chunk,
|
||||
&written, pdMS_TO_TICKS(500));
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
|
||||
break;
|
||||
}
|
||||
offset += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
/* Play WAV from an in-memory buffer. */
|
||||
static void play_wav_buf(const uint8_t *buf, size_t len)
|
||||
{
|
||||
wav_info_t wi = {0};
|
||||
esp_err_t ret = parse_wav_header(buf, len, &wi);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "WAV parse error: %s", esp_err_to_name(ret));
|
||||
audio_play_tone(880.0f, 200);
|
||||
return;
|
||||
}
|
||||
if (wi.bits_per_sample != 16) {
|
||||
ESP_LOGW(TAG, "WAV: %d-bit not supported (need 16-bit)", wi.bits_per_sample);
|
||||
return;
|
||||
}
|
||||
ESP_LOGI(TAG, "WAV: %"PRIu32" Hz %d-bit %d ch, %"PRIu32" bytes PCM",
|
||||
wi.sample_rate, wi.bits_per_sample, wi.channels, wi.data_size);
|
||||
stream_pcm(buf + wi.data_offset, wi.data_size);
|
||||
}
|
||||
|
||||
/* Play WAV from SD. Falls back to tone on error. */
|
||||
static void play_wav_file(const char *path)
|
||||
{
|
||||
ensure_sd_mounted();
|
||||
if (!s_sd_mounted) {
|
||||
ESP_LOGW(TAG, "SD not mounted — beep fallback");
|
||||
audio_play_tone(880.0f, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
char full[192];
|
||||
if (path[0] == '/') {
|
||||
snprintf(full, sizeof(full), "%s", path);
|
||||
} else {
|
||||
snprintf(full, sizeof(full), "%s/%s", PLIP_SD_MOUNT, path);
|
||||
}
|
||||
|
||||
FILE *f = fopen(full, "rb");
|
||||
if (!f) {
|
||||
ESP_LOGW(TAG, "file not found: %s", full);
|
||||
audio_play_tone(880.0f, 200);
|
||||
return;
|
||||
}
|
||||
fseek(f, 0, SEEK_END);
|
||||
long fsize = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
|
||||
if (fsize < WAV_MIN_HEADER || fsize > 512 * 1024) {
|
||||
ESP_LOGW(TAG, "file size %ld out of range", fsize);
|
||||
fclose(f);
|
||||
audio_play_tone(880.0f, 200);
|
||||
return;
|
||||
}
|
||||
uint8_t *buf = malloc((size_t)fsize);
|
||||
if (!buf) {
|
||||
ESP_LOGW(TAG, "OOM for %ld bytes", fsize);
|
||||
fclose(f);
|
||||
return;
|
||||
}
|
||||
size_t nread = fread(buf, 1, (size_t)fsize, f);
|
||||
fclose(f);
|
||||
|
||||
play_wav_buf(buf, nread);
|
||||
free(buf);
|
||||
}
|
||||
|
||||
/* Generate a simple embedded 3-tone cue C5-E5-G5 as proof-of-life. */
|
||||
static void play_embedded_cue(void)
|
||||
{
|
||||
/* Three 200ms tones: C5 523Hz, E5 659Hz, G5 784Hz */
|
||||
const float freqs[] = {523.0f, 659.0f, 784.0f};
|
||||
for (int i = 0; i < 3 && !s_stop_req; i++) {
|
||||
audio_play_tone(freqs[i], 200);
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Ring tone task ──────────────────────────────────────────────────────── */
|
||||
|
||||
static void ring_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
/* French ring: 1.5s ON at ~440Hz, 3.5s OFF, repeat until stop */
|
||||
while (!s_stop_req) {
|
||||
/* ON: 1.5s at 440 Hz */
|
||||
const int on_ms = 1500;
|
||||
const int total_samples = SAMPLE_RATE * on_ms / 1000;
|
||||
const float freq = 440.0f;
|
||||
const float amp = 12000.0f;
|
||||
#define RING_CHUNK 128
|
||||
int16_t buf[RING_CHUNK * 2]; /* stereo buffer */
|
||||
int sample_idx = 0;
|
||||
while (!s_stop_req && sample_idx < total_samples) {
|
||||
int chunk = (total_samples - sample_idx < RING_CHUNK)
|
||||
? (total_samples - sample_idx) : RING_CHUNK;
|
||||
for (int i = 0; i < chunk; i++) {
|
||||
float t = (float)(sample_idx + i) / (float)SAMPLE_RATE;
|
||||
int16_t v = (int16_t)(amp * sinf(2.0f * (float)M_PI * freq * t));
|
||||
buf[i * 2] = v; /* L */
|
||||
buf[i * 2 + 1] = v; /* R */
|
||||
}
|
||||
size_t written = 0;
|
||||
i2s_channel_write(s_spk_handle, buf, (size_t)chunk * 2 * sizeof(int16_t),
|
||||
&written, pdMS_TO_TICKS(500));
|
||||
sample_idx += chunk;
|
||||
}
|
||||
#undef RING_CHUNK
|
||||
if (s_stop_req) break;
|
||||
/* OFF: 3.5s silence */
|
||||
const int off_ms = 3500;
|
||||
const int steps = off_ms / 50;
|
||||
for (int i = 0; i < steps && !s_stop_req; i++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
}
|
||||
}
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
/* ── Worker task ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static void audio_worker_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
ESP_LOGI(TAG, "audio worker ready");
|
||||
audio_cmd_t cmd;
|
||||
for (;;) {
|
||||
if (xQueueReceive(s_queue, &cmd, portMAX_DELAY) != pdTRUE) continue;
|
||||
s_stop_req = false;
|
||||
|
||||
switch (cmd.kind) {
|
||||
case CMD_STOP:
|
||||
s_stop_req = true;
|
||||
ESP_LOGI(TAG, "stop");
|
||||
break;
|
||||
case CMD_RING:
|
||||
ESP_LOGI(TAG, "ring start");
|
||||
xTaskCreate(ring_task, "ring", 4096, NULL, 4, NULL);
|
||||
break;
|
||||
case CMD_PLAY: {
|
||||
const char *p = cmd.path;
|
||||
if (!p || !*p || strncmp(p, "embedded:", 9) == 0) {
|
||||
ESP_LOGI(TAG, "play: embedded cue");
|
||||
play_embedded_cue();
|
||||
} else {
|
||||
ESP_LOGI(TAG, "play: %s", p);
|
||||
play_wav_file(p);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Public API ──────────────────────────────────────────────────────────── */
|
||||
|
||||
esp_err_t audio_init(void)
|
||||
{
|
||||
/* 1. ES8388 I2C init + register sequence. */
|
||||
esp_err_t ret = es8388_init();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "es8388_init failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* 2. Create I2S channels (TX = speaker, RX = mic). */
|
||||
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(PLIP_I2S_NUM,
|
||||
I2S_ROLE_MASTER);
|
||||
chan_cfg.auto_clear = true;
|
||||
ret = i2s_new_channel(&chan_cfg, &s_spk_handle, NULL);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "i2s_new_channel: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* 3. Std mode: Philips 16-bit stereo @ 16 kHz. */
|
||||
i2s_std_config_t std_cfg = {
|
||||
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE),
|
||||
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(
|
||||
I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO),
|
||||
.gpio_cfg = {
|
||||
.mclk = PLIP_I2S_MCLK,
|
||||
.bclk = PLIP_I2S_BCLK,
|
||||
.ws = PLIP_I2S_WS,
|
||||
.dout = PLIP_I2S_DOUT,
|
||||
.din = I2S_GPIO_UNUSED, /* mic handled separately if needed */
|
||||
.invert_flags = {
|
||||
.mclk_inv = false,
|
||||
.bclk_inv = false,
|
||||
.ws_inv = false,
|
||||
},
|
||||
},
|
||||
};
|
||||
ret = i2s_channel_init_std_mode(s_spk_handle, &std_cfg);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "i2s_channel_init_std_mode: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
ret = i2s_channel_enable(s_spk_handle);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "i2s_channel_enable: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* 4. Create async command queue + worker. */
|
||||
s_queue = xQueueCreate(8, sizeof(audio_cmd_t));
|
||||
if (!s_queue) return ESP_ERR_NO_MEM;
|
||||
|
||||
BaseType_t ok = xTaskCreatePinnedToCore(audio_worker_task, "audio_work",
|
||||
8192, NULL, 5, NULL, 0);
|
||||
if (ok != pdPASS) return ESP_ERR_NO_MEM;
|
||||
|
||||
ESP_LOGI(TAG, "audio init OK (I2S TX ready, ES8388 live)");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
i2s_chan_handle_t audio_spk_handle(void)
|
||||
{
|
||||
return s_spk_handle;
|
||||
}
|
||||
|
||||
void audio_play_tone(float frequency_hz, int duration_ms)
|
||||
{
|
||||
if (!s_spk_handle || duration_ms <= 0) return;
|
||||
const int total_samples = SAMPLE_RATE * duration_ms / 1000;
|
||||
const float amp = 12000.0f;
|
||||
/* Stereo: each sample = L + R = 2 int16_t values. */
|
||||
/* Process 128 mono samples per iteration = 256 stereo int16_t = 512 bytes. */
|
||||
#define TONE_CHUNK 128
|
||||
int16_t buf[TONE_CHUNK * 2]; /* stereo buffer: 2 samples per frame */
|
||||
size_t written = 0;
|
||||
int idx = 0;
|
||||
while (idx < total_samples) {
|
||||
int chunk = (total_samples - idx < TONE_CHUNK)
|
||||
? (total_samples - idx) : TONE_CHUNK;
|
||||
for (int i = 0; i < chunk; i++) {
|
||||
float t = (float)(idx + i) / (float)SAMPLE_RATE;
|
||||
int16_t v = (int16_t)(amp * sinf(2.0f * (float)M_PI * frequency_hz * t));
|
||||
buf[i * 2] = v; /* L */
|
||||
buf[i * 2 + 1] = v; /* R */
|
||||
}
|
||||
i2s_channel_write(s_spk_handle, buf, (size_t)chunk * 2 * sizeof(int16_t),
|
||||
&written, pdMS_TO_TICKS(500));
|
||||
idx += chunk;
|
||||
}
|
||||
#undef TONE_CHUNK
|
||||
}
|
||||
|
||||
esp_err_t audio_play_async(const char *path)
|
||||
{
|
||||
if (!s_queue) return ESP_ERR_INVALID_STATE;
|
||||
audio_cmd_t cmd = { .kind = CMD_PLAY };
|
||||
if (path && *path) {
|
||||
strncpy(cmd.path, path, sizeof(cmd.path) - 1);
|
||||
}
|
||||
if (xQueueSend(s_queue, &cmd, 0) != pdTRUE) {
|
||||
ESP_LOGW(TAG, "play queue full");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t audio_stop(void)
|
||||
{
|
||||
s_stop_req = true;
|
||||
if (!s_queue) return ESP_ERR_INVALID_STATE;
|
||||
audio_cmd_t cmd = { .kind = CMD_STOP };
|
||||
xQueueSend(s_queue, &cmd, 0);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t audio_ring_start(void)
|
||||
{
|
||||
s_stop_req = false;
|
||||
if (!s_queue) return ESP_ERR_INVALID_STATE;
|
||||
audio_cmd_t cmd = { .kind = CMD_RING };
|
||||
if (xQueueSend(s_queue, &cmd, 0) != pdTRUE) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
/*
|
||||
* audio.h — I2S + ES8388 audio interface for the PLIP voice annex.
|
||||
*
|
||||
* Provides:
|
||||
* - es8388 + I2S initialisation (called once from app_main)
|
||||
* - Blocking 440 Hz tone (Phase A proof)
|
||||
* - WAV file playback from SD or embedded asset
|
||||
* - Ring tone (400/450 Hz cadence) for Phase D
|
||||
* - Async playback command queue (used by cmd_exec / ESP-NOW)
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
#include "driver/i2s_std.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Initialise ES8388 and I2S channels. Must be called before any other
|
||||
* audio_* function. Returns ESP_OK on success. */
|
||||
esp_err_t audio_init(void);
|
||||
|
||||
/* Return the speaker I2S TX channel handle (used by cmd_exec for WAV play). */
|
||||
i2s_chan_handle_t audio_spk_handle(void);
|
||||
|
||||
/* Play a pure sine tone for duration_ms (blocking). */
|
||||
void audio_play_tone(float frequency_hz, int duration_ms);
|
||||
|
||||
/* Enqueue an async play command. path may be:
|
||||
* - "/sdcard/<file>.wav" — read from SD
|
||||
* - "embedded://" — built-in C5-E5-G5 cue
|
||||
* - "" or NULL — same as embedded://
|
||||
* Returns ESP_OK if the command was enqueued (non-blocking from any task). */
|
||||
esp_err_t audio_play_async(const char *path);
|
||||
|
||||
/* Stop current playback immediately. */
|
||||
esp_err_t audio_stop(void);
|
||||
|
||||
/* Start ring tone cadence (ON 1s / OFF 2s) at ~440 Hz. Continues until
|
||||
* audio_stop() is called. Non-blocking — spawns an internal task. */
|
||||
esp_err_t audio_ring_start(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
/*
|
||||
* AI-Thinker ESP32-A1S Audio Kit V2.2 — Hardware Pin Configuration
|
||||
* ES8388 codec (I2C control + I2S data), SD card (SPI), power amp.
|
||||
* DIP switch: 1=OFF, 2=ON, 3=ON, 4=OFF, 5=OFF (SD active, KEY2 busy)
|
||||
*/
|
||||
|
||||
/* ---------- I2C Bus (ES8388 control) ---------- */
|
||||
#define PLIP_I2C_PORT I2C_NUM_0
|
||||
#define PLIP_I2C_SCL 32
|
||||
#define PLIP_I2C_SDA 33
|
||||
#define PLIP_I2C_FREQ_HZ 400000
|
||||
|
||||
/* ---------- ES8388 Audio Codec (I2C address) ---------- */
|
||||
#define PLIP_ES8388_ADDR 0x10 /* 7-bit: 0x10 with ADDR pin low */
|
||||
|
||||
/* ---------- I2S Audio Data ---------- */
|
||||
#define PLIP_I2S_NUM I2S_NUM_0
|
||||
#define PLIP_I2S_MCLK 0 /* GPIO0 — must be 0/1/3 on original ESP32 */
|
||||
#define PLIP_I2S_BCLK 27
|
||||
#define PLIP_I2S_WS 25 /* LRCK */
|
||||
#define PLIP_I2S_DOUT 26 /* DAC → speaker */
|
||||
#define PLIP_I2S_DIN 35 /* ADC ← mic (input-only) */
|
||||
|
||||
/* ---------- Power Amplifier ---------- */
|
||||
#define PLIP_PA_ENABLE 21 /* Active HIGH, drives NS4150 amp */
|
||||
|
||||
/* ---------- SD Card (SPI / HSPI) ---------- */
|
||||
#define PLIP_SD_CS 13
|
||||
#define PLIP_SD_MOSI 15
|
||||
#define PLIP_SD_MISO 2
|
||||
#define PLIP_SD_SCK 14
|
||||
#define PLIP_SD_MOUNT "/sdcard"
|
||||
|
||||
/* ---------- Audio Parameters ---------- */
|
||||
#define PLIP_SAMPLE_RATE 16000
|
||||
#define PLIP_BITS_PER_SAMPLE 16
|
||||
#define PLIP_CHANNELS 2 /* ES8388 I2S always stereo; output averaged */
|
||||
|
||||
/* ---------- Off-hook GPIO (dev kit uses BOOT/KEY1 GPIO4 as stand-in) ---------- */
|
||||
/* Actual value comes from CONFIG_PLIP_HOOK_GPIO (Kconfig) */
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* cmd_exec.c — PLIP voice CMD executor (D5 contract, adapted from box3_voice).
|
||||
*
|
||||
* The PLIP has no display; screen/evt/led ops are logged and ignored.
|
||||
* play → audio_play_async(path).
|
||||
*/
|
||||
|
||||
#include "cmd_exec.h"
|
||||
#include "audio.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#define TAG "cmd_exec"
|
||||
|
||||
static esp_err_t exec_play(const cJSON *a)
|
||||
{
|
||||
const cJSON *path_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
|
||||
const char *path = cJSON_IsString(path_j) ? path_j->valuestring : "embedded://";
|
||||
|
||||
ESP_LOGI(TAG, "CMD op=play path=\"%s\"", path);
|
||||
return audio_play_async(path);
|
||||
}
|
||||
|
||||
static esp_err_t exec_screen(const cJSON *a)
|
||||
{
|
||||
const cJSON *t_j = a ? cJSON_GetObjectItemCaseSensitive(a, "t") : NULL;
|
||||
ESP_LOGI(TAG, "CMD op=screen t=\"%s\" (no display on PLIP — ignored)",
|
||||
cJSON_IsString(t_j) ? t_j->valuestring : "");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t exec_evt(const cJSON *a)
|
||||
{
|
||||
const cJSON *n_j = a ? cJSON_GetObjectItemCaseSensitive(a, "n") : NULL;
|
||||
ESP_LOGI(TAG, "CMD op=evt n=\"%s\"",
|
||||
cJSON_IsString(n_j) ? n_j->valuestring : "<none>");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t exec_led(const cJSON *a)
|
||||
{
|
||||
const cJSON *p_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
|
||||
ESP_LOGI(TAG, "CMD op=led pattern=\"%s\" (no LED — ignored)",
|
||||
cJSON_IsString(p_j) ? p_j->valuestring : "<none>");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t cmd_exec_handle(const char *data, size_t len)
|
||||
{
|
||||
if (!data || len == 0) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
cJSON *root = cJSON_ParseWithLength(data, len);
|
||||
if (!root) {
|
||||
ESP_LOGW(TAG, "CMD parse error: not valid JSON (%.40s)", data);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
const cJSON *op_j = cJSON_GetObjectItemCaseSensitive(root, "op");
|
||||
if (!cJSON_IsString(op_j) || !op_j->valuestring || !op_j->valuestring[0]) {
|
||||
ESP_LOGW(TAG, "CMD missing/invalid 'op' field");
|
||||
cJSON_Delete(root);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
const char *op = op_j->valuestring;
|
||||
const cJSON *a = cJSON_GetObjectItemCaseSensitive(root, "a");
|
||||
|
||||
esp_err_t err;
|
||||
if (strcmp(op, "play") == 0) {
|
||||
err = exec_play(a);
|
||||
} else if (strcmp(op, "screen") == 0) {
|
||||
err = exec_screen(a);
|
||||
} else if (strcmp(op, "evt") == 0) {
|
||||
err = exec_evt(a);
|
||||
} else if (strcmp(op, "led") == 0) {
|
||||
err = exec_led(a);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "CMD op=\"%s\" unknown — ignored", op);
|
||||
err = ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return err;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
/*
|
||||
* cmd_exec.h — ESP-NOW CMD executor for the PLIP voice annex (D5 contract).
|
||||
*
|
||||
* Handles JSON CMD frames: { "op": "play"|"screen"|"evt"|"led", "a": {...} }
|
||||
* Routing:
|
||||
* play → audio_play_async(a.p)
|
||||
* screen → log only (no display on PLIP)
|
||||
* evt → log only
|
||||
* led → log only
|
||||
*/
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Parse and dispatch a CMD JSON payload received from the master via ESP-NOW.
|
||||
* `data` is a NUL-terminated JSON string of `len` bytes.
|
||||
* Returns ESP_OK on success, ESP_ERR_INVALID_ARG on parse error,
|
||||
* ESP_ERR_NOT_SUPPORTED for unknown ops. */
|
||||
esp_err_t cmd_exec_handle(const char *data, size_t len);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* es8388.c — ES8388 codec driver (IDF 5.x, legacy I2C driver API).
|
||||
*
|
||||
* Register map reference: ES8388 datasheet rev 1.6 (Everest Semiconductor).
|
||||
* Init sequence derived from:
|
||||
* - Espressif esp-adf es8388.c (Apache 2.0)
|
||||
* - schreibfaul1/ESP32-audioI2S ES8388 init (MIT)
|
||||
* - AI-Thinker SDK AudioKit driver
|
||||
*
|
||||
* The sequence configures the ES8388 for:
|
||||
* - Master clock: MCLK from ESP32 GPIO0 at 12.288 MHz (or 256*Fs at 16kHz)
|
||||
* - I2S format: I2S Philips, 16-bit, stereo
|
||||
* - DAC: LOUT1/ROUT1 → headphone amp (GPIO21 PA_ENABLE must be HIGH)
|
||||
* - ADC: LINPUT1/RINPUT1 mic (differential), gain 24 dB
|
||||
* - Sample rate: 16 kHz (MCLKDIV = 256Fs)
|
||||
*/
|
||||
|
||||
#include "es8388.h"
|
||||
#include "board_config.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "driver/i2c.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#define TAG "es8388"
|
||||
|
||||
/* ── ES8388 register addresses ──────────────────────────────────────────── */
|
||||
#define ES8388_CHIP_CTL1 0x00 /* CHIP_CTL1 */
|
||||
#define ES8388_CHIP_CTL2 0x01 /* CHIP_CTL2 */
|
||||
#define ES8388_CHIP_POWER 0x02 /* CHIP_POWER (ADCPD, DACPD, etc.) */
|
||||
#define ES8388_ADC_POWER 0x03 /* ADCPOWER */
|
||||
#define ES8388_DAC_POWER 0x04 /* DACPOWER */
|
||||
#define ES8388_CHIP_LP 0x05 /* CHIP_LP */
|
||||
#define ES8388_CHIP_CTL3 0x06 /* CHIP_CTL3 */
|
||||
#define ES8388_ADC_CTL 0x09 /* ADCCONTROL1 — PGA gain */
|
||||
#define ES8388_ADC_CTL3 0x0A /* ADCCONTROL3 — input select */
|
||||
#define ES8388_ADC_CTL4 0x0B /* ADCCONTROL4 — I2S word len / format */
|
||||
#define ES8388_ADC_CTL5 0x0C /* ADCCONTROL5 — MCLK divider */
|
||||
#define ES8388_ADC_CTL7 0x0E /* ADCCONTROL7 — HPF */
|
||||
#define ES8388_ADC_CTL8 0x0F /* ADCCONTROL8 — L volume */
|
||||
#define ES8388_ADC_CTL9 0x10 /* ADCCONTROL9 — R volume */
|
||||
#define ES8388_DAC_CTL1 0x17 /* DACCONTROL1 — I2S word len / format */
|
||||
#define ES8388_DAC_CTL2 0x18 /* DACCONTROL2 — MCLK divider */
|
||||
#define ES8388_DAC_CTL3 0x19 /* DACCONTROL3 — mute */
|
||||
#define ES8388_DAC_CTL4 0x1A /* DACCONTROL4 — LDACVOL */
|
||||
#define ES8388_DAC_CTL5 0x1B /* DACCONTROL5 — RDACVOL */
|
||||
#define ES8388_DAC_CTL16 0x26 /* DACCONTROL16 — L/R mixer config */
|
||||
#define ES8388_DAC_CTL17 0x27 /* DACCONTROL17 — L mixer gain */
|
||||
#define ES8388_DAC_CTL20 0x2A /* DACCONTROL20 — R mixer gain */
|
||||
#define ES8388_DAC_CTL21 0x2B /* DACCONTROL21 — out1 vol L */
|
||||
#define ES8388_DAC_CTL22 0x2C /* DACCONTROL22 — out1 vol R */
|
||||
#define ES8388_DAC_CTL23 0x2D /* DACCONTROL23 — out2 vol L (speaker) */
|
||||
#define ES8388_DAC_CTL24 0x2E /* DACCONTROL24 — out2 vol R */
|
||||
#define ES8388_DAC_CTL25 0x2F /* DACCONTROL25 — out1 enable */
|
||||
#define ES8388_DAC_CTL26 0x30 /* DACCONTROL26 — out2 enable */
|
||||
|
||||
/* Volume register: 0x00=0dB (max), 0x21=-33dB step per 1.5dB, 0x24=mute. */
|
||||
#define ES8388_VOL_MAX 0x00
|
||||
#define ES8388_VOL_0DB 0x00
|
||||
#define ES8388_VOL_MUTE 0x24
|
||||
|
||||
/* ── I2C helpers ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static esp_err_t i2c_write_reg(uint8_t reg, uint8_t value)
|
||||
{
|
||||
uint8_t buf[2] = { reg, value };
|
||||
esp_err_t ret = i2c_master_write_to_device(PLIP_I2C_PORT,
|
||||
PLIP_ES8388_ADDR,
|
||||
buf, sizeof(buf),
|
||||
pdMS_TO_TICKS(100));
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "write reg 0x%02X=0x%02X failed: %s",
|
||||
reg, value, esp_err_to_name(ret));
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
esp_err_t es8388_read_reg(uint8_t reg, uint8_t *value)
|
||||
{
|
||||
return i2c_master_write_read_device(PLIP_I2C_PORT,
|
||||
PLIP_ES8388_ADDR,
|
||||
®, 1,
|
||||
value, 1,
|
||||
pdMS_TO_TICKS(100));
|
||||
}
|
||||
|
||||
/* ── Public API ──────────────────────────────────────────────────────────── */
|
||||
|
||||
esp_err_t es8388_init(void)
|
||||
{
|
||||
/* Initialise I2C master on the A1S bus. */
|
||||
i2c_config_t conf = {
|
||||
.mode = I2C_MODE_MASTER,
|
||||
.sda_io_num = PLIP_I2C_SDA,
|
||||
.scl_io_num = PLIP_I2C_SCL,
|
||||
.sda_pullup_en = GPIO_PULLUP_ENABLE,
|
||||
.scl_pullup_en = GPIO_PULLUP_ENABLE,
|
||||
.master.clk_speed = PLIP_I2C_FREQ_HZ,
|
||||
};
|
||||
esp_err_t ret = i2c_param_config(PLIP_I2C_PORT, &conf);
|
||||
if (ret != ESP_OK) return ret;
|
||||
ret = i2c_driver_install(PLIP_I2C_PORT, conf.mode, 0, 0, 0);
|
||||
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
|
||||
/* ESP_ERR_INVALID_STATE means the driver is already installed — ok. */
|
||||
ESP_LOGE(TAG, "i2c_driver_install: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* Quick device presence check. */
|
||||
uint8_t chip_id = 0;
|
||||
ret = es8388_read_reg(ES8388_CHIP_CTL1, &chip_id);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "ES8388 not found on I2C bus (addr=0x%02X): %s",
|
||||
PLIP_ES8388_ADDR, esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
ESP_LOGI(TAG, "ES8388 detected: CHIP_CTL1=0x%02X", chip_id);
|
||||
|
||||
/* ── Full power-up + init sequence ─────────────────────────────────── */
|
||||
|
||||
/* 1. Reset. */
|
||||
if (i2c_write_reg(ES8388_CHIP_CTL1, 0x80) != ESP_OK) return ESP_FAIL;
|
||||
vTaskDelay(pdMS_TO_TICKS(20));
|
||||
if (i2c_write_reg(ES8388_CHIP_CTL1, 0x00) != ESP_OK) return ESP_FAIL;
|
||||
|
||||
/* 2. Power down all blocks first (prevents pop on line-up). */
|
||||
if (i2c_write_reg(ES8388_CHIP_POWER, 0xFF) != ESP_OK) return ESP_FAIL;
|
||||
|
||||
/* 3. Master mode off, serial port SLAVE (I2S clock from ESP32). */
|
||||
if (i2c_write_reg(ES8388_CHIP_CTL1, 0x05) != ESP_OK) return ESP_FAIL;
|
||||
|
||||
/* 4. Clock: MCLK divider = 256*Fs, DAC SRC = MCLK. */
|
||||
if (i2c_write_reg(0x08, 0x00) != ESP_OK) return ESP_FAIL; /* MASTERMODE = slave */
|
||||
|
||||
/* 5. ADC power: LINN1 × RINP1 on; keep ADC PGA/HPF off for now. */
|
||||
if (i2c_write_reg(ES8388_ADC_POWER, 0x00) != ESP_OK) return ESP_FAIL;
|
||||
|
||||
/* 6. ADC control: differential input on LIN1/RIN1, PGA gain +24dB. */
|
||||
if (i2c_write_reg(ES8388_ADC_CTL, 0x88) != ESP_OK) return ESP_FAIL;
|
||||
if (i2c_write_reg(ES8388_ADC_CTL3, 0x02) != ESP_OK) return ESP_FAIL; /* LINSEL=LIN2(mic) */
|
||||
|
||||
/* 7. ADC I2S: 16-bit I2S Philips. MCLK/256 = 16kHz. */
|
||||
if (i2c_write_reg(ES8388_ADC_CTL4, 0x0C) != ESP_OK) return ESP_FAIL;
|
||||
if (i2c_write_reg(ES8388_ADC_CTL5, 0x02) != ESP_OK) return ESP_FAIL; /* ADCLRCKDIV=256 */
|
||||
|
||||
/* 8. DAC I2S: 16-bit I2S Philips, MCLK/256, no softmute. */
|
||||
if (i2c_write_reg(ES8388_DAC_CTL1, 0x18) != ESP_OK) return ESP_FAIL;
|
||||
if (i2c_write_reg(ES8388_DAC_CTL2, 0x02) != ESP_OK) return ESP_FAIL; /* DACLRCKDIV=256 */
|
||||
if (i2c_write_reg(ES8388_DAC_CTL3, 0x00) != ESP_OK) return ESP_FAIL; /* mute off */
|
||||
|
||||
/* 9. DAC volume: 0 dB on both channels. */
|
||||
if (i2c_write_reg(ES8388_DAC_CTL4, ES8388_VOL_0DB) != ESP_OK) return ESP_FAIL;
|
||||
if (i2c_write_reg(ES8388_DAC_CTL5, ES8388_VOL_0DB) != ESP_OK) return ESP_FAIL;
|
||||
|
||||
/* 10. Mixer: L→LOUT, R→ROUT (straight through, no cross-mix). */
|
||||
if (i2c_write_reg(ES8388_DAC_CTL16, 0x1B) != ESP_OK) return ESP_FAIL;
|
||||
if (i2c_write_reg(ES8388_DAC_CTL17, 0x90) != ESP_OK) return ESP_FAIL;
|
||||
if (i2c_write_reg(ES8388_DAC_CTL20, 0x90) != ESP_OK) return ESP_FAIL;
|
||||
|
||||
/* 11. Output attenuation — OUT1 + OUT2 at 0 dB (register 0x00). */
|
||||
if (i2c_write_reg(ES8388_DAC_CTL21, 0x00) != ESP_OK) return ESP_FAIL;
|
||||
if (i2c_write_reg(ES8388_DAC_CTL22, 0x00) != ESP_OK) return ESP_FAIL;
|
||||
if (i2c_write_reg(ES8388_DAC_CTL23, 0x00) != ESP_OK) return ESP_FAIL;
|
||||
if (i2c_write_reg(ES8388_DAC_CTL24, 0x00) != ESP_OK) return ESP_FAIL;
|
||||
|
||||
/* 12. Enable OUT1 (headphone) and OUT2 (speaker line). */
|
||||
if (i2c_write_reg(ES8388_DAC_CTL25, 0x35) != ESP_OK) return ESP_FAIL;
|
||||
if (i2c_write_reg(ES8388_DAC_CTL26, 0x35) != ESP_OK) return ESP_FAIL;
|
||||
|
||||
/* 13. DAC power: power up DAC L+R. */
|
||||
if (i2c_write_reg(ES8388_DAC_POWER, 0x3C) != ESP_OK) return ESP_FAIL;
|
||||
|
||||
/* 14. Chip power: release global power-down (ADC+DAC active). */
|
||||
if (i2c_write_reg(ES8388_CHIP_POWER, 0x00) != ESP_OK) return ESP_FAIL;
|
||||
|
||||
/* 15. Enable PA (power amplifier for speaker). */
|
||||
gpio_set_direction(PLIP_PA_ENABLE, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(PLIP_PA_ENABLE, 1);
|
||||
|
||||
ESP_LOGI(TAG, "ES8388 init OK — PA enabled, DAC @ 0dB, ADC PGA +24dB");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t es8388_set_volume(uint8_t vol)
|
||||
{
|
||||
/* Map 0..100 to 0x00 (0dB) .. 0x24 (mute); register is attenuation. */
|
||||
uint8_t reg_val = (uint8_t)((100 - (int)vol) * 0x24 / 100);
|
||||
ESP_LOGI(TAG, "set_volume: %d%% → reg=0x%02X", vol, reg_val);
|
||||
esp_err_t r = ESP_OK;
|
||||
r |= i2c_write_reg(ES8388_DAC_CTL21, reg_val);
|
||||
r |= i2c_write_reg(ES8388_DAC_CTL22, reg_val);
|
||||
r |= i2c_write_reg(ES8388_DAC_CTL23, reg_val);
|
||||
r |= i2c_write_reg(ES8388_DAC_CTL24, reg_val);
|
||||
return r;
|
||||
}
|
||||
|
||||
esp_err_t es8388_mute(bool mute)
|
||||
{
|
||||
uint8_t val = mute ? 0x04 : 0x00; /* bit2 = DACMUTE */
|
||||
ESP_LOGI(TAG, "mute: %s", mute ? "on" : "off");
|
||||
return i2c_write_reg(ES8388_DAC_CTL3, val);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
/*
|
||||
* es8388.h — Minimal ES8388 codec driver for AI-Thinker ESP32-A1S (IDF 5.x).
|
||||
*
|
||||
* Controls the ES8388 via I2C (addr 0x10) for playback (DAC) and capture (ADC).
|
||||
* I2S is configured separately via the standard IDF driver/i2s_std.h API.
|
||||
*
|
||||
* Thread safety: all public functions are non-reentrant; call from a single task.
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Initialise the I2C master bus and configure the ES8388 registers for
|
||||
* stereo playback + mic capture at 16 kHz. Must be called once before any
|
||||
* other es8388_* function and after driver_i2c is available.
|
||||
*
|
||||
* Returns ESP_OK on success. On I2C error the register that failed is logged
|
||||
* and ESP_FAIL is returned — the codec is in an undefined state.
|
||||
*/
|
||||
esp_err_t es8388_init(void);
|
||||
|
||||
/* Set output volume. vol: 0 (mute) … 100 (full). Mapped to ES8388
|
||||
* OUT1 (headphone) + OUT2 (speaker) attenuation registers. */
|
||||
esp_err_t es8388_set_volume(uint8_t vol);
|
||||
|
||||
/* Mute / unmute DAC output. */
|
||||
esp_err_t es8388_mute(bool mute);
|
||||
|
||||
/* Read a single ES8388 register for diagnostic purposes. */
|
||||
esp_err_t es8388_read_reg(uint8_t reg, uint8_t *value);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* hook_client.c — Ported from PLIP_FIRMWARE/src/zacus_hook_client.cpp.
|
||||
*
|
||||
* Uses esp_http_client (IDF) instead of Arduino HTTPClient.
|
||||
* Failure policy: 3 s timeout, one retry at +250 ms, then drop.
|
||||
*/
|
||||
|
||||
#include "hook_client.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define TAG "hook_client"
|
||||
|
||||
#define HOOK_PATH "/voice/hook"
|
||||
#define TIMEOUT_MS 3000
|
||||
#define RETRY_DELAY_MS 250
|
||||
#define QUEUE_DEPTH 4
|
||||
#define WIFI_WAIT_MS 1500
|
||||
#define WIFI_WAIT_STEP_MS 50
|
||||
|
||||
typedef struct {
|
||||
char state[8]; /* "off" | "on" */
|
||||
char reason[32]; /* free-form */
|
||||
} hook_event_t;
|
||||
|
||||
static QueueHandle_t s_queue = NULL;
|
||||
static char s_url[192] = {0}; /* full URL: <base>/voice/hook */
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
static bool wifi_ready_within(uint32_t budget_ms)
|
||||
{
|
||||
uint32_t waited = 0;
|
||||
wifi_ap_record_t ap;
|
||||
while (waited < budget_ms) {
|
||||
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) return true;
|
||||
vTaskDelay(pdMS_TO_TICKS(WIFI_WAIT_STEP_MS));
|
||||
waited += WIFI_WAIT_STEP_MS;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool post_hook(const hook_event_t *ev)
|
||||
{
|
||||
char body[96];
|
||||
snprintf(body, sizeof(body),
|
||||
"{\"state\":\"%s\",\"reason\":\"%s\"}",
|
||||
ev->state, ev->reason);
|
||||
|
||||
esp_http_client_config_t cfg = {
|
||||
.url = s_url,
|
||||
.method = HTTP_METHOD_POST,
|
||||
.timeout_ms = TIMEOUT_MS,
|
||||
.buffer_size = 512,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||
if (!client) {
|
||||
ESP_LOGW(TAG, "http_client_init failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
esp_http_client_set_header(client, "Content-Type", "application/json");
|
||||
esp_http_client_set_post_field(client, body, (int)strlen(body));
|
||||
|
||||
esp_err_t ret = esp_http_client_perform(client);
|
||||
int code = 0;
|
||||
if (ret == ESP_OK) {
|
||||
code = esp_http_client_get_status_code(client);
|
||||
}
|
||||
esp_http_client_cleanup(client);
|
||||
|
||||
if (ret == ESP_OK && code >= 200 && code < 300) {
|
||||
ESP_LOGI(TAG, "POST %s -> %d (state=%s reason=%s)",
|
||||
s_url, code, ev->state, ev->reason);
|
||||
return true;
|
||||
}
|
||||
ESP_LOGW(TAG, "POST %s failed: ret=%s code=%d (state=%s reason=%s)",
|
||||
s_url, esp_err_to_name(ret), code, ev->state, ev->reason);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ── Worker task ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static void worker_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
ESP_LOGI(TAG, "worker ready, target=%s", s_url);
|
||||
hook_event_t ev;
|
||||
for (;;) {
|
||||
if (xQueueReceive(s_queue, &ev, portMAX_DELAY) != pdTRUE) continue;
|
||||
|
||||
if (!wifi_ready_within(WIFI_WAIT_MS)) {
|
||||
ESP_LOGW(TAG, "WiFi down, dropping hook event (state=%s)", ev.state);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (post_hook(&ev)) continue;
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(RETRY_DELAY_MS));
|
||||
if (!post_hook(&ev)) {
|
||||
ESP_LOGW(TAG, "giving up after retry (state=%s)", ev.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Public API ──────────────────────────────────────────────────────────── */
|
||||
|
||||
esp_err_t hook_client_init(const char *master_url)
|
||||
{
|
||||
if (s_queue) return ESP_OK; /* idempotent */
|
||||
|
||||
const char *base = (master_url && *master_url)
|
||||
? master_url : CONFIG_PLIP_MASTER_URL;
|
||||
|
||||
size_t blen = strnlen(base, sizeof(s_url) - sizeof(HOOK_PATH) - 1);
|
||||
if (blen == 0 || blen >= sizeof(s_url) - sizeof(HOOK_PATH)) {
|
||||
ESP_LOGE(TAG, "invalid master_url");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
memcpy(s_url, base, blen);
|
||||
if (s_url[blen - 1] == '/') blen--;
|
||||
s_url[blen] = '\0';
|
||||
strncat(s_url, HOOK_PATH, sizeof(s_url) - strlen(s_url) - 1);
|
||||
|
||||
s_queue = xQueueCreate(QUEUE_DEPTH, sizeof(hook_event_t));
|
||||
if (!s_queue) return ESP_ERR_NO_MEM;
|
||||
|
||||
if (xTaskCreate(worker_task, "hook_client", 4096, NULL, 5, NULL) != pdPASS) {
|
||||
vQueueDelete(s_queue);
|
||||
s_queue = NULL;
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool hook_client_report(const char *state, const char *reason)
|
||||
{
|
||||
if (!s_queue) {
|
||||
ESP_LOGW(TAG, "report() before init() — dropping");
|
||||
return false;
|
||||
}
|
||||
hook_event_t ev = {0};
|
||||
strncpy(ev.state, state ? state : "", sizeof(ev.state) - 1);
|
||||
strncpy(ev.reason, reason ? reason : "", sizeof(ev.reason) - 1);
|
||||
|
||||
if (xQueueSend(s_queue, &ev, 0) != pdTRUE) {
|
||||
ESP_LOGW(TAG, "queue full, dropping (state=%s)", ev.state);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
/*
|
||||
* hook_client.h — POST hook transitions to the Zacus master (Phase D).
|
||||
*
|
||||
* Ported from PLIP_FIRMWARE/src/zacus_hook_client.cpp to plain C / IDF.
|
||||
* Endpoint: POST <master_url>/voice/hook { "state": "off"|"on", "reason": "..." }
|
||||
*/
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Initialise (spawns worker task). master_url: base URL e.g.
|
||||
* "http://192.168.0.188". Pass NULL to use CONFIG_PLIP_MASTER_URL.
|
||||
* Idempotent — safe to call once from app_main. */
|
||||
esp_err_t hook_client_init(const char *master_url);
|
||||
|
||||
/* Enqueue a state report (non-blocking). state = "off"|"on",
|
||||
* reason = short free-form tag ("pickup", "hangup", "boot", ...).
|
||||
* Returns true if enqueued, false if queue was full. */
|
||||
bool hook_client_report(const char *state, const char *reason);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,6 @@
|
||||
## IDF Component Manager manifest for plip_voice main component.
|
||||
## Managed components are fetched during `idf.py build` if not already present.
|
||||
dependencies:
|
||||
## cJSON for CMD/EVT JSON parsing
|
||||
idf:
|
||||
version: ">=5.1.0"
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* main.c — PLIP voice annex application entry point.
|
||||
*
|
||||
* Board: AI-Thinker ESP32-A1S Audio Kit V2.2 (ESP32, ES8388, SD, mic, HP)
|
||||
* Target: ESP-IDF 5.4, esp32 target (NOT esp32s3)
|
||||
*
|
||||
* Boot sequence:
|
||||
* 1. NVS init
|
||||
* 2. audio_init() — ES8388 + I2S (Phase A)
|
||||
* 3. audio_play_tone(440Hz, 1s) — Phase A proof (sound before WiFi)
|
||||
* 4. net_init() — WiFi STA + HTTP server (Phase B)
|
||||
* 5. scenario_mesh_init() — ESP-NOW CMD receiver (Phase C)
|
||||
* 6. hook_client_init() — hook event forwarder (Phase D)
|
||||
* 7. phone_init() — off-hook GPIO monitor (Phase D)
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_system.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include "audio.h"
|
||||
#include "net.h"
|
||||
#include "cmd_exec.h"
|
||||
#include "hook_client.h"
|
||||
#include "phone.h"
|
||||
#include "scenario_mesh.h"
|
||||
|
||||
static const char *TAG = "plip-main";
|
||||
|
||||
/* ── ESP-NOW CMD/EVT text callback ─────────────────────────────────────────── */
|
||||
|
||||
static void on_mesh_text(uint8_t kind, const uint8_t src_mac[6], const char *text)
|
||||
{
|
||||
if (kind == SCENARIO_MESH_TEXT_CMD) {
|
||||
ESP_LOGI(TAG, "ESP-NOW CMD from %02x:%02x:%02x:%02x:%02x:%02x: %.80s",
|
||||
src_mac[0], src_mac[1], src_mac[2],
|
||||
src_mac[3], src_mac[4], src_mac[5], text);
|
||||
esp_err_t err = cmd_exec_handle(text, strlen(text));
|
||||
if (err != ESP_OK && err != ESP_ERR_NOT_SUPPORTED) {
|
||||
ESP_LOGW(TAG, "cmd_exec_handle: %s", esp_err_to_name(err));
|
||||
}
|
||||
} else {
|
||||
ESP_LOGI(TAG, "ESP-NOW EVT from %02x:%02x:%02x:%02x:%02x:%02x: %.80s",
|
||||
src_mac[0], src_mac[1], src_mac[2],
|
||||
src_mac[3], src_mac[4], src_mac[5], text);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Boot task — runs all phases with sufficient stack ────────────────────── */
|
||||
|
||||
static void boot_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
/* ── 2. Audio init (Phase A) ── */
|
||||
esp_err_t ret = audio_init();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "audio_init FAILED: %s", esp_err_to_name(ret));
|
||||
ESP_LOGE(TAG, "PHASE A BLOCKED — check ES8388 I2C wiring (SDA=33, SCL=32)");
|
||||
/* Continue anyway — WiFi + hook still work. */
|
||||
} else {
|
||||
/* ── 3. Phase A proof: 440 Hz test tone ── */
|
||||
ESP_LOGI(TAG, "Phase A: playing 440 Hz test tone (1s) — LISTEN FOR SOUND");
|
||||
audio_play_tone(440.0f, 1000);
|
||||
vTaskDelay(pdMS_TO_TICKS(200));
|
||||
ESP_LOGI(TAG, "Phase A: tone done. If you heard it, Phase A is VERIFIED.");
|
||||
}
|
||||
|
||||
/* ── 4. Network + HTTP server (Phase B) ── */
|
||||
ret = net_init();
|
||||
if (ret == ESP_OK) {
|
||||
ESP_LOGI(TAG, "Phase B: WiFi + httpd up. Verify: curl http://<PLIP_IP>/status");
|
||||
} else if (ret == ESP_ERR_TIMEOUT) {
|
||||
ESP_LOGW(TAG, "Phase B: WiFi timeout — running offline (ESP-NOW still active)");
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Phase B: net_init error %s", esp_err_to_name(ret));
|
||||
}
|
||||
|
||||
/* ── 5. ESP-NOW CMD receiver + scenario mesh (Phase C) ── */
|
||||
/* scenario_mesh_init() requires WiFi to be started (done in net_init). */
|
||||
ret = scenario_mesh_init(NULL); /* receive CMD only — no scenario apply */
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "scenario_mesh_init: %s — ESP-NOW unavailable",
|
||||
esp_err_to_name(ret));
|
||||
} else {
|
||||
ret = scenario_mesh_set_text_cb(on_mesh_text);
|
||||
if (ret == ESP_OK) {
|
||||
ESP_LOGI(TAG, "Phase C: ESP-NOW CMD receiver up");
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 6. Hook client (Phase D) ── */
|
||||
ret = hook_client_init(NULL); /* uses CONFIG_PLIP_MASTER_URL */
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "hook_client_init: %s", esp_err_to_name(ret));
|
||||
}
|
||||
|
||||
/* ── 7. Phone task (Phase D) ── */
|
||||
ret = phone_init();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "phone_init: %s", esp_err_to_name(ret));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Phase D: off-hook GPIO monitor up (GPIO=%d)",
|
||||
CONFIG_PLIP_HOOK_GPIO);
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Boot complete. Free heap: %lu bytes", esp_get_free_heap_size());
|
||||
ESP_LOGI(TAG, "Press BOOT button (GPIO%d) to simulate hook pickup.",
|
||||
CONFIG_PLIP_HOOK_GPIO);
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
/* ── Application entry point ─────────────────────────────────────────────── */
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "============================================");
|
||||
ESP_LOGI(TAG, " Zacus PLIP Voice Annex");
|
||||
ESP_LOGI(TAG, " Board: AI-Thinker ESP32-A1S (ES8388)");
|
||||
ESP_LOGI(TAG, " IDF: %s", esp_get_idf_version());
|
||||
ESP_LOGI(TAG, " Free heap: %lu bytes", esp_get_free_heap_size());
|
||||
ESP_LOGI(TAG, "============================================");
|
||||
|
||||
/* ── 1. NVS ── */
|
||||
esp_err_t ret = nvs_flash_init();
|
||||
if (ret == ESP_ERR_NVS_NO_FREE_PAGES ||
|
||||
ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
ret = nvs_flash_init();
|
||||
}
|
||||
ESP_ERROR_CHECK(ret);
|
||||
|
||||
/* Delegate all subsequent phases to a task with adequate stack.
|
||||
* app_main's default stack (8192) is too tight for I2S + WiFi init
|
||||
* when called from the main task (IDF internal stack usage adds up). */
|
||||
xTaskCreatePinnedToCore(boot_task, "boot", 12288, NULL, 5, NULL, 0);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* 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 <string.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/stat.h>
|
||||
#include <errno.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_wifi.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.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 (64 * 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) ─────── */
|
||||
|
||||
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();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* Write to SPIFFS under /spiffs prefix. */
|
||||
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);
|
||||
free(body);
|
||||
return send_json(req, "500 Internal Server Error", "{\"error\":\"write failed\"}");
|
||||
}
|
||||
size_t written = fwrite(body, 1, (size_t)total, f);
|
||||
fclose(f);
|
||||
free(body);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/* ── 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 = 12;
|
||||
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,
|
||||
};
|
||||
httpd_register_uri_handler(s_httpd, &uri_status);
|
||||
httpd_register_uri_handler(s_httpd, &uri_scenario);
|
||||
httpd_register_uri_handler(s_httpd, &uri_file);
|
||||
|
||||
ESP_LOGI(TAG, "httpd up on :80 (GET /status, POST /game/scenario, POST /game/file)");
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
/* net.h — WiFi STA + HTTP server for PLIP voice annex. */
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Start WiFi STA (creds from NVS or Kconfig fallback) + httpd on port 80.
|
||||
* Blocks until connected or 30 s elapses.
|
||||
* Returns ESP_OK on success; ESP_ERR_TIMEOUT if WiFi failed (board continues
|
||||
* offline — httpd is NOT started in that case). */
|
||||
esp_err_t net_init(void);
|
||||
|
||||
/* Returns true once the WiFi STA has obtained an IP address. */
|
||||
bool net_is_connected(void);
|
||||
|
||||
/* Expose the httpd handle so other modules can register additional URI
|
||||
* handlers after net_init() succeeds. Returns NULL if not yet started. */
|
||||
httpd_handle_t net_httpd_handle(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* phone.c — Off-hook GPIO debounce + ring control (Phase D).
|
||||
*
|
||||
* Mirrors PLIP_FIRMWARE/src/phone_task.cpp logic, ported to IDF C.
|
||||
* ISR sets a flag; the task reads the GPIO level after a 30 ms debounce
|
||||
* window and reports the transition via hook_client_report().
|
||||
*/
|
||||
|
||||
#include "phone.h"
|
||||
#include "audio.h"
|
||||
#include "hook_client.h"
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define TAG "phone"
|
||||
|
||||
#define DEBOUNCE_MS 30
|
||||
#define TASK_STACK 4096
|
||||
#define TASK_PRIO 5
|
||||
|
||||
static volatile bool s_edge_pending = false;
|
||||
static volatile bool s_ringing = false;
|
||||
|
||||
/* IRAM_ATTR: ISR must live in IRAM on original ESP32. */
|
||||
static void IRAM_ATTR on_hook_isr(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
s_edge_pending = true;
|
||||
}
|
||||
|
||||
void phone_ring_start(void)
|
||||
{
|
||||
if (s_ringing) return;
|
||||
s_ringing = true;
|
||||
ESP_LOGI(TAG, "ring start");
|
||||
audio_ring_start();
|
||||
}
|
||||
|
||||
void phone_ring_stop(void)
|
||||
{
|
||||
if (!s_ringing) return;
|
||||
s_ringing = false;
|
||||
ESP_LOGI(TAG, "ring stop");
|
||||
audio_stop();
|
||||
}
|
||||
|
||||
static void phone_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
const int hook_gpio = CONFIG_PLIP_HOOK_GPIO;
|
||||
|
||||
gpio_config_t io_conf = {
|
||||
.pin_bit_mask = (1ULL << hook_gpio),
|
||||
.mode = GPIO_MODE_INPUT,
|
||||
.pull_up_en = GPIO_PULLUP_ENABLE,
|
||||
.pull_down_en = GPIO_PULLDOWN_DISABLE,
|
||||
.intr_type = GPIO_INTR_ANYEDGE,
|
||||
};
|
||||
ESP_ERROR_CHECK(gpio_config(&io_conf));
|
||||
ESP_ERROR_CHECK(gpio_install_isr_service(0));
|
||||
ESP_ERROR_CHECK(gpio_isr_handler_add(hook_gpio, on_hook_isr, NULL));
|
||||
|
||||
/* Read and report initial level so master state machine is in sync. */
|
||||
int last_level = gpio_get_level(hook_gpio);
|
||||
const char *init_state = (last_level == 0) ? "off" : "on";
|
||||
ESP_LOGI(TAG, "phone task ready, hook GPIO=%d level=%d (%s)",
|
||||
hook_gpio, last_level, init_state);
|
||||
hook_client_report(init_state, "boot");
|
||||
|
||||
for (;;) {
|
||||
if (s_edge_pending) {
|
||||
s_edge_pending = false;
|
||||
vTaskDelay(pdMS_TO_TICKS(DEBOUNCE_MS));
|
||||
int level = gpio_get_level(hook_gpio);
|
||||
if (level != last_level) {
|
||||
last_level = level;
|
||||
if (level == 0) {
|
||||
/* Off-hook: handset picked up. Stop ringing if active. */
|
||||
ESP_LOGI(TAG, "off-hook (pickup) detected");
|
||||
phone_ring_stop();
|
||||
hook_client_report("off", "pickup");
|
||||
} else {
|
||||
/* On-hook: handset hung up. */
|
||||
ESP_LOGI(TAG, "on-hook (hangup) detected");
|
||||
audio_stop();
|
||||
hook_client_report("on", "hangup");
|
||||
}
|
||||
}
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(20));
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t phone_init(void)
|
||||
{
|
||||
BaseType_t ok = xTaskCreatePinnedToCore(phone_task, "phone",
|
||||
TASK_STACK, NULL,
|
||||
TASK_PRIO, NULL, 1);
|
||||
return (ok == pdPASS) ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
/*
|
||||
* phone.h — Off-hook GPIO monitor + ring control for PLIP (Phase D).
|
||||
*
|
||||
* On the ESP32-A1S dev kit: BOOT button (GPIO4 = KEY1) acts as the
|
||||
* off-hook stand-in (active LOW via INPUT_PULLUP, CONFIG_PLIP_HOOK_GPIO).
|
||||
* On the Si3210 PCB target: replace with the SLIC interrupt GPIO.
|
||||
*
|
||||
* Ring: drives audio_ring_start() / audio_stop().
|
||||
* Hook events: forwarded to hook_client_report().
|
||||
*/
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Initialise the off-hook GPIO and start the monitoring task.
|
||||
* hook_client_init() and audio_init() must have been called first. */
|
||||
esp_err_t phone_init(void);
|
||||
|
||||
/* Signal the phone to start ringing (called externally if needed). */
|
||||
void phone_ring_start(void);
|
||||
|
||||
/* Stop ringing. */
|
||||
void phone_ring_stop(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,8 @@
|
||||
# Partitions for PLIP voice firmware — AI-Thinker ESP32-A1S (4 MB flash)
|
||||
# Matches PLIP_FIRMWARE/partitions/plip_4mb.csv (OTA dual-bank + SPIFFS data).
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xe000, 0x2000,
|
||||
app0, app, ota_0, 0x10000, 0x180000,
|
||||
app1, app, ota_1, 0x190000, 0x180000,
|
||||
storage, data, spiffs, 0x310000, 0xF0000,
|
||||
|
@@ -0,0 +1,45 @@
|
||||
# Target: ESP32 (AI-Thinker ESP32-A1S, 4MB flash, 8MB PSRAM N4R8)
|
||||
CONFIG_IDF_TARGET="esp32"
|
||||
|
||||
# Flash: 4MB DIO (A1S default)
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
|
||||
CONFIG_ESPTOOLPY_FLASHFREQ_40M=y
|
||||
|
||||
# Partition table
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
||||
|
||||
# WiFi
|
||||
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=10
|
||||
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32
|
||||
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=32
|
||||
CONFIG_ESP_WIFI_NVS_ENABLED=y
|
||||
CONFIG_LWIP_LOCAL_HOSTNAME="plip"
|
||||
|
||||
# FreeRTOS
|
||||
CONFIG_FREERTOS_HZ=1000
|
||||
|
||||
# Logging
|
||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
CONFIG_LOG_COLORS=y
|
||||
|
||||
# HTTPD
|
||||
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
|
||||
CONFIG_HTTPD_MAX_URI_LEN=512
|
||||
|
||||
# Watchdog — allow for codec init time
|
||||
CONFIG_ESP_TASK_WDT_TIMEOUT_S=15
|
||||
|
||||
# FreeRTOS IDLE task stack — increase from default 1536 to avoid overflow
|
||||
# when I2S/WiFi interrupts spill into idle stack on original ESP32.
|
||||
CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=2048
|
||||
|
||||
# WiFi credentials — set via `idf.py menuconfig` (PLIP Voice Configuration)
|
||||
# or override locally in sdkconfig (NOT committed).
|
||||
# CONFIG_PLIP_WIFI_SSID="..."
|
||||
# CONFIG_PLIP_WIFI_PASSWORD="..."
|
||||
# CONFIG_PLIP_WIFI_CHANNEL=11
|
||||
|
||||
# Zacus master URL — default matches the lab IP
|
||||
# CONFIG_PLIP_MASTER_URL="http://192.168.0.188"
|
||||
Reference in New Issue
Block a user