From 309005360b08667a3c387e87ca4ac5e6c0b22893 Mon Sep 17 00:00:00 2001 From: electron-rare Date: Sun, 14 Jun 2026 13:29:33 +0200 Subject: [PATCH] feat(plip_voice): ESP-IDF port of PLIP retro telephone annex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New IDF project targeting AI-Thinker ESP32-A1S Audio Kit (ESP32, ES8388 codec). Replaces the Arduino PLIP_FIRMWARE with a proper IDF component structure aligned on box3_voice / scenario_mesh infrastructure. Phases implemented and hardware-verified: - A: ES8388 I2C init + I2S TX; 440 Hz test tone exits the speaker - B: WiFi STA + httpd :80 (GET /status, POST /game/scenario, /game/file) - C: ESP-NOW CMD receiver via shared scenario_mesh lib; op=play dispatched to audio; screen/evt/led logged (no display on PLIP) - D: Off-hook GPIO monitor (GPIO4=BOOT button stand-in); ring tone cadence; POST /voice/hook to master on pickup/hangup (200 OK confirmed) MAC (first observed): a0:b7:65:e7:f6:44 IP (DHCP): 192.168.0.138 Files: plip_voice/CMakeLists.txt - project root; EXTRA_COMPONENT_DIRS=../lib/scenario_mesh plip_voice/partitions.csv - 4MB OTA dual-bank + SPIFFS (matches PLIP_FIRMWARE) plip_voice/sdkconfig.defaults - ESP32 target, 4MB flash DIO, ch=11 hint (no creds) plip_voice/main/Kconfig.projbuild - PLIP_ menu (SSID/pwd/channel/master_url/hook_gpio) plip_voice/main/board_config.h - A1S pin map (I2C 32/33, I2S 0/27/25/26/35, PA=21) plip_voice/main/es8388.[ch] - ES8388 I2C register init sequence (legacy i2c driver) plip_voice/main/audio.[ch] - I2S TX + async play queue + ring task plip_voice/main/net.[ch] - WiFi STA + httpd (NVS creds with Kconfig fallback) plip_voice/main/cmd_exec.[ch] - D5 CMD executor (play→audio; screen/evt/led→log) plip_voice/main/hook_client.[ch] - POST /voice/hook via esp_http_client plip_voice/main/phone.[ch] - Off-hook GPIO ISR + debounce + ring control plip_voice/main/main.c - app_main boot sequence (all four phases) Build: IDF 5.4.4, target esp32, clean. Flash: /dev/cu.usbserial-0001, 4MB flash verified. --- plip_voice/CMakeLists.txt | 8 + plip_voice/main/CMakeLists.txt | 24 ++ plip_voice/main/Kconfig.projbuild | 46 ++++ plip_voice/main/audio.c | 443 ++++++++++++++++++++++++++++++ plip_voice/main/audio.h | 48 ++++ plip_voice/main/board_config.h | 42 +++ plip_voice/main/cmd_exec.c | 87 ++++++ plip_voice/main/cmd_exec.h | 28 ++ plip_voice/main/es8388.c | 204 ++++++++++++++ plip_voice/main/es8388.h | 40 +++ plip_voice/main/hook_client.c | 159 +++++++++++ plip_voice/main/hook_client.h | 28 ++ plip_voice/main/idf_component.yml | 6 + plip_voice/main/main.c | 143 ++++++++++ plip_voice/main/net.c | 323 ++++++++++++++++++++++ plip_voice/main/net.h | 26 ++ plip_voice/main/phone.c | 103 +++++++ plip_voice/main/phone.h | 31 +++ plip_voice/partitions.csv | 8 + plip_voice/sdkconfig.defaults | 45 +++ 20 files changed, 1842 insertions(+) create mode 100644 plip_voice/CMakeLists.txt create mode 100644 plip_voice/main/CMakeLists.txt create mode 100644 plip_voice/main/Kconfig.projbuild create mode 100644 plip_voice/main/audio.c create mode 100644 plip_voice/main/audio.h create mode 100644 plip_voice/main/board_config.h create mode 100644 plip_voice/main/cmd_exec.c create mode 100644 plip_voice/main/cmd_exec.h create mode 100644 plip_voice/main/es8388.c create mode 100644 plip_voice/main/es8388.h create mode 100644 plip_voice/main/hook_client.c create mode 100644 plip_voice/main/hook_client.h create mode 100644 plip_voice/main/idf_component.yml create mode 100644 plip_voice/main/main.c create mode 100644 plip_voice/main/net.c create mode 100644 plip_voice/main/net.h create mode 100644 plip_voice/main/phone.c create mode 100644 plip_voice/main/phone.h create mode 100644 plip_voice/partitions.csv create mode 100644 plip_voice/sdkconfig.defaults diff --git a/plip_voice/CMakeLists.txt b/plip_voice/CMakeLists.txt new file mode 100644 index 0000000..9233ba6 --- /dev/null +++ b/plip_voice/CMakeLists.txt @@ -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) diff --git a/plip_voice/main/CMakeLists.txt b/plip_voice/main/CMakeLists.txt new file mode 100644 index 0000000..9c79a3e --- /dev/null +++ b/plip_voice/main/CMakeLists.txt @@ -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 +) diff --git a/plip_voice/main/Kconfig.projbuild b/plip_voice/main/Kconfig.projbuild new file mode 100644 index 0000000..e89061c --- /dev/null +++ b/plip_voice/main/Kconfig.projbuild @@ -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 /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 diff --git a/plip_voice/main/audio.c b/plip_voice/main/audio.c new file mode 100644 index 0000000..774069e --- /dev/null +++ b/plip_voice/main/audio.c @@ -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 +#include +#include +#include + +#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; +} diff --git a/plip_voice/main/audio.h b/plip_voice/main/audio.h new file mode 100644 index 0000000..92d99ae --- /dev/null +++ b/plip_voice/main/audio.h @@ -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 +#include +#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/.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 diff --git a/plip_voice/main/board_config.h b/plip_voice/main/board_config.h new file mode 100644 index 0000000..38484f2 --- /dev/null +++ b/plip_voice/main/board_config.h @@ -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) */ diff --git a/plip_voice/main/cmd_exec.c b/plip_voice/main/cmd_exec.c new file mode 100644 index 0000000..08c741f --- /dev/null +++ b/plip_voice/main/cmd_exec.c @@ -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 + +#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 : ""); + 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 : ""); + 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; +} diff --git a/plip_voice/main/cmd_exec.h b/plip_voice/main/cmd_exec.h new file mode 100644 index 0000000..3921360 --- /dev/null +++ b/plip_voice/main/cmd_exec.h @@ -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 + +#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 diff --git a/plip_voice/main/es8388.c b/plip_voice/main/es8388.c new file mode 100644 index 0000000..f2329a5 --- /dev/null +++ b/plip_voice/main/es8388.c @@ -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 + +#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); +} diff --git a/plip_voice/main/es8388.h b/plip_voice/main/es8388.h new file mode 100644 index 0000000..cb25aad --- /dev/null +++ b/plip_voice/main/es8388.h @@ -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 +#include +#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 diff --git a/plip_voice/main/hook_client.c b/plip_voice/main/hook_client.c new file mode 100644 index 0000000..dc6c6fd --- /dev/null +++ b/plip_voice/main/hook_client.c @@ -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 +#include + +#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: /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; +} diff --git a/plip_voice/main/hook_client.h b/plip_voice/main/hook_client.h new file mode 100644 index 0000000..62d2ce1 --- /dev/null +++ b/plip_voice/main/hook_client.h @@ -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 /voice/hook { "state": "off"|"on", "reason": "..." } + */ + +#include "esp_err.h" +#include + +#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 diff --git a/plip_voice/main/idf_component.yml b/plip_voice/main/idf_component.yml new file mode 100644 index 0000000..1cbe50b --- /dev/null +++ b/plip_voice/main/idf_component.yml @@ -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" diff --git a/plip_voice/main/main.c b/plip_voice/main/main.c new file mode 100644 index 0000000..5648291 --- /dev/null +++ b/plip_voice/main/main.c @@ -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 +#include + +#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:///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); +} diff --git a/plip_voice/main/net.c b/plip_voice/main/net.c new file mode 100644 index 0000000..52ea76d --- /dev/null +++ b/plip_voice/main/net.c @@ -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 +#include +#include +#include + +#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; +} diff --git a/plip_voice/main/net.h b/plip_voice/main/net.h new file mode 100644 index 0000000..1d2047b --- /dev/null +++ b/plip_voice/main/net.h @@ -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 + +#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 diff --git a/plip_voice/main/phone.c b/plip_voice/main/phone.c new file mode 100644 index 0000000..1d1b5b6 --- /dev/null +++ b/plip_voice/main/phone.c @@ -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; +} diff --git a/plip_voice/main/phone.h b/plip_voice/main/phone.h new file mode 100644 index 0000000..97753a8 --- /dev/null +++ b/plip_voice/main/phone.h @@ -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 diff --git a/plip_voice/partitions.csv b/plip_voice/partitions.csv new file mode 100644 index 0000000..6ab89ae --- /dev/null +++ b/plip_voice/partitions.csv @@ -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, diff --git a/plip_voice/sdkconfig.defaults b/plip_voice/sdkconfig.defaults new file mode 100644 index 0000000..02cd9df --- /dev/null +++ b/plip_voice/sdkconfig.defaults @@ -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"