Merge pull request 'feat(plip): téléphone France Télécom — stage 1 (tonalités + dialer + machine à états)' (#20) from feat/plip-phone-stage1 into main
CI / platformio (push) Failing after 4m23s

This commit was merged in pull request #20.
This commit is contained in:
2026-06-14 20:17:25 +00:00
11 changed files with 593 additions and 21 deletions
+4
View File
@@ -7,6 +7,9 @@ idf_component_register(
"cmd_exec.c"
"hook_client.c"
"phone.c"
"tones.c"
"dialer.c"
"conversation.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
@@ -14,6 +17,7 @@ idf_component_register(
esp_http_client
esp_http_server
esp_netif
esp_timer
esp_wifi
json
nvs_flash
+19
View File
@@ -43,4 +43,23 @@ menu "PLIP Voice Configuration"
GPIO that signals handset off-hook (active LOW via INPUT_PULLUP).
Dev kit uses BOOT button (GPIO4 / KEY1). PCB uses Si3210 INT.
config PLIP_DIAL_PULSE
bool "Enable rotary dial pulse decoding on SHK GPIO"
default y
help
When enabled, brief open/close pulses on the hook GPIO (from a
rotary dial) are decoded into digits and pushed to the dialer.
Each pulse train: ~60-100 ms open + ~40 ms closed; 10 pulses = digit 0.
A gap > PLIP_DIAL_PULSE_MAX_GAP_MS terminates the digit.
config PLIP_DIAL_PULSE_MAX_GAP_MS
int "Rotary pulse inter-digit gap (ms)"
default 200
depends on PLIP_DIAL_PULSE
range 100 500
help
If the hook GPIO stays closed for more than this duration after
a pulse train, the train is considered complete and the digit is
emitted. 200 ms is standard for French rotary dials.
endmenu
+155
View File
@@ -0,0 +1,155 @@
/*
* conversation.c — PLIP telephone conversation state machine.
*
* States: IDLE → DIALTONE → DIALING → RINGBACK | BUSY
*
* Transitions:
* IDLE + off-hook → DIALTONE (tones_dialtone_start)
* DIALTONE + first digit → DIALING (tones_stop)
* DIALING + ms_since_last > 3000 → RINGBACK or BUSY (route decision)
* any state + on-hook → IDLE (tones_stop, audio_stop, PA off)
*
* Routing table (known numbers → ringback; unknown → busy after 3 s silence):
* "12", "3615", "15", "17", "18", "0142738200"
*/
#include "conversation.h"
#include "dialer.h"
#include "tones.h"
#include "audio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include <string.h>
#define TAG "conversation"
typedef enum {
STATE_IDLE,
STATE_DIALTONE,
STATE_DIALING,
STATE_RINGBACK,
STATE_BUSY,
} conv_state_t;
static volatile conv_state_t s_state = STATE_IDLE;
static volatile bool s_offhook = false;
static volatile bool s_hook_changed = false;
/* Known numbers: ringback when dialed */
static const char *KNOWN[] = {
"12", "3615", "15", "17", "18", "0142738200", NULL
};
static bool is_known(const char *num)
{
for (int i = 0; KNOWN[i] != NULL; i++) {
if (strcmp(num, KNOWN[i]) == 0) return true;
}
return false;
}
static void go_idle(void)
{
tones_stop();
audio_stop();
audio_pa_set(false);
dialer_reset();
s_state = STATE_IDLE;
ESP_LOGI(TAG, "-> IDLE");
}
static void conv_task(void *arg)
{
(void)arg;
ESP_LOGI(TAG, "conversation task ready");
for (;;) {
vTaskDelay(pdMS_TO_TICKS(50));
/* Handle hook change events */
if (s_hook_changed) {
s_hook_changed = false;
if (!s_offhook) {
/* On-hook: always go idle regardless of current state */
if (s_state != STATE_IDLE) {
ESP_LOGI(TAG, "on-hook -> IDLE");
go_idle();
}
continue;
} else {
/* Off-hook: start dial tone if we were idle */
if (s_state == STATE_IDLE) {
dialer_reset();
tones_dialtone_start();
s_state = STATE_DIALTONE;
ESP_LOGI(TAG, "off-hook -> DIALTONE");
}
continue;
}
}
/* State machine polling */
switch (s_state) {
case STATE_IDLE:
break; /* nothing to do */
case STATE_DIALTONE:
if (!s_offhook) {
go_idle();
break;
}
/* First digit received → stop dialtone, enter dialing */
if (!dialer_idle()) {
tones_stop();
s_state = STATE_DIALING;
ESP_LOGI(TAG, "first digit -> DIALING");
}
break;
case STATE_DIALING:
if (!s_offhook) {
go_idle();
break;
}
/* Wait for 3 s of silence after last digit, then route */
if (dialer_ms_since_last() > 3000) {
const char *num = dialer_current();
if (is_known(num)) {
ESP_LOGI(TAG, "route %s -> known (ringback)", num);
tones_ringback_start();
s_state = STATE_RINGBACK;
} else {
ESP_LOGI(TAG, "route %s -> unknown (busy)", num);
tones_busy_start();
s_state = STATE_BUSY;
}
}
break;
case STATE_RINGBACK:
case STATE_BUSY:
if (!s_offhook) {
go_idle();
}
break;
}
}
}
void conversation_init(void)
{
s_state = STATE_IDLE;
s_offhook = false;
s_hook_changed = false;
xTaskCreate(conv_task, "conv", 3072, NULL, 4, NULL);
ESP_LOGI(TAG, "conversation init");
}
void conversation_on_hook_change(bool offhook)
{
s_offhook = offhook;
s_hook_changed = true;
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <stdbool.h>
void conversation_init(void);
void conversation_on_hook_change(bool offhook);
+73
View File
@@ -0,0 +1,73 @@
/*
* dialer.c — Digit accumulator for the PLIP telephone state machine.
*
* Digits are pushed from two sources:
* 1. HTTP /debug/dial?number=NNNN (net.c handler)
* 2. Rotary pulse detection (phone.c, CONFIG_PLIP_DIAL_PULSE)
*
* Thread-safety: all state is protected by atomic operations on s_last_us
* and simple writes to s_len / s_num (called from a single phone task or
* HTTP task at a time — no concurrent push expected).
*/
#include "dialer.h"
#include "esp_log.h"
#include "esp_timer.h"
#include <string.h>
#include <stdint.h>
#define TAG "dialer"
#define MAX_DIGITS 12
static char s_num[MAX_DIGITS + 1];
static int s_len;
static int64_t s_last_us; /* esp_timer_get_time() at last push */
void dialer_init(void)
{
memset(s_num, 0, sizeof(s_num));
s_len = 0;
s_last_us = 0;
ESP_LOGI(TAG, "dialer init");
}
void dialer_push_digit(int d)
{
if (d < 0 || d > 9) return;
if (s_len >= MAX_DIGITS) return; /* silently discard overflow */
s_num[s_len++] = (char)('0' + d);
s_num[s_len] = '\0';
s_last_us = esp_timer_get_time();
ESP_LOGI(TAG, "digit %d -> number so far: \"%s\"", d, s_num);
}
void dialer_reset(void)
{
memset(s_num, 0, sizeof(s_num));
s_len = 0;
s_last_us = 0;
ESP_LOGI(TAG, "dialer reset");
}
const char *dialer_current(void)
{
return s_num;
}
bool dialer_idle(void)
{
return s_len == 0;
}
int dialer_ms_since_last(void)
{
if (s_last_us == 0) return 0;
int64_t elapsed_us = esp_timer_get_time() - s_last_us;
int ms = (int)(elapsed_us / 1000);
return (ms < 0) ? 0 : ms;
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <stdbool.h>
void dialer_init(void);
void dialer_push_digit(int d);
void dialer_reset(void);
const char *dialer_current(void);
bool dialer_idle(void);
int dialer_ms_since_last(void);
+7
View File
@@ -30,6 +30,8 @@
#include "hook_client.h"
#include "phone.h"
#include "scenario_mesh.h"
#include "dialer.h"
#include "conversation.h"
static const char *TAG = "plip-main";
@@ -101,6 +103,11 @@ static void boot_task(void *arg)
ESP_LOGW(TAG, "hook_client_init: %s", esp_err_to_name(ret));
}
/* ── Stage 1: dialer + conversation state machine ── */
dialer_init();
conversation_init();
ESP_LOGI(TAG, "Stage 1: dialer + conversation state machine up");
/* ── 7. Phone task (Phase D) ── */
ret = phone_init();
if (ret != ESP_OK) {
+34 -2
View File
@@ -15,6 +15,7 @@
*/
#include "net.h"
#include "dialer.h"
#include <inttypes.h>
#include <stdlib.h>
@@ -428,6 +429,32 @@ static esp_err_t handle_cmd_post(httpd_req_t *req)
return send_json(req, "200 OK", "{\"ok\":true}");
}
/* ── GET /debug/dial?number=NNNN (push digits into the dialer) ───────────── */
static esp_err_t handle_debug_dial(httpd_req_t *req)
{
char query[64] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char number[16] = {0};
if (httpd_query_key_value(query, "number", number, sizeof(number)) != ESP_OK) {
return send_json(req, "400 Bad Request", "{\"error\":\"missing number param\"}");
}
int pushed = 0;
for (int i = 0; number[i] != '\0' && i < 12; i++) {
if (number[i] >= '0' && number[i] <= '9') {
dialer_push_digit(number[i] - '0');
pushed++;
}
}
char resp[64];
snprintf(resp, sizeof(resp), "{\"ok\":true,\"pushed\":%d,\"number\":\"%s\"}",
pushed, dialer_current());
ESP_LOGI(TAG, "debug/dial: pushed %d digits -> \"%s\"", pushed, dialer_current());
return send_json(req, "200 OK", resp);
}
/* ── Public API ───────────────────────────────────────────────────────────── */
bool net_is_connected(void)
@@ -500,7 +527,7 @@ esp_err_t net_init(void)
/* Start HTTP server. */
httpd_config_t hcfg = HTTPD_DEFAULT_CONFIG();
hcfg.server_port = 80;
hcfg.max_uri_handlers = 12;
hcfg.max_uri_handlers = 14;
hcfg.stack_size = 8192;
esp_err_t ret = httpd_start(&s_httpd, &hcfg);
@@ -533,13 +560,18 @@ esp_err_t net_init(void)
.uri = "/debug/regs", .method = HTTP_GET,
.handler = handle_debug_regs, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_dial = {
.uri = "/debug/dial", .method = HTTP_GET,
.handler = handle_debug_dial, .user_ctx = NULL,
};
httpd_register_uri_handler(s_httpd, &uri_status);
httpd_register_uri_handler(s_httpd, &uri_scenario);
httpd_register_uri_handler(s_httpd, &uri_file);
httpd_register_uri_handler(s_httpd, &uri_cmd);
httpd_register_uri_handler(s_httpd, &uri_capture);
httpd_register_uri_handler(s_httpd, &uri_debug_regs);
httpd_register_uri_handler(s_httpd, &uri_debug_dial);
ESP_LOGI(TAG, "httpd up on :80 (GET /status, GET /debug/regs, POST /game/scenario, POST /game/file, POST /game/cmd, POST /voice/capture)");
ESP_LOGI(TAG, "httpd up on :80 (GET /status, GET /debug/regs, GET /debug/dial, POST /game/scenario, POST /game/file, POST /game/cmd, POST /voice/capture)");
return ESP_OK;
}
+141 -19
View File
@@ -1,25 +1,41 @@
/*
* phone.c — Off-hook GPIO debounce + ring control (Phase D).
* phone.c — Off-hook GPIO debounce + rotary pulse decoding + ring control.
*
* 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().
* ISR sets a flag; the task reads the GPIO level after a short debounce
* and reports the transition via hook_client_report() and
* conversation_on_hook_change().
*
* Rotary pulse decoding (CONFIG_PLIP_DIAL_PULSE):
* While off-hook, a rotary dial produces brief open/close pulses on the
* SHK line (~60-100 ms open per pulse). A real hangup holds the line
* open for >500 ms. The task polls the GPIO at 5 ms intervals to catch
* pulses that would be missed by the 30 ms debounce.
*
* Pulse train end: if the line stays closed for > PLIP_DIAL_PULSE_MAX_GAP_MS
* after at least one pulse, the count is emitted as a digit (10 pulses = 0).
*/
#include "phone.h"
#include "audio.h"
#include "hook_client.h"
#include "conversation.h"
#if CONFIG_PLIP_DIAL_PULSE
#include "dialer.h"
#endif
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define TAG "phone"
#define DEBOUNCE_MS 30
#define TASK_STACK 4096
#define TASK_PRIO 5
#define DEBOUNCE_MS 30
#define HANGUP_THRESHOLD_MS 500 /* open > this → real hangup, not a pulse */
#define TASK_STACK 4096
#define TASK_PRIO 5
static volatile bool s_edge_pending = false;
static volatile bool s_ringing = false;
@@ -48,6 +64,12 @@ void phone_ring_stop(void)
audio_stop();
}
static void report_offhook(bool offhook)
{
conversation_on_hook_change(offhook);
hook_client_report(offhook ? "off" : "on", offhook ? "pickup" : "hangup");
}
static void phone_task(void *arg)
{
(void)arg;
@@ -66,48 +88,148 @@ static void phone_task(void *arg)
/* 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";
s_offhook = (last_level == 0);
ESP_LOGI(TAG, "phone task ready, hook GPIO=%d level=%d (%s)",
hook_gpio, last_level, init_state);
hook_gpio, last_level, s_offhook ? "off-hook" : "on-hook");
/* Set PA state to match initial hook position.
* On-hook at boot → mute PA so no audio escapes while handset is down. */
if (!s_offhook) {
ESP_LOGI(TAG, "boot: on-hook PA off (mute)");
ESP_LOGI(TAG, "boot: on-hook -> PA off (mute)");
audio_pa_set(false);
} else {
ESP_LOGI(TAG, "boot: off-hook PA on (unmute)");
ESP_LOGI(TAG, "boot: off-hook -> PA on (unmute)");
audio_pa_set(true);
}
hook_client_report(init_state, "boot");
hook_client_report(s_offhook ? "off" : "on", "boot");
conversation_on_hook_change(s_offhook);
#if CONFIG_PLIP_DIAL_PULSE
/* Rotary pulse state */
int pulse_count = 0;
bool in_pulse = false; /* GPIO is currently high (open) */
int64_t pulse_open_us = 0; /* timestamp when GPIO went high */
int64_t last_close_us = 0; /* timestamp when GPIO last went low (closed) */
#endif
for (;;) {
if (s_edge_pending) {
s_edge_pending = false;
#if CONFIG_PLIP_DIAL_PULSE
/* While off-hook, use fast polling instead of 30ms debounce
* so rotary pulses (~60-100ms) are not missed. */
if (s_offhook) {
/* The ISR fired — re-read immediately to catch rising edge. */
vTaskDelay(pdMS_TO_TICKS(5)); /* minimal settle */
int level = gpio_get_level(hook_gpio);
if (level == 1 && last_level == 0) {
/* Rising edge: GPIO went open */
last_level = 1;
in_pulse = true;
pulse_open_us = esp_timer_get_time();
ESP_LOGD(TAG, "pulse: open");
} else if (level == 0 && last_level == 1) {
/* Falling edge: GPIO closed again */
last_level = 0;
last_close_us = esp_timer_get_time();
int64_t open_dur_ms = (last_close_us - pulse_open_us) / 1000;
if (in_pulse && open_dur_ms < HANGUP_THRESHOLD_MS) {
/* Short open: count as a rotary pulse */
pulse_count++;
in_pulse = false;
ESP_LOGD(TAG, "pulse %d (open %"PRId64"ms)", pulse_count, open_dur_ms);
} else if (open_dur_ms >= HANGUP_THRESHOLD_MS) {
/* Was open too long: treat as hangup */
ESP_LOGI(TAG, "on-hook (hangup) after %"PRId64"ms open", open_dur_ms);
pulse_count = 0;
in_pulse = false;
s_offhook = false;
audio_stop();
audio_pa_set(false);
report_offhook(false);
}
}
/* Don't fall through to the standard debounce below */
goto poll_sleep;
}
#endif
/* Standard 30 ms debounce for on-hook state transitions */
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. Enable PA, stop ringing. */
/* Off-hook: handset picked up. */
s_offhook = true;
#if CONFIG_PLIP_DIAL_PULSE
pulse_count = 0;
in_pulse = false;
last_close_us = esp_timer_get_time();
#endif
ESP_LOGI(TAG, "off-hook (pickup) detected — PA on");
audio_pa_set(true);
phone_ring_stop();
hook_client_report("off", "pickup");
report_offhook(true);
} else {
/* On-hook: handset hung up. Stop playback, kill PA. */
/* On-hook: handset hung up. */
s_offhook = false;
ESP_LOGI(TAG, "on-hook (hangup) detected — PA off, stopping audio");
audio_stop();
audio_pa_set(false);
hook_client_report("on", "hangup");
report_offhook(false);
}
}
}
vTaskDelay(pdMS_TO_TICKS(20));
#if CONFIG_PLIP_DIAL_PULSE
/* Check for inter-digit gap: if we accumulated pulses and the line
* has been closed (quiet) for > MAX_GAP_MS, emit the digit. */
if (s_offhook && pulse_count > 0 && !in_pulse && last_close_us > 0) {
int64_t gap_ms = (esp_timer_get_time() - last_close_us) / 1000;
if (gap_ms > CONFIG_PLIP_DIAL_PULSE_MAX_GAP_MS) {
if (pulse_count > 10) {
ESP_LOGW(TAG, "bad pulse count %d, ignored", pulse_count);
pulse_count = 0;
} else {
int digit = (pulse_count == 10) ? 0 : pulse_count;
ESP_LOGI(TAG, "rotary digit: %d pulses -> %d", pulse_count, digit);
dialer_push_digit(digit);
pulse_count = 0;
}
}
}
/* Also detect prolonged open (hangup) even if no more edges arrive */
if (s_offhook && in_pulse && pulse_open_us > 0) {
int64_t open_ms = (esp_timer_get_time() - pulse_open_us) / 1000;
if (open_ms >= HANGUP_THRESHOLD_MS) {
int level_now = gpio_get_level(hook_gpio);
if (level_now == 1) {
ESP_LOGI(TAG, "on-hook (prolonged open %"PRId64"ms) detected", open_ms);
last_level = 1;
pulse_count = 0;
in_pulse = false;
s_offhook = false;
audio_stop();
audio_pa_set(false);
report_offhook(false);
} else {
/* GPIO closed already but ISR missed it */
last_level = 0;
last_close_us = esp_timer_get_time();
if ((esp_timer_get_time() - pulse_open_us) / 1000 < HANGUP_THRESHOLD_MS) {
pulse_count++;
}
in_pulse = false;
}
}
}
poll_sleep:
#endif
vTaskDelay(pdMS_TO_TICKS(10));
}
}
+139
View File
@@ -0,0 +1,139 @@
/*
* tones.c — France Télécom telephone tones (dial / busy / ringback).
*
* Generates 440 Hz sinusoid with on/off cadence written directly to the
* I2S speaker handle (stereo — ES8388 requires L+R pairs).
*
* Cadences (all at 440 Hz):
* DIAL : continuous
* BUSY : 0.5 s on / 0.5 s off (1 s cycle)
* RINGBACK : 1.5 s on / 3.5 s off (5 s cycle)
*/
#include "tones.h"
#include "audio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2s_std.h"
#include "esp_log.h"
#include <math.h>
#include <string.h>
#include <stdbool.h>
#define TAG "tones"
typedef enum { TONE_NONE, TONE_DIAL, TONE_BUSY, TONE_RINGBACK } tone_mode_t;
static volatile tone_mode_t s_mode = TONE_NONE;
static volatile bool s_tone_idle = true; /* true when tone_task is NOT in i2s_channel_write */
static TaskHandle_t s_task = NULL;
#define SR 16000 /* sample rate (must match audio_init) */
#define FRAME 320 /* 20 ms @ 16 kHz */
/*
* Fill `n` stereo frames (L=R = 440 Hz sine) into buf.
* buf must hold n * 2 int16_t (stereo interleaved).
* ph: running phase accumulator (mono sample index).
*/
static void fill_440_stereo(int16_t *buf, int n, int *ph)
{
for (int i = 0; i < n; i++) {
int16_t v = (int16_t)(8000.0f * sinf(2.0f * (float)M_PI * 440.0f * (*ph) / SR));
buf[i * 2] = v; /* L */
buf[i * 2 + 1] = v; /* R */
(*ph)++;
}
}
static void tone_task(void *a)
{
(void)a;
/* Stereo buffer: FRAME mono samples × 2 channels × 2 bytes = FRAME*4 bytes */
int16_t buf[FRAME * 2];
int ph = 0;
int64_t t = 0; /* ms elapsed in current tone state */
for (;;) {
tone_mode_t m = s_mode;
if (m == TONE_NONE) {
s_tone_idle = true;
vTaskDelay(pdMS_TO_TICKS(20));
t = 0;
ph = 0;
continue;
}
bool on = true;
if (m == TONE_BUSY) {
on = (t % 1000) < 500; /* 500ms on / 500ms off */
} else if (m == TONE_RINGBACK) {
on = (t % 5000) < 1500; /* 1.5s on / 3.5s off */
}
/* TONE_DIAL: always on */
if (on) {
fill_440_stereo(buf, FRAME, &ph);
} else {
memset(buf, 0, sizeof(buf));
/* Keep phase accumulator advancing so pitch is smooth on resumption */
ph += FRAME;
}
size_t written = 0;
s_tone_idle = false;
i2s_channel_write(audio_spk_handle(), buf, sizeof(buf), &written, pdMS_TO_TICKS(50));
t += 20; /* each frame = 20 ms */
}
}
static void ensure_task(void)
{
if (!s_task) {
xTaskCreate(tone_task, "tones", 3072, NULL, 5, &s_task);
ESP_LOGI(TAG, "tone_task created");
}
}
void tones_dialtone_start(void)
{
ESP_LOGI(TAG, "dial tone start");
audio_pa_set(true);
ensure_task();
s_mode = TONE_DIAL;
}
void tones_busy_start(void)
{
ESP_LOGI(TAG, "busy tone start");
audio_pa_set(true);
ensure_task();
s_mode = TONE_BUSY;
}
void tones_ringback_start(void)
{
ESP_LOGI(TAG, "ringback tone start");
audio_pa_set(true);
ensure_task();
s_mode = TONE_RINGBACK;
}
void tones_stop(void)
{
ESP_LOGI(TAG, "tones stop");
s_mode = TONE_NONE;
/* Wait until tone_task is no longer inside i2s_channel_write (I2S arbitration).
* Guard: max 200 ms so we never block indefinitely. */
const int max_wait_ms = 200;
int waited_ms = 0;
while (!s_tone_idle && waited_ms < max_wait_ms) {
vTaskDelay(pdMS_TO_TICKS(5));
waited_ms += 5;
}
if (waited_ms >= max_wait_ms) {
ESP_LOGW(TAG, "tones_stop: timeout waiting for tone_task idle");
}
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include "esp_err.h"
void tones_dialtone_start(void); /* 440 Hz continuous */
void tones_busy_start(void); /* 440 Hz 0.5s on/off */
void tones_ringback_start(void); /* 440 Hz 1.5s/3.5s */
void tones_stop(void);