diff --git a/box3_voice/main/CMakeLists.txt b/box3_voice/main/CMakeLists.txt index 317c840..c931303 100644 --- a/box3_voice/main/CMakeLists.txt +++ b/box3_voice/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" + SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" INCLUDE_DIRS "." PRIV_REQUIRES driver diff --git a/box3_voice/main/main.c b/box3_voice/main/main.c index ffea8de..70fea23 100644 --- a/box3_voice/main/main.c +++ b/box3_voice/main/main.c @@ -27,6 +27,7 @@ #include "voice_ws_client.h" #include "scenario_server.h" #include "plip_virtual.h" +#include "plip_ui.h" #include "scenario_mesh.h" /* BSP header — provided by espressif/esp-box component */ @@ -399,6 +400,9 @@ void app_main(void) * BOOT button as the virtual hook switch. */ if (plip_virtual_init(scenario_server_handle(), s_spk_handle) != ESP_OK) { ESP_LOGW(TAG, "plip_virtual_init failed — virtual phone unavailable"); + } else if (plip_ui_init() != ESP_OK) { + /* REST/ESP-NOW phone still works headless if the UI fails. */ + ESP_LOGW(TAG, "plip_ui_init failed — on-screen phone unavailable"); } } diff --git a/box3_voice/main/plip_ui.c b/box3_voice/main/plip_ui.c new file mode 100644 index 0000000..35b7e23 --- /dev/null +++ b/box3_voice/main/plip_ui.c @@ -0,0 +1,204 @@ +// plip_ui — implementation. See plip_ui.h for the design. +// +// LVGL 9 widget tree, built once under bsp_display_lock(). The plip_virtual +// state callback can fire from any task, so the callback re-takes the lock +// before touching widgets. Touch events (keypad, bottom button) already run +// in the LVGL task context, so they call plip_virtual_* directly. + +#include "plip_ui.h" + +#include + +#include "bsp/esp-box.h" +#include "esp_log.h" +#include "lvgl.h" + +#include "plip_virtual.h" + +#define TAG "plip_ui" +#define LOCK_MS 1000 + +static bool s_built = false; +static lv_obj_t *s_phone_view; // idle / ringing illustration +static lv_obj_t *s_keypad_view; // off-hook dial pad +static lv_obj_t *s_status_lbl; // line under the phone glyph +static lv_obj_t *s_number_lbl; // dialed digits +static lv_obj_t *s_hook_btn; // fixed bottom button +static lv_obj_t *s_hook_lbl; // its label +static char s_number[21] = ""; + +static const char *KEYS[] = { + "1", "2", "3", + "4", "5", "6", + "7", "8", "9", + "*", "0", "#", +}; + +// ---------- touch event handlers (LVGL task context) ---------- + +static void key_event_cb(lv_event_t *e) +{ + const char *key = (const char *) lv_event_get_user_data(e); + size_t len = strlen(s_number); + if (len < sizeof(s_number) - 1) { + s_number[len] = key[0]; + s_number[len + 1] = '\0'; + lv_label_set_text(s_number_lbl, s_number); + // Report the running number to the master; "#" finalises (drop it + // from the buffer first). The master decides what a valid number is. + if (key[0] == '#' && len > 0) { + s_number[len] = '\0'; + lv_label_set_text(s_number_lbl, s_number); + } + plip_virtual_dial(s_number); + } +} + +static void hook_btn_event_cb(lv_event_t *e) +{ + (void) e; + switch (plip_virtual_state()) { + case PLIP_HOOK_RINGING: plip_virtual_pickup(); break; + case PLIP_HOOK_OFF: plip_virtual_hangup(); break; + default: break; // idle: button is disabled, nothing to do + } +} + +// ---------- view switching (must hold the display lock) ---------- + +static void apply_state_locked(plip_hook_state_t st) +{ + switch (st) { + case PLIP_HOOK_OFF: + s_number[0] = '\0'; + lv_label_set_text(s_number_lbl, ""); + lv_obj_add_flag(s_phone_view, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(s_keypad_view, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(s_hook_lbl, "Raccrocher"); + lv_obj_set_style_bg_color(s_hook_btn, lv_palette_main(LV_PALETTE_RED), 0); + lv_obj_clear_state(s_hook_btn, LV_STATE_DISABLED); + break; + case PLIP_HOOK_RINGING: + lv_obj_clear_flag(s_phone_view, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_keypad_view, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(s_status_lbl, "Appel entrant…"); + lv_label_set_text(s_hook_lbl, "Décrocher"); + lv_obj_set_style_bg_color(s_hook_btn, lv_palette_main(LV_PALETTE_GREEN), 0); + lv_obj_clear_state(s_hook_btn, LV_STATE_DISABLED); + break; + case PLIP_HOOK_ON: + default: + lv_obj_clear_flag(s_phone_view, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_keypad_view, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(s_status_lbl, "PLIP — en attente"); + lv_label_set_text(s_hook_lbl, "—"); + lv_obj_set_style_bg_color(s_hook_btn, lv_palette_darken(LV_PALETTE_GREY, 2), 0); + lv_obj_add_state(s_hook_btn, LV_STATE_DISABLED); + break; + } +} + +// plip_virtual callback — arbitrary task context, so take the lock here. +static void on_hook_state(plip_hook_state_t st) +{ + if (bsp_display_lock(LOCK_MS)) { + apply_state_locked(st); + bsp_display_unlock(); + } +} + +// ---------- widget tree ---------- + +static void build_phone_view(lv_obj_t *parent) +{ + s_phone_view = lv_obj_create(parent); + lv_obj_remove_style_all(s_phone_view); + lv_obj_set_size(s_phone_view, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(s_phone_view, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(s_phone_view, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *glyph = lv_label_create(s_phone_view); + lv_label_set_text(glyph, LV_SYMBOL_CALL); + lv_obj_set_style_text_font(glyph, &lv_font_montserrat_48, 0); + lv_obj_set_style_text_color(glyph, lv_palette_main(LV_PALETTE_BLUE), 0); + + s_status_lbl = lv_label_create(s_phone_view); + lv_label_set_text(s_status_lbl, "PLIP — en attente"); + lv_obj_set_style_pad_top(s_status_lbl, 12, 0); +} + +static void build_keypad_view(lv_obj_t *parent) +{ + s_keypad_view = lv_obj_create(parent); + lv_obj_remove_style_all(s_keypad_view); + lv_obj_set_size(s_keypad_view, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(s_keypad_view, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(s_keypad_view, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_add_flag(s_keypad_view, LV_OBJ_FLAG_HIDDEN); + + s_number_lbl = lv_label_create(s_keypad_view); + lv_label_set_text(s_number_lbl, ""); + lv_obj_set_style_text_font(s_number_lbl, &lv_font_montserrat_24, 0); + lv_obj_set_style_pad_bottom(s_number_lbl, 6, 0); + + lv_obj_t *grid = lv_obj_create(s_keypad_view); + lv_obj_remove_style_all(grid); + lv_obj_set_size(grid, 240, 150); + lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + for (size_t i = 0; i < sizeof(KEYS) / sizeof(KEYS[0]); i++) { + lv_obj_t *btn = lv_button_create(grid); + lv_obj_set_size(btn, 64, 40); + lv_obj_add_event_cb(btn, key_event_cb, LV_EVENT_CLICKED, + (void *) KEYS[i]); + lv_obj_t *lbl = lv_label_create(btn); + lv_label_set_text(lbl, KEYS[i]); + lv_obj_center(lbl); + } +} + +static void build_hook_button(lv_obj_t *parent) +{ + s_hook_btn = lv_button_create(parent); + lv_obj_set_size(s_hook_btn, LV_PCT(96), 44); + lv_obj_align(s_hook_btn, LV_ALIGN_BOTTOM_MID, 0, -4); + lv_obj_add_event_cb(s_hook_btn, hook_btn_event_cb, LV_EVENT_CLICKED, NULL); + + s_hook_lbl = lv_label_create(s_hook_btn); + lv_label_set_text(s_hook_lbl, "—"); + lv_obj_center(s_hook_lbl); +} + +esp_err_t plip_ui_init(void) +{ + if (s_built) return ESP_OK; + if (!bsp_display_lock(LOCK_MS)) { + ESP_LOGE(TAG, "display lock failed"); + return ESP_FAIL; + } + + lv_obj_t *scr = lv_screen_active(); + lv_obj_set_style_bg_color(scr, lv_color_black(), 0); + + // Content area above the fixed bottom button. + lv_obj_t *content = lv_obj_create(scr); + lv_obj_remove_style_all(content); + lv_obj_set_size(content, LV_PCT(100), 188); + lv_obj_align(content, LV_ALIGN_TOP_MID, 0, 0); + + build_phone_view(content); + build_keypad_view(content); + build_hook_button(scr); + + apply_state_locked(plip_virtual_state()); + bsp_display_unlock(); + + plip_virtual_register_state_cb(on_hook_state); + s_built = true; + ESP_LOGI(TAG, "phone UI ready"); + return ESP_OK; +} diff --git a/box3_voice/main/plip_ui.h b/box3_voice/main/plip_ui.h new file mode 100644 index 0000000..328b587 --- /dev/null +++ b/box3_voice/main/plip_ui.h @@ -0,0 +1,31 @@ +// plip_ui — on-screen telephone for the phone-less PLIP annex (BOX-3 LCD). +// +// Two views on the 320x240 display, switched by the hook state of +// plip_virtual: +// - idle / ringing : a telephone illustration + status line; during a ring +// the screen shows "Appel entrant…". +// - off-hook : a 3x4 dial keypad (1-9, *, 0, #) with a number field. +// +// A fixed bottom button toggles the hook: "Décrocher" (green) while ringing, +// "Raccrocher" (red) while off-hook. It is disabled when idle. +// +// All LVGL access is guarded with bsp_display_lock/unlock. The widget tree is +// built once; hook-state changes only flip visibility + the bottom button, +// driven by the plip_virtual state callback. + +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Build the phone UI on the active LVGL display and subscribe to +// plip_virtual hook transitions. Call after bsp_display_start() and +// plip_virtual_init(). Idempotent. +esp_err_t plip_ui_init(void); + +#ifdef __cplusplus +} +#endif diff --git a/box3_voice/main/plip_virtual.c b/box3_voice/main/plip_virtual.c index 8a0e78d..65788d9 100644 --- a/box3_voice/main/plip_virtual.c +++ b/box3_voice/main/plip_virtual.c @@ -2,7 +2,9 @@ // // Threading: the ring tone runs in its own task (synthesised sine bursts on // the shared speaker I2S channel, French cadence 1.5 s on / 3.5 s off); hook -// reports run in a tiny worker so the button path never blocks on HTTP. +// reports run in a tiny worker so the button/touch paths never block on +// HTTP. The UI callback fires from whichever context transitions the state +// (httpd, button task, LVGL touch) — the UI does its own locking. #include "plip_virtual.h" @@ -29,19 +31,26 @@ #define RING_OFF_MS 3500 #define RING_AMPLITUDE 14000.0f #define HOOK_QUEUE_DEPTH 4 - -typedef enum { HOOK_ON = 0, HOOK_RINGING, HOOK_OFF } hook_state_t; +#define DIAL_MAX 20 typedef struct { char state[8]; /* "off" | "on" */ - char reason[16]; /* "pickup" | "hangup" | "ring_timeout" */ + char reason[32]; /* "pickup" | "hangup" | "ring_timeout" | "dial:" */ } hook_event_t; -static i2s_chan_handle_t s_spk; -static volatile hook_state_t s_state = HOOK_ON; -static volatile bool s_ring_stop = false; -static TaskHandle_t s_ring_task = NULL; -static QueueHandle_t s_hook_queue = NULL; +static i2s_chan_handle_t s_spk; +static volatile plip_hook_state_t s_state = PLIP_HOOK_ON; +static volatile bool s_ring_stop = false; +static TaskHandle_t s_ring_task = NULL; +static QueueHandle_t s_hook_queue = NULL; +static plip_state_cb_t s_state_cb = NULL; +static char s_dialed[DIAL_MAX + 1] = ""; + +static void set_state(plip_hook_state_t st) +{ + s_state = st; + if (s_state_cb) s_state_cb(st); +} /* ---------- hook reporting (mirrors PLIP's zacus_hook_client) ---------- */ @@ -129,8 +138,8 @@ static void ring_task(void *arg) elapsed += RING_OFF_MS; } - if (s_state == HOOK_RINGING) { - s_state = HOOK_ON; /* nobody picked up */ + if (s_state == PLIP_HOOK_RINGING) { + set_state(PLIP_HOOK_ON); /* nobody picked up */ if (!s_ring_stop) hook_report("on", "ring_timeout"); } ESP_LOGI(TAG, "ring end (state=%d)", (int) s_state); @@ -172,15 +181,15 @@ static esp_err_t handle_ring_post(httpd_req_t *req) } } - if (s_state == HOOK_OFF) { + if (s_state == PLIP_HOOK_OFF) { return send_json(req, "409 Conflict", "{\"error\":\"off-hook\"}"); } if (s_ring_task) ring_stop(); - s_state = HOOK_RINGING; + set_state(PLIP_HOOK_RINGING); if (xTaskCreate(ring_task, "plip_ring", 4096, (void *) (intptr_t) duration_ms, 4, &s_ring_task) != pdPASS) { - s_state = HOOK_ON; + set_state(PLIP_HOOK_ON); return send_json(req, "500 Internal Server Error", "{\"error\":\"ring task failed\"}"); } @@ -192,7 +201,7 @@ static esp_err_t handle_ring_post(httpd_req_t *req) static esp_err_t handle_stop_post(httpd_req_t *req) { if (s_ring_task) ring_stop(); - if (s_state == HOOK_RINGING) s_state = HOOK_ON; + if (s_state == PLIP_HOOK_RINGING) set_state(PLIP_HOOK_ON); return send_json(req, "200 OK", "{\"ok\":true}"); } @@ -206,35 +215,70 @@ static esp_err_t handle_play_post(httpd_req_t *req) static esp_err_t handle_status_get(httpd_req_t *req) { - char resp[96]; + char resp[128]; snprintf(resp, sizeof(resp), - "{\"off_hook\":%s,\"ringing\":%s,\"playing\":false}", - s_state == HOOK_OFF ? "true" : "false", - s_state == HOOK_RINGING ? "true" : "false"); + "{\"off_hook\":%s,\"ringing\":%s,\"playing\":false," + "\"dialed\":\"%s\"}", + s_state == PLIP_HOOK_OFF ? "true" : "false", + s_state == PLIP_HOOK_RINGING ? "true" : "false", + s_dialed); return send_json(req, "200 OK", resp); } /* ---------- public API ---------- */ +void plip_virtual_pickup(void) +{ + if (s_state != PLIP_HOOK_RINGING) return; + ring_stop(); + s_dialed[0] = '\0'; + set_state(PLIP_HOOK_OFF); + ESP_LOGI(TAG, "virtual pickup"); + hook_report("off", "pickup"); +} + +void plip_virtual_hangup(void) +{ + if (s_state != PLIP_HOOK_OFF) return; + set_state(PLIP_HOOK_ON); + ESP_LOGI(TAG, "virtual hangup"); + hook_report("on", "hangup"); +} + +void plip_virtual_dial(const char *number) +{ + if (s_state != PLIP_HOOK_OFF || !number || !*number) return; + strlcpy(s_dialed, number, sizeof(s_dialed)); + char reason[32]; + snprintf(reason, sizeof(reason), "dial:%s", s_dialed); + ESP_LOGI(TAG, "dialed \"%s\"", s_dialed); + hook_report("off", reason); +} + bool plip_virtual_button_press(void) { switch (s_state) { - case HOOK_RINGING: - ring_stop(); - s_state = HOOK_OFF; - ESP_LOGI(TAG, "virtual pickup"); - hook_report("off", "pickup"); + case PLIP_HOOK_RINGING: + plip_virtual_pickup(); return true; - case HOOK_OFF: - s_state = HOOK_ON; - ESP_LOGI(TAG, "virtual hangup"); - hook_report("on", "hangup"); + case PLIP_HOOK_OFF: + plip_virtual_hangup(); return true; default: return false; /* on-hook, not ringing: not ours */ } } +void plip_virtual_register_state_cb(plip_state_cb_t cb) +{ + s_state_cb = cb; +} + +plip_hook_state_t plip_virtual_state(void) +{ + return s_state; +} + esp_err_t plip_virtual_init(httpd_handle_t server, i2s_chan_handle_t spk) { if (!server || !spk) return ESP_ERR_INVALID_ARG; diff --git a/box3_voice/main/plip_virtual.h b/box3_voice/main/plip_virtual.h index 72d57ed..15d300c 100644 --- a/box3_voice/main/plip_virtual.h +++ b/box3_voice/main/plip_virtual.h @@ -24,6 +24,13 @@ extern "C" { #endif +// Hook state, exposed for the on-screen phone UI (plip_ui). +typedef enum { + PLIP_HOOK_ON = 0, // idle, handset down + PLIP_HOOK_RINGING, + PLIP_HOOK_OFF, // picked up +} plip_hook_state_t; + // Register the REST handlers on `server` and keep `spk` for the ring tone. // Call once after scenario_server_start(); the speaker channel must be // enabled (speaker_init() in main.c). @@ -34,6 +41,23 @@ esp_err_t plip_virtual_init(httpd_handle_t server, i2s_chan_handle_t spk); // handling (voice-streaming toggle). bool plip_virtual_button_press(void); +// Programmatic hook transitions (used by the touch UI). Both are no-ops +// when the state does not allow the transition. +void plip_virtual_pickup(void); // RINGING -> OFF (stops the ring) +void plip_virtual_hangup(void); // OFF -> ON + +// Report a dialed number to the master (reason "dial:" on the +// existing /voice/hook contract) and remember it for GET /status. +// Only meaningful while off-hook; ignored otherwise. +void plip_virtual_dial(const char *number); + +// UI notification: called on every hook state change (from HTTP, button or +// touch context — the callback must do its own LVGL locking). +typedef void (*plip_state_cb_t)(plip_hook_state_t state); +void plip_virtual_register_state_cb(plip_state_cb_t cb); + +plip_hook_state_t plip_virtual_state(void); + #ifdef __cplusplus } #endif diff --git a/box3_voice/sdkconfig.defaults b/box3_voice/sdkconfig.defaults index 0344c42..9abbbc2 100644 --- a/box3_voice/sdkconfig.defaults +++ b/box3_voice/sdkconfig.defaults @@ -55,3 +55,7 @@ CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768 # Force-enable legacy mode so both coexist (and prevent the runtime abort # in bsp_display_start when both drivers race to register on the same bus). CONFIG_I2C_ENABLE_LEGACY_DRIVERS=y + +# PLIP phone UI fonts (plip_ui.c) +CONFIG_LV_FONT_MONTSERRAT_24=y +CONFIG_LV_FONT_MONTSERRAT_48=y