From 4dbeeba0c39aaaaeaed8d57c2f122e42051503ee Mon Sep 17 00:00:00 2001 From: Dylan Luo Date: Fri, 23 Oct 2026 07:19:33 +0800 Subject: [PATCH 1/3] feat: add first-run Wi-Fi and Gemini setup --- .../include/config_service/config_service.hpp | 7 + .../config_service/settings_registry.hpp | 2 +- .../config_service/settings_registry.cpp | 3 + .../wifi_config_service/http_handlers.cpp | 22 + .../wifi_config_service/http_handlers.hpp | 3 + .../wifi_config_service.hpp | 5 + .../web/settings_wifi.html | 37 +- .../wifi_config_service.cpp | 7 +- main/CMakeLists.txt | 8 + main/ap_screen.cpp | 45 +- main/app_main.cpp | 44 +- main/atom_status.cpp | 2 +- main/demo_loop.cpp | 51 +- main/demo_loop.hpp | 4 + main/first_run.cpp | 1131 +++++++++++++++++ main/first_run.hpp | 35 + main/screens.cpp | 23 +- main/web/first_run_setup.html | 680 ++++++++++ main/wifi_sta.cpp | 538 +++++--- main/wifi_sta.hpp | 41 +- tools/settings_i18n/catalog.json | 9 +- 21 files changed, 2498 insertions(+), 199 deletions(-) create mode 100644 main/first_run.cpp create mode 100644 main/first_run.hpp create mode 100644 main/web/first_run_setup.html diff --git a/components/config_service/include/config_service/config_service.hpp b/components/config_service/include/config_service/config_service.hpp index 45448f8..b08fe6d 100644 --- a/components/config_service/include/config_service/config_service.hpp +++ b/components/config_service/include/config_service/config_service.hpp @@ -114,6 +114,13 @@ static_assert(static_cast(LipSyncMode::Brightness) == 0 && struct DeviceConfig { std::string wifi_ssid; std::string wifi_password; + // Optional IPv4 static network configuration for the STA interface. + // Empty static IP = DHCP. Non-empty static IP implies a /24 prefix, + // gateway is required, and DNS is an optional main DNS server (the + // gateway is used when DNS is empty). Staged, like the Wi-Fi credentials. + std::string wifi_static_ip; + std::string wifi_gateway; + std::string wifi_dns; std::string openai_api_key; std::string gemini_api_key; // Gemini Live model id and prebuilt voice name. Empty → Kconfig default diff --git a/components/config_service/include/config_service/settings_registry.hpp b/components/config_service/include/config_service/settings_registry.hpp index 20320eb..c0b36f1 100644 --- a/components/config_service/include/config_service/settings_registry.hpp +++ b/components/config_service/include/config_service/settings_registry.hpp @@ -73,7 +73,7 @@ struct SettingDescriptor { void (*num_set)(DeviceConfig&, std::uint32_t); }; -inline constexpr std::size_t kSettingCount = 61; +inline constexpr std::size_t kSettingCount = 64; // The full table, one row per DeviceConfig field. Order is stable but not a // contract (lookup is by id / nvs_key). diff --git a/components/config_service/settings_registry.cpp b/components/config_service/settings_registry.cpp index b272466..f3b8c18 100644 --- a/components/config_service/settings_registry.cpp +++ b/components/config_service/settings_registry.cpp @@ -45,6 +45,9 @@ const std::array kTable = {{ // max_len mirrors the HTTP kMax* / BLE plaintext caps for each field. str_row("ssid", "wifi_ssid", ApplyKind::Staged, &DeviceConfig::wifi_ssid, 32), str_row("password", "wifi_pass", ApplyKind::Staged, &DeviceConfig::wifi_password, 64, true), + str_row("wifi-static-ip", "wifi_static", ApplyKind::Staged, &DeviceConfig::wifi_static_ip, 15), + str_row("wifi-gateway", "wifi_gw", ApplyKind::Staged, &DeviceConfig::wifi_gateway, 15), + str_row("wifi-dns", "wifi_dns", ApplyKind::Staged, &DeviceConfig::wifi_dns, 15), str_row("api-key", "openai_key", ApplyKind::Staged, &DeviceConfig::openai_api_key, 256, true), str_row("gemini-api-key", "gemini_key", ApplyKind::Staged, &DeviceConfig::gemini_api_key, 256, true), str_row("gemini-model", "gem_model", ApplyKind::Staged, &DeviceConfig::gemini_model, 64), diff --git a/components/wifi_config_service/http_handlers.cpp b/components/wifi_config_service/http_handlers.cpp index 7c67ff8..1e13b0d 100644 --- a/components/wifi_config_service/http_handlers.cpp +++ b/components/wifi_config_service/http_handlers.cpp @@ -119,6 +119,10 @@ std::atomic g_wifi_connected{false}; // behind the Basic-auth gate (Android's captive WebView surfaces the 401 // as ERR_HTTP_RESPONSE_CODE_FAILURE; observed on HW). std::atomic g_provisioning_mode{false}; +// Dedicated step-by-step onboarding flow. Kept separate from provisioning_mode +// because the setup AP always uses provisioning mode, but only the first-run +// flow redirects / to /setup or exposes first_run_mode in status. +std::atomic g_first_run_mode{false}; // Cached battery snapshot served by /api/status. -1 mV / percent → unknown. int g_battery_mv = -1; int g_battery_ma = 0; @@ -451,6 +455,7 @@ esp_err_t handle_status_get(httpd_req_t* req) if (!require_auth(req)) return ESP_OK; const bool wifi_ok = g_wifi_connected.load(); const bool prov_mode = g_provisioning_mode.load(); + const bool first_run_mode = g_first_run_mode.load(); xSemaphoreTake(g_mutex, portMAX_DELAY); const auto cfg = g_active; const int bat_mv = g_battery_mv; @@ -478,8 +483,12 @@ esp_err_t handle_status_get(httpd_req_t* req) body += "\"ip\":\"" + escape_json(ip) + "\","; body += "\"wifi_connected\":" + std::string(wifi_ok ? "true" : "false") + ","; body += "\"provisioning_mode\":" + std::string(prov_mode ? "true" : "false") + ","; + body += "\"first_run_mode\":" + std::string(first_run_mode ? "true" : "false") + ","; body += "\"has_camera\":" + std::string(has_camera ? "true" : "false") + ","; body += "\"ssid\":\"" + escape_json(cfg.wifi_ssid) + "\","; + body += "\"wifi_static_ip\":\"" + escape_json(cfg.wifi_static_ip) + "\","; + body += "\"wifi_gateway\":\"" + escape_json(cfg.wifi_gateway) + "\","; + body += "\"wifi_dns\":\"" + escape_json(cfg.wifi_dns) + "\","; body += "\"has_password\":" + std::string(cfg.wifi_password.empty() ? "false" : "true") + ","; body += "\"has_openai_key\":" + std::string(cfg.openai_api_key.empty() ? "false" : "true") + ","; body += "\"has_gemini_key\":" + std::string(cfg.gemini_api_key.empty() ? "false" : "true") + ","; @@ -2264,6 +2273,13 @@ esp_err_t handle_dance_upload_post(httpd_req_t* req) esp_err_t handle_root_get(httpd_req_t* req) { + if (g_first_run_mode.load() && + std::strcmp(req->uri, "/setup") != 0) { + httpd_resp_set_status(req, "302 Found"); + httpd_resp_set_hdr(req, "Location", "/setup"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return httpd_resp_send(req, nullptr, 0); + } if (!require_auth(req)) { ESP_LOGW(kTag, "GET / -> 401 (auth)"); return ESP_OK; @@ -2426,6 +2442,12 @@ void set_provisioning_mode(bool active) g_provisioning_mode.store(active); } +void set_first_run_mode(bool active) +{ + // Same init-ordering contract as set_provisioning_mode(). + g_first_run_mode.store(active); +} + void set_battery(int millivolts, int milliamps, int percent) { if (g_mutex == nullptr) return; diff --git a/components/wifi_config_service/http_handlers.hpp b/components/wifi_config_service/http_handlers.hpp index 6860b18..4cd915f 100644 --- a/components/wifi_config_service/http_handlers.hpp +++ b/components/wifi_config_service/http_handlers.hpp @@ -21,6 +21,9 @@ void set_wifi_connected(bool connected); // while set, and surfaces "provisioning_mode" in /api/status. void set_provisioning_mode(bool active); +// Mark the HTTP service as serving the dedicated first-run onboarding flow. +void set_first_run_mode(bool active); + // Update the cached battery snapshot (mV / mA / percent) exposed by /api/status. void set_battery(int millivolts, int milliamps, int percent); diff --git a/components/wifi_config_service/include/wifi_config_service/wifi_config_service.hpp b/components/wifi_config_service/include/wifi_config_service/wifi_config_service.hpp index f15a533..ef8dfaa 100644 --- a/components/wifi_config_service/include/wifi_config_service/wifi_config_service.hpp +++ b/components/wifi_config_service/include/wifi_config_service/wifi_config_service.hpp @@ -56,6 +56,11 @@ httpd_handle_t handle(); // Idempotent. Thread-safe. void set_provisioning_mode(bool active); +// Mark the HTTP service as serving the dedicated first-run onboarding flow. +// While set, GET / redirects to /setup and /api/status reports first_run_mode. +// Thread-safe and, like set_provisioning_mode(), valid before httpd_start(). +void set_first_run_mode(bool active); + // Update the battery snapshot (mV / mA / percent) surfaced by /api/status. // No-op until the HTTP server has started. Thread-safe. void set_battery(int millivolts, int milliamps, int percent); diff --git a/components/wifi_config_service/web/settings_wifi.html b/components/wifi_config_service/web/settings_wifi.html index 1950f3d..391b2d8 100644 --- a/components/wifi_config_service/web/settings_wifi.html +++ b/components/wifi_config_service/web/settings_wifi.html @@ -431,6 +431,27 @@

動作モード

+

静的 IP 設定

+

+ 空欄なら DHCP を使用します。静的 IP は /24 サブネットで設定され、ゲートウェイは必須です。 + DNS が空欄の場合はゲートウェイを使用します。保存後の再起動で反映されます。 +

+ + +
+
+ + +
+
+ + +
+
+

デバイス名 (BLE / mDNS)

BLE アドバタイズ名と mDNS ホスト名のもとになります。空欄なら自動生成 (Stackchan-XXXXXX)。 @@ -1641,6 +1662,9 @@

+ (s.battery_mv / 1000).toFixed(2) + 'V ' + (s.battery_ma | 0) + 'mA'); $('ssid').value = s.ssid || ''; + $('wifi-static-ip').value = s.wifi_static_ip || ''; + $('wifi-gateway').value = s.wifi_gateway || ''; + $('wifi-dns').value = s.wifi_dns || ''; $('provider').value = String(s.provider ?? 0); if (s.gemini_model && $('gemini-model')) $('gemini-model').value = s.gemini_model; if (s.gemini_voice && $('gemini-voice')) $('gemini-voice').value = s.gemini_voice; @@ -1769,6 +1793,15 @@

if (ssid) { await postText('/api/ssid', ssid); log('SSID 送信', 'ok'); } if (pass) { await postText('/api/password', pass); log('パスワード送信', 'ok'); } + // Stage the three static-IPv4 fields together. Empty static IP switches + // the STA interface back to DHCP. /api/settings validates the batch before + // staging anything, then the normal Apply action persists it to NVS. + await postText('/api/settings', JSON.stringify({ + 'wifi-static-ip': $('wifi-static-ip').value.trim(), + 'wifi-gateway': $('wifi-gateway').value.trim(), + 'wifi-dns': $('wifi-dns').value.trim(), + })); + log('Static IP settings sent', 'ok'); if (apiKey) { await postText('/api/api-key', apiKey); log('OpenAI キー送信', 'ok'); } if (geminiKey) { await postText('/api/gemini-api-key', geminiKey); log('Gemini キー送信', 'ok'); } if ($('gemini-model') && $('gemini-model').value) { @@ -2292,7 +2325,9 @@

// 手書きヒントのように陳腐化しない。BADGE_FIELDS = registry id → 対象コントロールの // DOM id。checkbox は toggle-label ごと、input/select はコントロール直後に置く。 const BADGE_FIELDS = { - 'ssid':'ssid', 'password':'password', 'provider':'provider', + 'ssid':'ssid', 'password':'password', + 'wifi-static-ip':'wifi-static-ip', 'wifi-gateway':'wifi-gateway', 'wifi-dns':'wifi-dns', + 'provider':'provider', 'operation-mode':'operation-mode', 'audio-output':'audio-output', 'lip-sync-mode':'lip-sync-mode', 'led-mouth-sync':'led-mouth-sync', 'barge-in':'barge-in-enabled', 'startup-arpeggio':'startup-arpeggio-enabled', diff --git a/components/wifi_config_service/wifi_config_service.cpp b/components/wifi_config_service/wifi_config_service.cpp index d90c00c..d05f92c 100644 --- a/components/wifi_config_service/wifi_config_service.cpp +++ b/components/wifi_config_service/wifi_config_service.cpp @@ -137,7 +137,7 @@ tl::expected start_http_server() // internal RAM; the runtime cost is the slot count we actually register, // not the cap. add() now ESP_LOGEs on registration failure so a future // count/cap drift is loud instead of silent. - cfg.max_uri_handlers = 96; // /api/coredump (2) + /api/debug/crash を追加した 2026-09-17 時点で 77 + cfg.max_uri_handlers = 128; // 96 → 128 leaves room for first-run onboarding routes // Stack MUST live in internal RAM: the OTA control handler calls // esp_ota_begin → esp_partition_mmap, which disables CPU cache while // remapping flash. PSRAM is cached, so a PSRAM-resident stack becomes @@ -271,6 +271,11 @@ void set_provisioning_mode(bool active) http::set_provisioning_mode(active); } +void set_first_run_mode(bool active) +{ + http::set_first_run_mode(active); +} + void set_battery(int millivolts, int milliamps, int percent) { if (!g_started) return; diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 3cd01dd..a6d22ca 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -20,6 +20,7 @@ set(_main_priv_reqs esp_wifi esp_netif esp_event + esp_http_server app_update lwip esp_http_client @@ -52,6 +53,7 @@ set(_main_srcs "device_ui.cpp" "diag.cpp" "face_config.cpp" + "first_run.cpp" "i2c_dump.cpp" "led_task.cpp" "lt_timer.cpp" @@ -93,6 +95,12 @@ idf_component_register( ) target_compile_features(${COMPONENT_LIB} PRIVATE cxx_std_20) +target_add_binary_data( + ${COMPONENT_LIB} + "${PROJECT_DIR}/main/web/first_run_setup.html" + TEXT +) + if(CONFIG_STACKCHAN_BOOT_SPLASH) target_add_binary_data(${COMPONENT_LIB} "${PROJECT_DIR}/assets/splash/splash_cores3.png" BINARY) target_add_binary_data(${COMPONENT_LIB} "${PROJECT_DIR}/assets/splash/splash_atom.png" BINARY) diff --git a/main/ap_screen.cpp b/main/ap_screen.cpp index e3d0f10..0ffb2a7 100644 --- a/main/ap_screen.cpp +++ b/main/ap_screen.cpp @@ -21,6 +21,7 @@ namespace { char g_last_ssid[33] = {0}; char g_last_pw[33] = {0}; char g_last_ip[16] = {0}; +bool g_last_first_run = false; bool g_painted = false; // Exit-button rect in panel coords (set by the most recent draw call). @@ -78,12 +79,14 @@ bool draw(M5GFX& display) if (!wifi_ap_info(ssid, sizeof(ssid), pw, sizeof(pw), ip, sizeof(ip))) { return false; } + const bool first_run = wifi_ap_first_run(); // Only repaint when the credentials actually changed — saves the full // QR render cost on every render-task tick. if (g_painted && std::strcmp(ssid, g_last_ssid) == 0 && std::strcmp(pw, g_last_pw) == 0 && - std::strcmp(ip, g_last_ip) == 0) { + std::strcmp(ip, g_last_ip) == 0 && + first_run == g_last_first_run) { return false; } // Use snprintf rather than strncpy(dst, src, sizeof-1): GCC 14 (IDF 5.5) @@ -93,6 +96,7 @@ bool draw(M5GFX& display) std::snprintf(g_last_ssid, sizeof(g_last_ssid), "%s", ssid); std::snprintf(g_last_pw, sizeof(g_last_pw), "%s", pw); std::snprintf(g_last_ip, sizeof(g_last_ip), "%s", ip); + g_last_first_run = first_run; const std::int32_t W = display.width(); const std::int32_t H = display.height(); @@ -114,30 +118,32 @@ bool draw(M5GFX& display) ? static_cast(W * (1.0f - 0.70710678f) * 0.5f) + 4 : 4; - // Bottom exit-button band. Height ~ 1/6 of panel height on touch boards. + // Bottom exit-button band for the existing settings AP. First-run setup + // deliberately has no exit action: closing that AP would strand setup. + // Height ~ 1/6 of panel height on touch boards. // On round panels the band can extend slightly below the inscribed // square because the button is centred horizontally — the visible // circle is widest along the vertical axis, narrowest at the corners. - const int btn_h = tiny ? 0 + const int btn_h = (tiny || first_run) ? 0 : round_ ? 40 : 30; const int btn_band_y = tiny ? H : (H - btn_h - (round_ ? 18 : 4)); - const int btn_w = tiny ? 0 + const int btn_w = (tiny || first_run) ? 0 : round_ ? 200 : (W - 24); const int btn_x = (W - btn_w) / 2; - // QR + caption block fills the area above the button band. Pick a QR - // size that leaves room for the title (top) and 3 caption lines plus - // the button band (bottom). - const int caption_lines = tiny ? 1 : 3; - const int caption_line_h = round_ ? 22 : 14; + // QR + caption block fills the area above the button band. First-run + // adds the phone CTA and URL while removing the exit button; the normal + // settings AP keeps its three credential lines plus the exit action. + const int caption_lines = tiny ? 2 : (first_run ? 4 : 3); + const int caption_line_h = round_ ? 22 : (tiny ? 10 : 14); const int caption_h = caption_lines * caption_line_h + 4; const int title_h = tiny ? 0 : (round_ ? 32 : 20); const int top_margin = tiny ? 8 : (round_ ? inset : 4); const int avail_y_for_qr = btn_band_y - top_margin - title_h - caption_h; - const int qr_max_by_w = tiny ? (W - 18) + const int qr_max_by_w = tiny ? (W - 28) : round_ ? (W * 220 / 466) : 150; const int qr_w = (avail_y_for_qr < qr_max_by_w) ? avail_y_for_qr : qr_max_by_w; @@ -182,16 +188,24 @@ bool draw(M5GFX& display) std::snprintf(buf, sizeof(buf), "PW: %s", pw); display.drawString(buf, W / 2, ty); ty += caption_line_h; + if (first_run) { + display.drawString(config::tr("Continue on your phone.", "スマートフォンで続行してください。"), + W / 2, ty); + ty += caption_line_h; + } std::snprintf(buf, sizeof(buf), "http://%s/", ip); display.drawString(buf, W / 2, ty); } else { - // 128×128: only the SSID fits readably under the QR. iOS-camera- - // scan-then-tap-join carries the URL via the captive portal probe, - // so the URL line isn't critical here. + // 128×128: SSID + password below the QR. The captive portal carries + // the setup URL after joining, so it is omitted here for legibility. display.setFont(&fonts::Font0); display.setTextColor(TFT_BLACK, TFT_WHITE); display.setTextDatum(lgfx::textdatum_t::top_center); - display.drawString(ssid, W / 2, qr_y + qr_w + 2); + char buf[48]; + std::snprintf(buf, sizeof(buf), "SSID %s", ssid); + display.drawString(buf, W / 2, qr_y + qr_w + 2); + std::snprintf(buf, sizeof(buf), "PW %s", pw); + display.drawString(buf, W / 2, qr_y + qr_w + 2 + caption_line_h); } // Exit button (touch boards only). Red so it reads as "stop/cancel" @@ -221,7 +235,8 @@ bool handle_tap(int x, int y) // hidden device_ui underneath (whose top-right corner opens the 5-tab // settings UI), which would surprise the user when AP is later // dismissed and the UI reappears already open. - if (g_btn_w > 0 && g_btn_h > 0 && + if (!wifi_ap_first_run() && + g_btn_w > 0 && g_btn_h > 0 && x >= g_btn_x && x < g_btn_x + g_btn_w && y >= g_btn_y && y < g_btn_y + g_btn_h) { wifi_disable_ap_mode(); diff --git a/main/app_main.cpp b/main/app_main.cpp index 49a89b7..512bac0 100644 --- a/main/app_main.cpp +++ b/main/app_main.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -57,6 +58,7 @@ #include "dance_poc.hpp" #include "espnow_poc/espnow_poc.hpp" #include "espnow_remote/espnow_remote.hpp" +#include "first_run.hpp" #include "screens.hpp" #include "servo_limits.hpp" #include "servo_task.hpp" @@ -376,6 +378,13 @@ extern "C" void app_main() // 前提とする httpd / 会話 / wifi_audio / STA 待ちの各処理を等しくスキップする。 const bool espnow_any = espnow_mode || espnow_sender_mode; + // Gemini customer builds must not reach the avatar conversation path until + // Wi-Fi and the Gemini API key are configured. ESP-NOW modes never use the + // Gemini connection, so they remain on their existing path. + const bool first_run_required = + stackchan::config::gemini_only() && !espnow_any && + (cfg.wifi_ssid.empty() || cfg.gemini_api_key.empty()); + // Resident camera init (M5Base only; no-op elsewhere / slim builds). // Must run after the i2c dump (which uses In_I2C) and before any task // that touches In_I2C starts — see camera_service::init_resident. @@ -954,10 +963,26 @@ extern "C" void app_main() (void)api_key; (void)xiaozhi_url; (void)xiaozhi_token; #endif - // Overlay screen stack: SoftAP QR screen + the per-board settings UI - // flavour (device_ui on touch boards, atom_status on button-only ones — - // profile.button_overlay_ui decides). render_task and demo_loop - // dispatch through screens:: from here on. + // Overlay screen stack: SoftAP QR screen + first-run gate + the per-board + // settings UI flavour (device_ui on touch boards, atom_status on + // button-only ones — profile.button_overlay_ui decides). render_task and + // demo_loop dispatch through screens:: from here on. + std::atomic conversation_started{false}; + auto start_conversation_once = [espnow_any, &conversation_started]() { + bool expected = false; + if (!conversation_started.compare_exchange_strong(expected, true)) return; +#if CONFIG_STACKCHAN_CONVERSATION_ENABLED + if (!espnow_any) { + stackchan::app::start_conversation_task(*g_conversation_args); + } +#else + (void)espnow_any; + ESP_LOGI(kTag, "conversation: disabled at compile time (slim build)"); +#endif + }; + stackchan::app::first_run::set_ready_callback(start_conversation_once); + stackchan::app::first_run::init(first_run_required, cfg, profile.button_overlay_ui); + stackchan::app::diag_heap("pre-screens/render"); stackchan::app::boot_splash::wait_min_time(); stackchan::app::screens::init(board.display(), *g_state, profile); @@ -1064,13 +1089,11 @@ extern "C" void app_main() // so the two never contend for the bus. Gated by Kconfig so the AtomS3 // (no-PSRAM) slim profile drops the whole TLS / WebSocket / assistant // PCM ring stack at compile time. -#if CONFIG_STACKCHAN_CONVERSATION_ENABLED - if (!espnow_any) { - stackchan::app::start_conversation_task(*g_conversation_args); + // First-run skips this call until the prompt is dismissed. The atomic + // helper also tolerates a Skip tap racing the normal boot continuation. + if (!first_run_required) { + start_conversation_once(); } -#else - ESP_LOGI(kTag, "conversation: disabled at compile time (slim build)"); -#endif // Mic-driven lip sync. Activates only when BOTH the conversation backend // AND the jtts idle babble are off — that way nothing else is driving @@ -1269,6 +1292,7 @@ extern "C" void app_main() .conversation_enabled = cfg.openai_enabled, .jtts_idle_enabled = cfg.jtts_idle_enabled, .external_servo_control = espnow_mode, + .first_run_active = &stackchan::app::first_run::active, .limits = servo_limits, }); } diff --git a/main/atom_status.cpp b/main/atom_status.cpp index 6dfc3e5..33884e0 100644 --- a/main/atom_status.cpp +++ b/main/atom_status.cpp @@ -141,7 +141,7 @@ void poll_button() g_ap_press_fired = true; const bool overlay_up = g_active.load(std::memory_order_relaxed); if (!overlay_up || ap_screen::active()) { - if (wifi_ap_active()) { + if (wifi_ap_active() && !wifi_ap_first_run()) { wifi_disable_ap_mode(); } else { wifi_enable_ap_mode(); diff --git a/main/demo_loop.cpp b/main/demo_loop.cpp index 75c2ec1..53b2694 100644 --- a/main/demo_loop.cpp +++ b/main/demo_loop.cpp @@ -52,6 +52,9 @@ constexpr const char* kTag = "stackchan"; const bool jtts_idle_enabled = args.jtts_idle_enabled; const bool external_servo_control = args.external_servo_control; const ServoLimits& limits = args.limits; + auto first_run_active = [&args]() { + return args.first_run_active != nullptr && args.first_run_active(); + }; using namespace stackchan; @@ -189,6 +192,7 @@ constexpr const char* kTag = "stackchan"; constexpr float kShakeThresholdG = 1.6f; // |a| ≥ 1.6 g (≈ 0.6 g jerk) constexpr std::uint32_t kShakeCooldownMs = 800; std::uint32_t next_shake_ms = 0; + bool first_run_was_active = false; for (;;) { // Camera session in progress: every In_I2C touch (M5.update's @@ -205,6 +209,24 @@ constexpr const char* kTag = "stackchan"; M5.update(); const std::uint32_t now_ms = static_cast(esp_timer_get_time() / 1000); + const bool first_run = first_run_active(); + + if (first_run && !first_run_was_active) { + // Google Cloud splash has finished and the setup prompt / SoftAP + // screen now owns the panel. Stop any boot-time avatar audio or + // balloon once; all idle animation below stays gated off. + speech.stop(); + g_state->clear_balloon(); + balloon_in_flight.store(false, std::memory_order_release); + g_state->face.mouth_form.store(-1.0f, std::memory_order_relaxed); + } else if (!first_run && first_run_was_active) { + // Resume the avatar with a short quiet period rather than firing a + // babble / random pose immediately after Skip. + next_speech_ms = now_ms + 1500; + next_expression_ms = now_ms + kExpressionPeriodMs; + next_pose_ms = now_ms + rand_range_ms(kPoseMinMs, kPoseMaxMs); + } + first_run_was_active = first_run; // Live-apply speaker_volume_pct changes from the device-UI (BLE / // HTTP sinks already apply directly). Cheap atomic compare per @@ -240,7 +262,7 @@ constexpr const char* kTag = "stackchan"; // expression rotation immediately (no wait for the 5 s timer). // A brief haptic confirms the shake actually registered (handy // when the user can't see the avatar on a wrist-worn device). - if (now_ms >= next_shake_ms) { + if (!first_run && now_ms >= next_shake_ms) { float ax = 0, ay = 0, az = 0; if (M5.Imu.getAccel(&ax, &ay, &az)) { const float mag = std::sqrt(ax * ax + ay * ay + az * az); @@ -260,7 +282,7 @@ constexpr const char* kTag = "stackchan"; // BtnB (StopWatch Blue / G1) — manual expression cycle with haptic // confirmation. wasPressed() is false on boards without BtnB so the // check is harmless universally. - if (M5.BtnB.wasPressed()) { + if (!first_run && M5.BtnB.wasPressed()) { const int cur = g_state->face.expression.load(std::memory_order_relaxed); g_state->face.expression.store((cur + 1) % 6, std::memory_order_relaxed); if (g_board != nullptr) (void)g_board->vibrate(30); @@ -272,7 +294,7 @@ constexpr const char* kTag = "stackchan"; // boards BtnA either has no role (CoreS3 uses touch only) or is // already claimed by atom_status's gesture vocab (polled via // screens::poll_inputs above; btn_a_toggles_ui is false there). - if (btn_a_toggles_ui && g_board != nullptr && M5.BtnA.wasPressed()) { + if (!first_run && btn_a_toggles_ui && g_board != nullptr && M5.BtnA.wasPressed()) { app::ui::toggle(); (void)g_board->vibrate(20); } @@ -280,12 +302,14 @@ constexpr const char* kTag = "stackchan"; // LT timekeeper: re-configure when BLE/HTTP (or the boot seed) pushed // a new config JSON, then consume UI commands / update the countdown / // announce the 1-minute warning + overtime through speech + balloon. - static std::uint32_t lt_cfg_seen = 0; - if (const std::uint32_t v = g_state->lt_config_version(); v != lt_cfg_seen) { - lt_cfg_seen = v; - lt_timer.configure(g_state->snapshot_lt_config(), g_state); + if (!first_run) { + static std::uint32_t lt_cfg_seen = 0; + if (const std::uint32_t v = g_state->lt_config_version(); v != lt_cfg_seen) { + lt_cfg_seen = v; + lt_timer.configure(g_state->snapshot_lt_config(), g_state); + } + lt_timer.tick(*g_state, speech, now_ms); } - lt_timer.tick(*g_state, speech, now_ms); // Battery: sample the INA226 every few seconds and fan the result out to // the device UI (SharedState) + the BLE / Wi-Fi settings services. @@ -351,7 +375,7 @@ constexpr const char* kTag = "stackchan"; constexpr float kGazeGain = 5.0f; // DSL multiplier // is *3 → 15 px peak bool follow_active = false; - if (td.isPressed() && !app::screens::overlay_active()) { + if (td.isPressed() && !first_run && !app::screens::overlay_active()) { const float dx = static_cast(td.x) - kCenter; const float dy = static_cast(td.y) - kCenter; const float r = std::sqrt(dx * dx + dy * dy); @@ -374,6 +398,15 @@ constexpr const char* kTag = "stackchan"; } } + // First-run onboarding keeps only the input/housekeeping paths above. + // Suppress reminders, babble, Wi-Fi balloons, proximity reactions, + // nadenade, random poses, and expression cycling while the prompt or + // SoftAP QR screen owns the avatar. + if (first_run) { + vTaskDelay(pdMS_TO_TICKS(50)); + continue; + } + const bool conv_active = g_state->conv.active.load(std::memory_order_relaxed); const bool conv_idle = g_state->conv.idle.load(std::memory_order_relaxed); const bool audio_streaming = g_state->audio_stream_active.load(std::memory_order_relaxed); diff --git a/main/demo_loop.hpp b/main/demo_loop.hpp index 47e8dc9..bf1acee 100644 --- a/main/demo_loop.hpp +++ b/main/demo_loop.hpp @@ -34,6 +34,10 @@ struct DemoLoopArgs { // head-wobble are suppressed (the rest — expression, balloon, babble — // still runs). See OperationMode::EspNowRemote. bool external_servo_control = false; + // Gemini customer first-run prompt / onboarding. While true, suppress all + // avatar idle behaviour and gestures that could steal the panel or audio; + // keep panel input, speaker housekeeping, and battery polling alive. + bool (*first_run_active)() = nullptr; ServoLimits limits; // random-pose ranges }; diff --git a/main/first_run.cpp b/main/first_run.cpp new file mode 100644 index 0000000..da8d761 --- /dev/null +++ b/main/first_run.cpp @@ -0,0 +1,1131 @@ +// SPDX-FileCopyrightText: 2026 Kenta IDA +// SPDX-License-Identifier: BSL-1.0 + +#include "first_run.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#if CONFIG_STACKCHAN_CONVERSATION_ENABLED +#include +#include +#endif +#include + +#include "wifi_sta.hpp" + +namespace stackchan::app::first_run { +namespace { + +constexpr const char* kTag = "first-run"; +constexpr std::size_t kMaxHttpBody = 4096; +constexpr std::uint32_t kGeminiTestTimeoutMs = 15000; + +extern "C" const char first_run_setup_html_start[] asm("_binary_first_run_setup_html_start"); +extern "C" const char first_run_setup_html_end[] asm("_binary_first_run_setup_html_end"); + +struct ScanNetwork { + std::string ssid; + int rssi = 0; + bool secure = true; +}; + +struct State { + bool missing_wifi = false; + bool missing_key = false; + bool setup_started = false; + bool registered = false; + bool complete = false; + bool finish_pending = false; + + std::string scan_state = "idle"; // idle | scanning | done | error + std::string scan_error; + std::vector networks; + + std::string wifi_state = "idle"; // idle | connecting | connected | error + std::string wifi_error; + std::string wifi_ssid; + std::string wifi_password; + std::string wifi_static_ip; + std::string wifi_gateway; + std::string wifi_dns; + std::string wifi_ip; + + std::string gemini_state = "idle"; // idle | testing | success | error + std::string gemini_error; + std::string gemini_key; + std::string gemini_model; + std::string gemini_voice; + std::string gemini_prompt; + std::string tested_signature; + std::string tested_model; + std::string tested_voice; +}; + +struct WifiJob { + std::string ssid; + std::string password; + std::string static_ip; + std::string gateway; + std::string dns; +}; + +struct GeminiJob { + std::string key; + std::string model; + std::string voice; + std::string prompt; + std::string signature; +}; + +#if CONFIG_STACKCHAN_CONVERSATION_ENABLED +struct GeminiSignals { + std::atomic error{false}; +}; +#endif + +SemaphoreHandle_t g_lock = nullptr; +State g_state; +std::atomic g_enabled{false}; +std::atomic g_ui_prompt{false}; +std::atomic g_ui_success{false}; +std::atomic g_button_hold_suppress{false}; +std::atomic g_busy{false}; +std::atomic g_resolution_notified{false}; +bool g_button_only = false; +std::function g_ready_callback; + +class LockGuard { +public: + explicit LockGuard(SemaphoreHandle_t lock) : lock_{lock} + { + if (lock_ != nullptr) xSemaphoreTake(lock_, portMAX_DELAY); + } + ~LockGuard() + { + if (lock_ != nullptr) xSemaphoreGive(lock_); + } + LockGuard(const LockGuard&) = delete; + LockGuard& operator=(const LockGuard&) = delete; + +private: + SemaphoreHandle_t lock_; +}; + +void ensure_lock() +{ + if (g_lock == nullptr) g_lock = xSemaphoreCreateMutex(); +} + +std::string signature_for(const std::string& key, const std::string& model, + const std::string& voice, const std::string& prompt) +{ + std::string out; + out.reserve(key.size() + model.size() + voice.size() + prompt.size() + 4); + out += key; + out.push_back('\n'); + out += model; + out.push_back('\n'); + out += voice; + out.push_back('\n'); + out += prompt; + return out; +} + +std::string json_escape(const std::string& input) +{ + std::string out; + out.reserve(input.size() + 8); + for (unsigned char c : input) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) { + char buf[7]; + std::snprintf(buf, sizeof(buf), "\\u%04x", c); + out += buf; + } else { + out.push_back(static_cast(c)); + } + break; + } + } + return out; +} + +void send_json(httpd_req_t* req, int status, const std::string& body) +{ + char status_text[40]; + std::snprintf(status_text, sizeof(status_text), "%d %s", status, + status == 202 ? "Accepted" + : status == 200 ? "OK" + : status == 400 ? "Bad Request" + : status == 404 ? "Not Found" + : status == 409 ? "Conflict" + : status == 500 ? "Internal Server Error" + : "Error"); + httpd_resp_set_status(req, status_text); + httpd_resp_set_type(req, "application/json; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + (void)httpd_resp_send(req, body.data(), body.size()); +} + +void send_accepted(httpd_req_t* req) +{ + send_json(req, 202, R"({"accepted":true})"); +} + +bool read_json(httpd_req_t* req, cJSON** root) +{ + *root = nullptr; + const int len = req->content_len; + if (len < 0 || static_cast(len) > kMaxHttpBody) { + send_json(req, 400, R"({"error":"request body is too large"})"); + return false; + } + if (len == 0) { + send_json(req, 400, R"({"error":"JSON body required"})"); + return false; + } + std::string body(static_cast(len), '\0'); + std::size_t off = 0; + while (off < body.size()) { + const int got = httpd_req_recv(req, body.data() + off, body.size() - off); + if (got == HTTPD_SOCK_ERR_TIMEOUT) continue; + if (got <= 0) { + send_json(req, 400, R"({"error":"failed to read request body"})"); + return false; + } + off += static_cast(got); + } + *root = cJSON_ParseWithLength(body.data(), body.size()); + if (*root == nullptr || !cJSON_IsObject(*root)) { + cJSON_Delete(*root); + *root = nullptr; + send_json(req, 400, R"({"error":"invalid JSON object"})"); + return false; + } + return true; +} + +bool setup_routes_ready() +{ + bool setup_started; + { + LockGuard lock{g_lock}; + setup_started = g_state.setup_started; + } + return setup_started && wifi_ap_active() && wifi_ap_first_run(); +} + +bool require_setup(httpd_req_t* req) +{ + if (!setup_routes_ready()) { + send_json(req, 409, R"({"error":"first-run setup is not active"})"); + return false; + } + return true; +} + +bool valid_ipv4(const std::string& text) +{ + if (text.empty()) return false; + esp_ip4_addr_t parsed{}; + return esp_netif_str_to_ip4(text.c_str(), &parsed) == ESP_OK && parsed.addr != 0; +} + +bool valid_static_fields(const std::string& ip, const std::string& gateway, + const std::string& dns, std::string* error) +{ + if (ip.empty()) { + if (!gateway.empty() || !dns.empty()) { + *error = "Clear gateway and DNS when using DHCP."; + return false; + } + return true; + } + if (!valid_ipv4(ip)) { + *error = "Static IP must be a valid IPv4 address."; + return false; + } + if (!valid_ipv4(gateway)) { + *error = "Gateway is required for a static IP."; + return false; + } + if (!dns.empty() && !valid_ipv4(dns)) { + *error = "DNS must be a valid IPv4 address or empty."; + return false; + } + return true; +} + +bool copy_string_field(const cJSON* root, const char* key, std::size_t max_len, + std::string* out, std::string* error) +{ + const cJSON* item = cJSON_GetObjectItemCaseSensitive(root, key); + if (item == nullptr || cJSON_IsNull(item)) { + out->clear(); + return true; + } + if (!cJSON_IsString(item)) { + *error = std::string(key) + " must be a string."; + return false; + } + const std::size_t len = std::strlen(item->valuestring); + if (len > max_len) { + *error = std::string(key) + " is too long."; + return false; + } + *out = item->valuestring; + return true; +} + +void notify_resolved_once() +{ + std::function callback; + { + LockGuard lock{g_lock}; + if (!g_resolution_notified.exchange(true)) callback = g_ready_callback; + } + if (callback) callback(); +} + +void begin_skip() +{ + if (!g_enabled.load()) return; + g_ui_prompt.store(false); + g_ui_success.store(false); + notify_resolved_once(); +} + +void begin_setup() +{ + if (!g_enabled.load() || !g_ui_prompt.load()) return; + g_ui_prompt.store(false); + { + LockGuard lock{g_lock}; + g_state.setup_started = true; + g_state.wifi_state = "idle"; + g_state.gemini_state = "idle"; + g_state.scan_state = "idle"; + } + wifi_config::set_first_run_mode(true); + wifi_set_setup_mode(true); + wifi_enable_ap_mode(true); + if (!wifi_ap_active() || !wifi_ap_first_run()) { + // Return to the prompt instead of stranding the user on a dead setup + // screen. Undo every piece of setup state that may have been set by a + // partial AP bring-up. + if (wifi_ap_active()) (void)wifi_disable_ap_mode(); + wifi_config::set_first_run_mode(false); + wifi_set_setup_mode(false); + { + LockGuard lock{g_lock}; + g_state.setup_started = false; + g_state.wifi_state = "idle"; + g_state.gemini_state = "idle"; + g_state.scan_state = "idle"; + } + g_ui_prompt.store(true); + ESP_LOGE(kTag, "SoftAP could not be started"); + } +} + +std::string status_json() +{ + State snap; + { + LockGuard lock{g_lock}; + snap = g_state; + } + std::string body = "{"; + body += "\"setup_active\":" + std::string(setup_routes_ready() ? "true" : "false") + ","; + body += "\"wifi_state\":\"" + json_escape(snap.wifi_state) + "\","; + body += "\"wifi_error\":\"" + json_escape(snap.wifi_error) + "\","; + body += "\"wifi_connected\":" + + std::string((snap.wifi_state == "connected" && wifi_is_connected()) ? "true" : "false") + ","; + body += "\"wifi_ssid\":\"" + json_escape(snap.wifi_ssid) + "\","; + body += "\"wifi_ip\":\"" + json_escape(wifi_ip_address()) + "\","; + body += "\"static_ip\":\"" + json_escape(snap.wifi_static_ip) + "\","; + body += "\"gateway\":\"" + json_escape(snap.wifi_gateway) + "\","; + body += "\"dns\":\"" + json_escape(snap.wifi_dns) + "\","; + body += "\"scan_state\":\"" + json_escape(snap.scan_state) + "\","; + body += "\"scan_error\":\"" + json_escape(snap.scan_error) + "\","; + body += "\"networks\":["; + for (std::size_t i = 0; i < snap.networks.size(); ++i) { + if (i != 0) body += ","; + const auto& n = snap.networks[i]; + body += "{\"ssid\":\"" + json_escape(n.ssid) + "\",\"rssi\":" + + std::to_string(n.rssi) + ",\"secure\":" + + (n.secure ? "true" : "false") + "}"; + } + body += "],"; + body += "\"gemini_state\":\"" + json_escape(snap.gemini_state) + "\","; + body += "\"gemini_error\":\"" + json_escape(snap.gemini_error) + "\","; + body += "\"system_prompt\":\"" + json_escape(snap.gemini_prompt) + "\","; + body += "\"gemini_test_ok\":" + + std::string((snap.gemini_state == "success" && !snap.tested_signature.empty()) + ? "true" : "false") + ","; + body += "\"tested_model\":\"" + json_escape(snap.tested_model) + "\","; + body += "\"tested_voice\":\"" + json_escape(snap.tested_voice) + "\","; + body += "\"default_model\":\"" + json_escape(config::default_gemini_model()) + "\","; + body += "\"default_voice\":\"" + json_escape(config::default_gemini_voice()) + "\","; + body += "\"can_finish\":" + + std::string((snap.wifi_state == "connected" && snap.gemini_state == "success" && + !snap.tested_signature.empty()) ? "true" : "false") + ","; + body += "\"finish_pending\":" + std::string(snap.finish_pending ? "true" : "false") + ","; + body += "\"complete\":" + std::string(snap.complete ? "true" : "false"); + body += "}"; + return body; +} + +esp_err_t handle_setup_get(httpd_req_t* req) +{ + if (!g_enabled.load() || !setup_routes_ready()) { + send_json(req, 404, R"({"error":"setup page is only available in first-run mode"})"); + return ESP_OK; + } + const std::size_t len = + static_cast(first_run_setup_html_end - first_run_setup_html_start); + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return httpd_resp_send(req, first_run_setup_html_start, len); +} + +esp_err_t handle_status_get(httpd_req_t* req) +{ + if (!g_enabled.load() || !setup_routes_ready()) { + send_json(req, 404, R"({"error":"first-run setup is not active"})"); + return ESP_OK; + } + send_json(req, 200, status_json()); + return ESP_OK; +} + +void scan_task(void*) +{ + auto results = wifi_scan(); + { + LockGuard lock{g_lock}; + g_state.networks.clear(); + g_state.networks.reserve(results.size()); + for (const auto& result : results) { + g_state.networks.push_back(ScanNetwork{result.ssid, result.rssi, result.secure}); + } + g_state.scan_state = "done"; + g_state.scan_error.clear(); + } + g_busy.store(false); + vTaskDelete(nullptr); +} + +esp_err_t handle_scan_post(httpd_req_t* req) +{ + if (!require_setup(req)) return ESP_OK; + bool expected = false; + if (!g_busy.compare_exchange_strong(expected, true)) { + send_json(req, 409, R"({"error":"another setup operation is in progress"})"); + return ESP_OK; + } + { + LockGuard lock{g_lock}; + g_state.scan_state = "scanning"; + g_state.scan_error.clear(); + } + if (xTaskCreate(scan_task, "firstrun-scan", 4096, nullptr, + tskIDLE_PRIORITY + 2, nullptr) != pdPASS) { + g_busy.store(false); + { + LockGuard lock{g_lock}; + g_state.scan_state = "error"; + g_state.scan_error = "Could not start Wi-Fi scan."; + } + send_json(req, 500, R"({"error":"failed to start Wi-Fi scan"})"); + return ESP_OK; + } + send_accepted(req); + return ESP_OK; +} + +void wifi_connect_task(void* arg) +{ + std::unique_ptr job{static_cast(arg)}; + const bool ok = wifi_connect_temporary(job->ssid, job->password, job->static_ip, + job->gateway, job->dns); + const std::string ip = ok ? wifi_ip_address() : std::string{}; + { + LockGuard lock{g_lock}; + if (ok && wifi_is_connected() && !ip.empty()) { + g_state.wifi_state = "connected"; + g_state.wifi_error.clear(); + g_state.wifi_ssid = job->ssid; + g_state.wifi_password = job->password; + g_state.wifi_static_ip = job->static_ip; + g_state.wifi_gateway = job->gateway; + g_state.wifi_dns = job->dns; + g_state.wifi_ip = ip; + // A new network invalidates the previous Gemini session test. + g_state.gemini_state = "idle"; + g_state.gemini_error.clear(); + g_state.tested_signature.clear(); + g_state.tested_model.clear(); + g_state.tested_voice.clear(); + } else { + g_state.wifi_state = "error"; + g_state.wifi_error = ok ? "Connected, but no IPv4 address was assigned." + : "Could not connect. Check the SSID/password and network settings."; + g_state.wifi_ip.clear(); + } + } + g_busy.store(false); + vTaskDelete(nullptr); +} + +esp_err_t handle_wifi_post(httpd_req_t* req) +{ + if (!require_setup(req)) return ESP_OK; + cJSON* root = nullptr; + if (!read_json(req, &root)) return ESP_OK; + + std::string ssid; + std::string password; + std::string static_ip; + std::string gateway; + std::string dns; + std::string error; + const bool parsed = + copy_string_field(root, "ssid", 32, &ssid, &error) && + copy_string_field(root, "password", 64, &password, &error) && + copy_string_field(root, "static_ip", 15, &static_ip, &error) && + copy_string_field(root, "gateway", 15, &gateway, &error) && + copy_string_field(root, "dns", 15, &dns, &error); + cJSON_Delete(root); + if (!parsed) { + send_json(req, 400, "{\"error\":\"" + json_escape(error) + "\"}"); + return ESP_OK; + } + if (ssid.empty()) { + send_json(req, 400, R"({"error":"Select a Wi-Fi network first."})"); + return ESP_OK; + } + if (!valid_static_fields(static_ip, gateway, dns, &error)) { + send_json(req, 400, "{\"error\":\"" + json_escape(error) + "\"}"); + return ESP_OK; + } + + bool expected = false; + if (!g_busy.compare_exchange_strong(expected, true)) { + send_json(req, 409, R"({"error":"another setup operation is in progress"})"); + return ESP_OK; + } + { + LockGuard lock{g_lock}; + g_state.wifi_state = "connecting"; + g_state.wifi_error.clear(); + g_state.wifi_ip.clear(); + g_state.gemini_state = "idle"; + g_state.tested_signature.clear(); + } + auto* job = new WifiJob{ssid, password, static_ip, gateway, dns}; + if (xTaskCreate(wifi_connect_task, "firstrun-wifi", 6144, job, + tskIDLE_PRIORITY + 2, nullptr) != pdPASS) { + delete job; + g_busy.store(false); + LockGuard lock{g_lock}; + g_state.wifi_state = "error"; + g_state.wifi_error = "Could not start Wi-Fi connection."; + send_json(req, 500, R"({"error":"failed to start Wi-Fi connection"})"); + return ESP_OK; + } + send_accepted(req); + return ESP_OK; +} + +#if CONFIG_STACKCHAN_CONVERSATION_ENABLED +void gemini_test_task(void* arg) +{ + std::unique_ptr job{static_cast(arg)}; + auto signals = std::make_shared(); + + stackchan::conversation::GeminiLiveClient client{job->key}; + client.set_event_callback([signals](const stackchan::conversation::ConversationEvent& event) { + if (event.type == stackchan::conversation::ConversationEventType::Error || + (event.type == stackchan::conversation::ConversationEventType::StateChanged && + event.state == stackchan::conversation::ConversationState::Error)) { + signals->error.store(true, std::memory_order_release); + } + }); + + stackchan::conversation::ConversationConfig config{}; + config.model = job->model; + config.voice = job->voice; + config.instructions = job->prompt.empty() + ? "You are Stack-chan. Reply briefly. Do not call tools during this setup test." + : job->prompt; + config.input_sample_rate_hz = 16000; + config.output_sample_rate_hz = 24000; + config.enable_input_transcription = false; + + bool success = false; + std::string error; + auto started = client.start(config); + if (!started) { + error = "The WebSocket connection could not be created."; + } else { + const std::int64_t deadline = esp_timer_get_time() + kGeminiTestTimeoutMs * 1000; + while (esp_timer_get_time() < deadline) { + const auto state = client.state(); + if (state == stackchan::conversation::ConversationState::Listening || + state == stackchan::conversation::ConversationState::Speaking) { + success = true; + break; + } + if (signals->error.load(std::memory_order_acquire) || + state == stackchan::conversation::ConversationState::Error) { + error = "Gemini rejected the connection or session setup."; + break; + } + vTaskDelay(pdMS_TO_TICKS(100)); + } + if (!success && error.empty()) { + error = "Timed out waiting for Gemini setupComplete."; + } + } + client.stop(); + + { + LockGuard lock{g_lock}; + if (success) { + g_state.gemini_state = "success"; + g_state.gemini_error.clear(); + g_state.gemini_key = job->key; + g_state.gemini_model = job->model; + g_state.gemini_voice = job->voice; + g_state.gemini_prompt = job->prompt; + g_state.tested_signature = job->signature; + g_state.tested_model = job->model; + g_state.tested_voice = job->voice; + } else { + g_state.gemini_state = "error"; + g_state.gemini_error = error; + g_state.tested_signature.clear(); + g_state.tested_model.clear(); + g_state.tested_voice.clear(); + } + } + g_busy.store(false); + ESP_LOGI(kTag, "Gemini connection test %s", success ? "passed" : "failed"); + vTaskDelete(nullptr); +} +#endif + +esp_err_t handle_gemini_test_post(httpd_req_t* req) +{ + if (!require_setup(req)) return ESP_OK; +#if !CONFIG_STACKCHAN_CONVERSATION_ENABLED + send_json(req, 503, + R"({"error":"Gemini testing is unavailable in this conversation-disabled build."})"); + return ESP_OK; +#else + State snap; + { + LockGuard lock{g_lock}; + if (g_state.finish_pending) { + send_json(req, 409, R"({"error":"setup is already finishing."})"); + return ESP_OK; + } + snap = g_state; + } + if (snap.wifi_state != "connected" || !wifi_is_connected()) { + send_json(req, 409, R"({"error":"Connect Wi-Fi successfully before testing Gemini."})"); + return ESP_OK; + } + + cJSON* root = nullptr; + if (!read_json(req, &root)) return ESP_OK; + std::string key; + std::string model; + std::string voice; + std::string prompt; + std::string error; + const bool parsed = + copy_string_field(root, "api_key", 256, &key, &error) && + copy_string_field(root, "model", 64, &model, &error) && + copy_string_field(root, "voice", 32, &voice, &error) && + copy_string_field(root, "system_prompt", 2048, &prompt, &error); + cJSON_Delete(root); + if (!parsed) { + send_json(req, 400, "{\"error\":\"" + json_escape(error) + "\"}"); + return ESP_OK; + } + if (key.empty()) { + send_json(req, 400, R"({"error":"Gemini API key is required."})"); + return ESP_OK; + } + if (model.empty()) model = config::default_gemini_model(); + if (voice.empty()) voice = config::default_gemini_voice(); + + bool expected = false; + if (!g_busy.compare_exchange_strong(expected, true)) { + send_json(req, 409, R"({"error":"another setup operation is in progress"})"); + return ESP_OK; + } + { + LockGuard lock{g_lock}; + g_state.gemini_state = "testing"; + g_state.gemini_error.clear(); + g_state.tested_signature.clear(); + g_state.tested_model.clear(); + g_state.tested_voice.clear(); + } + auto* job = new GeminiJob{key, model, voice, prompt, + signature_for(key, model, voice, prompt)}; + if (xTaskCreate(gemini_test_task, "firstrun-gemini", 10240, job, + tskIDLE_PRIORITY + 2, nullptr) != pdPASS) { + delete job; + g_busy.store(false); + LockGuard lock{g_lock}; + g_state.gemini_state = "error"; + g_state.gemini_error = "Could not start the Gemini connection test."; + send_json(req, 500, R"({"error":"failed to start Gemini connection test"})"); + return ESP_OK; + } + send_accepted(req); + return ESP_OK; +#endif +} + +void restart_after_finish() +{ + vTaskDelay(pdMS_TO_TICKS(1500)); + ESP_LOGI(kTag, "restarting into configured conversation mode"); + esp_restart(); +} + +void finish_task(void*) +{ + State snap; + { + LockGuard lock{g_lock}; + snap = g_state; + } + config::DeviceConfig cfg = config::load(); + cfg.wifi_ssid = snap.wifi_ssid; + cfg.wifi_password = snap.wifi_password; + cfg.wifi_static_ip = snap.wifi_static_ip; + cfg.wifi_gateway = snap.wifi_gateway; + cfg.wifi_dns = snap.wifi_dns; + cfg.gemini_api_key = snap.gemini_key; + cfg.gemini_model = snap.gemini_model; + cfg.gemini_voice = snap.gemini_voice; + cfg.system_prompt = snap.gemini_prompt; + cfg.provider = config::Provider::Gemini; + cfg.openai_enabled = true; + cfg.jtts_idle_enabled = false; + cfg.operation_mode = config::OperationMode::Conversation; + + const auto saved = config::store::save(cfg); + if (!saved) { + { + LockGuard lock{g_lock}; + g_state.finish_pending = false; + g_state.gemini_state = "error"; + g_state.gemini_error = "Could not save setup to NVS. Please retry."; + } + g_busy.store(false); + ESP_LOGE(kTag, "failed to save first-run configuration"); + vTaskDelete(nullptr); + return; + } + + { + LockGuard lock{g_lock}; + g_state.finish_pending = false; + g_state.complete = true; + } + g_ui_success.store(true); + // Keep the setup AP and status route alive briefly so the browser can + // observe complete=true instead of inferring success from a lost socket. + vTaskDelay(pdMS_TO_TICKS(1200)); + wifi_config::set_first_run_mode(false); + const bool ap_down = wifi_disable_ap_mode(); + g_busy.store(false); + ESP_LOGI(kTag, "setup complete; AP down=%d", static_cast(ap_down)); + restart_after_finish(); + vTaskDelete(nullptr); +} + +esp_err_t handle_finish_post(httpd_req_t* req) +{ + if (!require_setup(req)) return ESP_OK; + + State snap; + { + LockGuard lock{g_lock}; + if (g_state.finish_pending) { + send_json(req, 409, R"({"error":"setup is already finishing."})"); + return ESP_OK; + } + snap = g_state; + } + if (snap.wifi_state != "connected" || !wifi_is_connected()) { + send_json(req, 409, R"({"error":"Wi-Fi is not connected."})"); + return ESP_OK; + } + if (snap.gemini_state != "success" || snap.tested_signature.empty()) { + send_json(req, 409, R"({"error":"Test the Gemini connection successfully before finishing."})"); + return ESP_OK; + } + + // If the page sends the fields again, require them to match the exact + // values covered by the successful test. An omitted/blank API key reuses + // the in-memory tested key so a page reload can still finish. + if (req->content_len > 0) { + cJSON* root = nullptr; + if (!read_json(req, &root)) return ESP_OK; + std::string key; + std::string model; + std::string voice; + std::string prompt; + std::string error; + const bool parsed = + copy_string_field(root, "api_key", 256, &key, &error) && + copy_string_field(root, "model", 64, &model, &error) && + copy_string_field(root, "voice", 32, &voice, &error) && + copy_string_field(root, "system_prompt", 2048, &prompt, &error); + cJSON_Delete(root); + if (!parsed) { + send_json(req, 400, "{\"error\":\"" + json_escape(error) + "\"}"); + return ESP_OK; + } + if (key.empty()) key = snap.gemini_key; + if (model.empty()) model = snap.gemini_model; + if (voice.empty()) voice = snap.gemini_voice; + if (signature_for(key, model, voice, prompt) != snap.tested_signature) { + send_json(req, 409, + R"({"error":"Chat settings changed after the successful test. Test again."})"); + return ESP_OK; + } + } + + bool expected = false; + if (!g_busy.compare_exchange_strong(expected, true)) { + send_json(req, 409, R"({"error":"another setup operation is in progress"})"); + return ESP_OK; + } + { + LockGuard lock{g_lock}; + g_state.finish_pending = true; + } + if (xTaskCreate(finish_task, "firstrun-finish", 6144, nullptr, + tskIDLE_PRIORITY + 2, nullptr) != pdPASS) { + g_busy.store(false); + LockGuard lock{g_lock}; + g_state.finish_pending = false; + send_json(req, 500, R"({"error":"failed to finish setup"})"); + return ESP_OK; + } + send_accepted(req); + return ESP_OK; +} + +void register_routes(httpd_handle_t server) +{ + httpd_uri_t routes[] = { + {.uri = "/setup", .method = HTTP_GET, .handler = handle_setup_get, .user_ctx = nullptr}, + {.uri = "/api/first-run/status", .method = HTTP_GET, .handler = handle_status_get, + .user_ctx = nullptr}, + {.uri = "/api/first-run/scan", .method = HTTP_POST, .handler = handle_scan_post, + .user_ctx = nullptr}, + {.uri = "/api/first-run/wifi", .method = HTTP_POST, .handler = handle_wifi_post, + .user_ctx = nullptr}, + {.uri = "/api/first-run/gemini/test", .method = HTTP_POST, + .handler = handle_gemini_test_post, .user_ctx = nullptr}, + {.uri = "/api/first-run/finish", .method = HTTP_POST, .handler = handle_finish_post, + .user_ctx = nullptr}, + }; + for (const auto& route : routes) { + esp_err_t err = httpd_register_uri_handler(server, &route); + if (err != ESP_OK) { + ESP_LOGE(kTag, "route %s registration failed: %s", route.uri, + esp_err_to_name(err)); + } + } + LockGuard lock{g_lock}; + g_state.registered = true; +} + +void route_watcher_task(void*) +{ + for (;;) { + bool setup_started; + bool registered; + { + LockGuard lock{g_lock}; + setup_started = g_state.setup_started; + registered = g_state.registered; + } + if (setup_started && !registered) { + if (httpd_handle_t server = wifi_config::handle(); server != nullptr) { + register_routes(server); + ESP_LOGI(kTag, "first-run HTTP routes registered"); + } + } + vTaskDelay(pdMS_TO_TICKS(200)); + } +} + +std::int32_t g_start_x = 0; +std::int32_t g_start_y = 0; +std::int32_t g_start_w = 0; +std::int32_t g_start_h = 0; +std::int32_t g_skip_x = 0; +std::int32_t g_skip_y = 0; +std::int32_t g_skip_w = 0; +std::int32_t g_skip_h = 0; + +void draw_prompt(avatar::RichCanvas& canvas) +{ + const std::int32_t w = canvas.width(); + const std::int32_t h = canvas.height(); + const bool compact = w < 200; + const std::uint16_t paper = canvas.color565(245, 248, 251); + const std::uint16_t ink = canvas.color565(20, 35, 52); + const std::uint16_t muted = canvas.color565(80, 100, 120); + const std::uint16_t blue = canvas.color565(8, 122, 193); + const std::uint16_t grey = canvas.color565(224, 230, 236); + + canvas.begin_frame(paper); + canvas.setTextColor(ink, paper); + canvas.setTextDatum(lgfx::textdatum_t::top_left); + canvas.setFont(&fonts::Font0); + if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_16); + canvas.drawString("Stack-chan setup", compact ? 6 : 18, compact ? 8 : 18); + + canvas.setTextColor(muted, paper); + canvas.setFont(&fonts::Font0); + if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_12); + bool missing_wifi; + bool missing_key; + { + LockGuard lock{g_lock}; + missing_wifi = g_state.missing_wifi; + missing_key = g_state.missing_key; + } + if (compact) { + canvas.drawString(missing_wifi && missing_key ? "Wi-Fi + API key missing" + : missing_wifi ? "Wi-Fi is not configured" + : "Gemini API key missing", + 6, 32); + canvas.drawString("A: setup hold A: skip", 6, h - 44); + } else { + const char* line1 = missing_wifi && missing_key + ? "Wi-Fi and Gemini API key are not configured." + : missing_wifi ? "Wi-Fi is not configured." + : "Gemini API key is not configured."; + canvas.drawString(line1, 18, 66); + canvas.drawString("Start setup opens a guided phone setup page.", 18, 88); + } + + const std::int32_t margin = compact ? 6 : 14; + const std::int32_t gap = compact ? 4 : 10; + const std::int32_t button_h = compact ? 24 : 38; + const std::int32_t button_y = h - button_h - (compact ? 7 : 14); + const std::int32_t total_w = w - margin * 2; + const std::int32_t start_w = (total_w - gap) * 3 / 5; + const std::int32_t skip_w = total_w - gap - start_w; + g_start_x = margin; + g_start_y = button_y; + g_start_w = start_w; + g_start_h = button_h; + g_skip_x = margin + start_w + gap; + g_skip_y = button_y; + g_skip_w = skip_w; + g_skip_h = button_h; + + canvas.fillRoundRect(g_skip_x, g_skip_y, g_skip_w, g_skip_h, 6, grey); + canvas.setTextColor(ink, grey); + canvas.setTextDatum(lgfx::textdatum_t::middle_center); + canvas.setFont(&fonts::Font0); + if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_12); + canvas.drawString("Skip", g_skip_x + g_skip_w / 2, g_skip_y + g_skip_h / 2); + + canvas.fillRoundRect(g_start_x, g_start_y, g_start_w, g_start_h, 6, blue); + canvas.setTextColor(TFT_WHITE, blue); + canvas.drawString("Start setup", g_start_x + g_start_w / 2, + g_start_y + g_start_h / 2); + canvas.setTextDatum(lgfx::textdatum_t::top_left); + return; +} + +void draw_success(avatar::RichCanvas& canvas) +{ + const std::int32_t w = canvas.width(); + const std::int32_t h = canvas.height(); + const bool compact = w < 200; + const std::uint16_t blue = canvas.color565(8, 122, 193); + canvas.begin_frame(blue); + canvas.setTextColor(TFT_WHITE, blue); + canvas.setTextDatum(lgfx::textdatum_t::middle_center); + canvas.setFont(&fonts::Font0); + if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_16); + canvas.drawString("Setup complete", w / 2, h / 2 - 12); + if (!compact) { + canvas.setFont(&fonts::lgfxJapanGothic_12); + canvas.drawString("Restarting...", w / 2, h / 2 + 16); + } + canvas.setTextDatum(lgfx::textdatum_t::top_left); +} + +bool hit(std::int32_t x, std::int32_t y, std::int32_t bx, std::int32_t by, + std::int32_t bw, std::int32_t bh) +{ + return bw > 0 && bh > 0 && x >= bx && x < bx + bw && y >= by && y < by + bh; +} + +} // namespace + +void init(bool required, const config::DeviceConfig& cfg, bool button_only) +{ + ensure_lock(); + g_button_only = button_only; + constexpr bool conversation_supported = +#if CONFIG_STACKCHAN_CONVERSATION_ENABLED + true; +#else + false; +#endif + const bool enabled = config::gemini_only() && required && conversation_supported; + g_enabled.store(enabled); + { + LockGuard lock{g_lock}; + g_state = State{}; + g_state.missing_wifi = cfg.wifi_ssid.empty(); + g_state.missing_key = cfg.gemini_api_key.empty(); + if (!cfg.gemini_model.empty()) g_state.gemini_model = cfg.gemini_model; + if (!cfg.gemini_voice.empty()) g_state.gemini_voice = cfg.gemini_voice; + if (!cfg.system_prompt.empty()) g_state.gemini_prompt = cfg.system_prompt; + } + g_ui_prompt.store(enabled); + g_ui_success.store(false); + g_button_hold_suppress.store(false); + g_resolution_notified.store(false); + if (enabled) { + ESP_LOGI(kTag, "prompt enabled (wifi_missing=%d key_missing=%d)", + static_cast(cfg.wifi_ssid.empty()), + static_cast(cfg.gemini_api_key.empty())); + if (xTaskCreate(route_watcher_task, "firstrun-http", 4096, nullptr, + tskIDLE_PRIORITY + 1, nullptr) != pdPASS) { + ESP_LOGE(kTag, "failed to create HTTP route watcher"); + } + } +} + +void set_ready_callback(std::function callback) +{ + ensure_lock(); + std::function notify_now; + { + LockGuard lock{g_lock}; + g_ready_callback = std::move(callback); + // Skip may resolve before app_main installs the continuation. Deliver + // an already-resolved transition immediately rather than losing it. + if (g_resolution_notified.load(std::memory_order_acquire)) { + notify_now = g_ready_callback; + } + } + if (notify_now) notify_now(); +} + +bool screen_active() +{ + // On button-only boards, a long-press Skip resolves the prompt before the + // physical button is released. Keep ownership for that remaining hold so + // the status overlay underneath cannot interpret the same press as its + // own AP-toggle gesture. + return g_ui_prompt.load() || g_ui_success.load() || + g_button_hold_suppress.load(); +} + +bool draw(avatar::RichCanvas& canvas) +{ + if (g_ui_success.load()) { + draw_success(canvas); + return true; + } + if (g_ui_prompt.load()) { + draw_prompt(canvas); + return true; + } + return false; +} + +bool handle_tap(int x, int y) +{ + if (g_ui_success.load()) return true; + if (!g_ui_prompt.load()) return false; + if (hit(x, y, g_start_x, g_start_y, g_start_w, g_start_h)) { + begin_setup(); + return true; + } + if (hit(x, y, g_skip_x, g_skip_y, g_skip_w, g_skip_h)) { + begin_skip(); + return true; + } + return true; // prompt owns the panel and must not leak taps underneath +} + +void poll_button() +{ + if (!g_button_only) return; + if (g_button_hold_suppress.load()) { + if (!M5.BtnA.isPressed()) g_button_hold_suppress.store(false); + return; + } + if (!g_ui_prompt.load()) return; + if (M5.BtnA.wasHold()) { + g_button_hold_suppress.store(true); + begin_skip(); + } else if (M5.BtnA.wasClicked()) { + begin_setup(); + } +} + +bool active() +{ + if (!g_enabled.load()) return false; + bool setup_started; + { + LockGuard lock{g_lock}; + setup_started = g_state.setup_started; + } + return g_ui_prompt.load() || g_ui_success.load() || setup_started; +} + +} // namespace stackchan::app::first_run diff --git a/main/first_run.hpp b/main/first_run.hpp new file mode 100644 index 0000000..61822de --- /dev/null +++ b/main/first_run.hpp @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 Kenta IDA +// SPDX-License-Identifier: BSL-1.0 + +#pragma once + +#include + +#include + +#include "avatar/canvas.hpp" + +namespace stackchan::app::first_run { + +// Initialise the Gemini-only setup gate. When `required` is true the prompt +// owns the panel until the user starts setup or skips it. The HTTP route +// watcher is created here; routes become live once the SoftAP HTTP server is +// up. +void init(bool required, const config::DeviceConfig& cfg, bool button_only); + +// Register the one-shot continuation invoked when the user skips setup. This +// lets app_main defer conversation-task startup until the avatar is actually +// going to be shown. Safe to call before or after init(). +void set_ready_callback(std::function callback); + +// Screen adapter hooks used by main/screens.cpp. +bool screen_active(); +bool draw(avatar::RichCanvas& canvas); +bool handle_tap(int x, int y); +void poll_button(); + +// True while the first-run prompt or setup flow owns boot. app_main uses this +// to suppress conversation startup until Skip. Completion reboots. +bool active(); + +} // namespace stackchan::app::first_run diff --git a/main/screens.cpp b/main/screens.cpp index 25b5409..96df1ca 100644 --- a/main/screens.cpp +++ b/main/screens.cpp @@ -9,6 +9,7 @@ #include "ap_screen.hpp" #include "atom_status.hpp" #include "device_ui.hpp" +#include "first_run.hpp" namespace stackchan::app::screens { @@ -45,6 +46,17 @@ class DeviceUiScreen final : public Screen { void handle_flick(int dx, int dy) override { ui::handle_flick(dx, dy); } }; +// First-run prompt / success card. Buttons are touch on CoreS3 and the A +// button on tiny boards; while setup is active the SoftAP QR screen above it +// owns the panel. +class FirstRunScreen final : public Screen { +public: + bool active() const override { return first_run::screen_active(); } + bool draw(avatar::RichCanvas& canvas) override { return first_run::draw(canvas); } + bool handle_tap(int x, int y) override { return first_run::handle_tap(x, y); } + void poll_input() override { first_run::poll_button(); } +}; + // Button-toggled status overlay (AtomS3R / AtomS3, no LCD touch). All input // arrives through the BtnA gesture vocabulary polled each tick. class AtomStatusScreen final : public Screen { @@ -59,10 +71,11 @@ class AtomStatusScreen final : public Screen { // Storage for the adapters (no heap; exactly one settings-UI flavour is // constructed). Priority = index order in g_stack. alignas(ApScreen) unsigned char g_ap_storage[sizeof(ApScreen)]; +FirstRunScreen g_first_run; DeviceUiScreen g_device_ui; AtomStatusScreen g_atom_status; -std::array g_stack{}; +std::array g_stack{}; std::size_t g_count = 0; } // namespace @@ -71,6 +84,7 @@ void init(M5GFX& display, SharedState& state, const stackchan::board::BoardProfi { g_count = 0; g_stack[g_count++] = new (g_ap_storage) ApScreen{display}; + g_stack[g_count++] = &g_first_run; if (profile.button_overlay_ui) { atom_status::init(state); g_stack[g_count++] = &g_atom_status; @@ -101,7 +115,14 @@ bool draw_overlay(avatar::RichCanvas& canvas) void poll_inputs() { for (std::size_t i = 0; i < g_count; ++i) { + const bool owns_input = g_stack[i]->active(); g_stack[i]->poll_input(); + // First-run owns the button until its own gesture is complete. This + // matters for Skip: the same long-press release must not also toggle + // the Atom status overlay underneath. Other overlays keep their old + // behaviour, including Atom's A-button shortcut for closing the + // normal settings SoftAP. + if (owns_input && g_stack[i] == &g_first_run) break; } } diff --git a/main/web/first_run_setup.html b/main/web/first_run_setup.html new file mode 100644 index 0000000..d21f6e7 --- /dev/null +++ b/main/web/first_run_setup.html @@ -0,0 +1,680 @@ + + + + + + + +Stack-chan setup + + + +
+
+ +
+

Stack-chan setup

+

Connect Wi-Fi, then verify Gemini.

+
+
+ + + +
+
+

1. Connect Wi-Fi

+

Choose the network Stack-chan should use. You can stay connected to the Stack-chan setup Wi-Fi while the device joins your home network.

+ +
+
+
Available networks
+
Starting scan…
+
+ +
+
+
Scanning…
+
+ +
+ + +
+ +
+ + +
+ +
+ Advanced network settings +
+

Leave Static IP empty to use DHCP. A static address is applied with a /24 subnet mask. Gateway is required for static mode; leave DNS empty to use the gateway.

+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+
Select a network, enter its password, then connect.
+ + +
+ +
+
+ + + + +
+
+ + + + diff --git a/main/wifi_sta.cpp b/main/wifi_sta.cpp index e99a1cd..d4f8bd0 100644 --- a/main/wifi_sta.cpp +++ b/main/wifi_sta.cpp @@ -3,9 +3,13 @@ #include "wifi_sta.hpp" +#include #include #include #include +#include +#include +#include #include #include @@ -31,8 +35,8 @@ namespace { constexpr const char* kTag = "wifi-sta"; // Bounded copy into a fixed field that always NUL-terminates. Used for the -// esp_wifi AP config's ssid/password byte arrays. strncpy(dst, src, sizeof-1) -// and snprintf both trip GCC 14's -Werror=stringop-truncation / +// esp_wifi AP/STA config's byte arrays. strncpy(dst, src, sizeof-1) and +// snprintf both trip GCC 14's -Werror=stringop-truncation / // format-truncation under IDF 5.5; an explicit memcpy of a clamped length // followed by a NUL is accepted on both IDF 5.4 (GCC 13) and 5.5 (GCC 14). void copy_field(std::uint8_t* dst, std::size_t cap, const char* src) @@ -45,74 +49,193 @@ void copy_field(std::uint8_t* dst, std::size_t cap, const char* src) std::atomic g_connected{false}; std::atomic g_ap_active{false}; +std::atomic g_ap_first_run{false}; +std::atomic g_setup_mode{false}; +std::atomic g_wifi_inited{false}; + // Cached AP credentials so wifi_ap_info() can serve the on-device QR screen // without re-querying the driver. Derived from ESP_MAC_WIFI_STA. char g_ap_ssid[33] = {0}; char g_ap_pw[33] = {0}; char g_ap_ip[16] = "192.168.4.1"; +esp_netif_t* g_sta_netif = nullptr; esp_netif_t* g_ap_netif = nullptr; + // Snapshot of the config used at boot — held so the HTTP settings service -// can be brought up on the *first* IP_EVENT_STA_GOT_IP with the same view -// of NVS that the BLE service registered. After the first start it stays -// up across reconnects (wifi_config_service::start ignores re-entry). +// can be brought up on the first IP_EVENT_STA_GOT_IP with the same view of +// NVS that the BLE service registered. After the first start it stays up +// across reconnects (wifi_config_service::start ignores re-entry). const config::DeviceConfig* g_boot_cfg = nullptr; +char g_configured_ssid[33] = {0}; // One-shot timer that defers the next esp_wifi_connect() while the // provisioning AP is up. Each STA connect attempt runs a full-channel scan // (~5 s) that pulls the shared radio off the AP's channel — with the // immediate-retry loop the AP beacons go silent most of the time and joined -// clients get kicked within seconds (observed on HW when the stored SSID no -// longer exists, which is exactly the situation provisioning is for). While -// the AP is active the retry is throttled to this period instead — and -// paused entirely while a client is associated (the off-channel scan makes -// the AP fail its SA-query keepalive and disassoc the client mid-page-load; -// also observed on HW). +// clients get kicked within seconds. While the AP is active the retry is +// throttled to this period, and paused entirely while a client is associated. constexpr std::uint64_t kApModeStaRetryUs = 30 * 1000 * 1000; esp_timer_handle_t g_sta_retry_timer = nullptr; std::atomic g_ap_client_count{0}; +bool can_auto_connect() +{ + if (g_setup_mode.load(std::memory_order_acquire)) return false; + if (g_configured_ssid[0] == '\0') return false; + if (g_ap_active.load(std::memory_order_acquire) && + g_ap_client_count.load(std::memory_order_acquire) > 0) { + return false; + } + return true; +} + +void stop_retry_timer() +{ + if (g_sta_retry_timer != nullptr) { + (void)esp_timer_stop(g_sta_retry_timer); + } +} + void sta_retry_timer_cb(void*) { - esp_wifi_connect(); + if (can_auto_connect()) { + (void)esp_wifi_connect(); + } +} + +bool valid_ipv4(const std::string& text, esp_ip4_addr_t* out) +{ + if (text.empty()) return false; + esp_ip4_addr_t parsed{}; + if (esp_netif_str_to_ip4(text.c_str(), &parsed) != ESP_OK || parsed.addr == 0) { + return false; + } + if (out != nullptr) *out = parsed; + return true; +} + +esp_err_t start_dhcp() +{ + if (g_sta_netif == nullptr) return ESP_ERR_INVALID_STATE; + const esp_err_t err = esp_netif_dhcpc_start(g_sta_netif); + return (err == ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) ? ESP_OK : err; +} + +// Apply the optional static IPv4 configuration. Empty static_ip selects +// DHCP. Static configuration uses a /24 mask, requires a gateway, and uses +// the gateway as DNS when dns is empty. Malformed saved settings fall back +// to DHCP rather than leaving the interface unreachable. +bool apply_sta_ipv4(const std::string& static_ip, const std::string& gateway, + const std::string& dns) +{ + if (g_sta_netif == nullptr) return false; + + if (static_ip.empty()) { + stop_retry_timer(); + const esp_err_t err = start_dhcp(); + if (err != ESP_OK) { + ESP_LOGW(kTag, "DHCP start failed: %s", esp_err_to_name(err)); + } + return true; + } + + esp_ip4_addr_t ip{}; + esp_ip4_addr_t gw{}; + if (!valid_ipv4(static_ip, &ip) || !valid_ipv4(gateway, &gw)) { + ESP_LOGW(kTag, "invalid static IPv4 config (ip=%s gateway=%s) — falling back to DHCP", + static_ip.c_str(), gateway.c_str()); + (void)start_dhcp(); + return false; + } + + esp_ip4_addr_t dns_addr = gw; + if (!dns.empty() && !valid_ipv4(dns, &dns_addr)) { + ESP_LOGW(kTag, "invalid static DNS '%s' — using gateway", dns.c_str()); + dns_addr = gw; + } + + const esp_err_t stop_err = esp_netif_dhcpc_stop(g_sta_netif); + if (stop_err != ESP_OK && stop_err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { + ESP_LOGW(kTag, "DHCP stop failed: %s — falling back to DHCP", esp_err_to_name(stop_err)); + (void)start_dhcp(); + return false; + } + + esp_netif_ip_info_t info{}; + info.ip = ip; + info.gw = gw; + (void)esp_netif_str_to_ip4("255.255.255.0", &info.netmask); + esp_err_t err = esp_netif_set_ip_info(g_sta_netif, &info); + if (err != ESP_OK) { + ESP_LOGW(kTag, "set static IPv4 failed: %s — falling back to DHCP", esp_err_to_name(err)); + (void)start_dhcp(); + return false; + } + + esp_netif_dns_info_t dns_info{}; + dns_info.ip.type = ESP_IPADDR_TYPE_V4; + dns_info.ip.u_addr.ip4 = dns_addr; + err = esp_netif_set_dns_info(g_sta_netif, ESP_NETIF_DNS_MAIN, &dns_info); + if (err != ESP_OK) { + ESP_LOGW(kTag, "set static DNS failed: %s", esp_err_to_name(err)); + } + ESP_LOGI(kTag, "STA static IPv4 " IPSTR "/24 gateway " IPSTR " dns " IPSTR, + IP2STR(&ip), IP2STR(&gw), IP2STR(&dns_addr)); + return true; +} + +void schedule_retry_if_allowed() +{ + if (g_sta_retry_timer == nullptr || !can_auto_connect()) return; + stop_retry_timer(); + (void)esp_timer_start_once(g_sta_retry_timer, kApModeStaRetryUs); } void event_handler(void* /*arg*/, esp_event_base_t base, int32_t id, void* data) { if (base == WIFI_EVENT) { if (id == WIFI_EVENT_STA_START) { - esp_wifi_connect(); + if (can_auto_connect()) { + (void)esp_wifi_connect(); + } else { + ESP_LOGI(kTag, "STA started; automatic connect suppressed (%s)", + g_setup_mode.load(std::memory_order_acquire) ? "setup mode" + : "no SSID"); + } } else if (id == WIFI_EVENT_STA_DISCONNECTED) { g_connected.store(false, std::memory_order_release); config::notify_wifi_connected(false); wifi_config::notify_wifi_connected(false); - if (g_ap_active.load(std::memory_order_acquire) && - g_sta_retry_timer != nullptr) { - esp_timer_stop(g_sta_retry_timer); + + if (g_setup_mode.load(std::memory_order_acquire)) { + ESP_LOGW(kTag, "STA disconnected in setup mode; retry paused"); + } else if (g_configured_ssid[0] == '\0') { + ESP_LOGW(kTag, "STA disconnected; no SSID configured, retry paused"); + } else if (g_ap_active.load(std::memory_order_acquire)) { + stop_retry_timer(); if (g_ap_client_count.load(std::memory_order_acquire) > 0) { ESP_LOGW(kTag, "disconnected — AP client associated, STA retry paused"); } else { ESP_LOGW(kTag, "disconnected — AP mode active, next retry in %llu s", kApModeStaRetryUs / 1000000ULL); - esp_timer_start_once(g_sta_retry_timer, kApModeStaRetryUs); + (void)esp_timer_start_once(g_sta_retry_timer, kApModeStaRetryUs); } } else { ESP_LOGW(kTag, "disconnected, retrying"); - esp_wifi_connect(); + (void)esp_wifi_connect(); } } else if (id == WIFI_EVENT_AP_STACONNECTED) { // A provisioning client joined — give it quiet air: no STA // scans until every client leaves. g_ap_client_count.fetch_add(1, std::memory_order_acq_rel); - if (g_sta_retry_timer != nullptr) esp_timer_stop(g_sta_retry_timer); + stop_retry_timer(); } else if (id == WIFI_EVENT_AP_STADISCONNECTED) { const int left = g_ap_client_count.fetch_sub(1, std::memory_order_acq_rel) - 1; if (left <= 0) { g_ap_client_count.store(0, std::memory_order_release); if (g_ap_active.load(std::memory_order_acquire) && - !g_connected.load(std::memory_order_acquire) && - g_sta_retry_timer != nullptr) { - esp_timer_stop(g_sta_retry_timer); - esp_timer_start_once(g_sta_retry_timer, kApModeStaRetryUs); + !g_connected.load(std::memory_order_acquire)) { + schedule_retry_if_allowed(); } } } @@ -134,13 +257,8 @@ void event_handler(void* /*arg*/, esp_event_base_t base, int32_t id, void* data) } wifi_config::notify_wifi_connected(true); - // SNTP — start once on the first connection. esp_netif_sntp_init - // sets up the lwIP SNTP client in background; it will write the - // system time as soon as a response comes back. We also push the - // synced time into M5.Rtc once it's valid so the watch keeps - // sensible time across short power loss (RX8130CE is battery- - // backed via the M5PM1 PMIC). JST is hardcoded — no UI to pick - // a TZ on a wearable, and the dev unit lives in JP. + // SNTP — start once on the first connection. JST is the product's + // default; the time is mirrored into the battery-backed RTC. static bool sntp_started = false; if (!sntp_started) { sntp_started = true; @@ -149,10 +267,6 @@ void event_handler(void* /*arg*/, esp_event_base_t base, int32_t id, void* data) esp_sntp_config_t cfg = ESP_NETIF_SNTP_DEFAULT_CONFIG("pool.ntp.org"); cfg.start = true; esp_netif_sntp_init(&cfg); - // Mirror system time into M5.Rtc once SNTP has converged. - // Block briefly here to give the first sync a chance; if it - // hasn't arrived in 5 s we move on and the next reconnect - // will retry. ESP_OK = synced, ERR_TIMEOUT = not yet. if (esp_netif_sntp_sync_wait(pdMS_TO_TICKS(5000)) == ESP_OK) { time_t now = 0; ::time(&now); @@ -189,16 +303,13 @@ void wifi_start(const config::DeviceConfig& cfg) ESP_ERROR_CHECK(esp_event_loop_create_default()); loop_started = true; } - esp_netif_create_default_wifi_sta(); + if (g_sta_netif == nullptr) { + g_sta_netif = esp_netif_create_default_wifi_sta(); + } wifi_init_config_t wifi_cfg = WIFI_INIT_CONFIG_DEFAULT(); - // 会話 / ASR モードは内部 RAM が最も厳しい (TLS + WebSocket + マイク DMA)。 - // Wi-Fi の静的 RX/TX バッファは DMA 対応内部 RAM を ~1.6 KiB/個 使うため、 - // RTP 受信が走らないこれらのモードでは 2026-07 当時の 3 / 4 に絞る。 - // (sdkconfig の 6 / 10 は RTP 受信 (L16 48 kHz, ~70 pkt/s) 用。alpha.3 で - // CoreS3 も 6 / 10 に戻した直後、Gemini Live の setup 直後に esp-aes の - // 1.6 KiB 確保が失敗して切断 → 再接続不能になった。) - // RX BA ウィンドウは静的 RX の 2 倍以下という制約があるので合わせて縮める。 + // Conversation / ASR modes are the tightest internal-RAM cases (TLS + + // WebSocket + mic DMA). Trim the static RX/TX buffers as before. if (cfg.operation_mode == config::OperationMode::Conversation || cfg.operation_mode == config::OperationMode::AsrLocal) { wifi_cfg.static_rx_buf_num = 3; @@ -210,14 +321,15 @@ void wifi_start(const config::DeviceConfig& cfg) static_cast(cfg.operation_mode), wifi_cfg.static_rx_buf_num, wifi_cfg.static_tx_buf_num); } - ESP_ERROR_CHECK(esp_wifi_init(&wifi_cfg)); + if (!g_wifi_inited.load(std::memory_order_acquire)) { + ESP_ERROR_CHECK(esp_wifi_init(&wifi_cfg)); + g_wifi_inited.store(true, std::memory_order_release); - ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, - &event_handler, nullptr)); - ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, - &event_handler, nullptr)); + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &event_handler, nullptr)); + ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + &event_handler, nullptr)); - if (g_sta_retry_timer == nullptr) { const esp_timer_create_args_t args = { .callback = &sta_retry_timer_cb, .arg = nullptr, @@ -228,21 +340,33 @@ void wifi_start(const config::DeviceConfig& cfg) ESP_ERROR_CHECK(esp_timer_create(&args, &g_sta_retry_timer)); } - if (cfg.wifi_ssid.empty()) { - ESP_LOGI(kTag, "no SSID configured — Wi-Fi driver initialised but not started"); - return; - } + copy_field(reinterpret_cast(g_configured_ssid), + sizeof(g_configured_ssid), cfg.wifi_ssid.c_str()); wifi_config_t sta_cfg{}; - std::strncpy(reinterpret_cast(sta_cfg.sta.ssid), - cfg.wifi_ssid.c_str(), sizeof(sta_cfg.sta.ssid) - 1); - std::strncpy(reinterpret_cast(sta_cfg.sta.password), - cfg.wifi_password.c_str(), sizeof(sta_cfg.sta.password) - 1); + copy_field(sta_cfg.sta.ssid, sizeof(sta_cfg.sta.ssid), cfg.wifi_ssid.c_str()); + copy_field(sta_cfg.sta.password, sizeof(sta_cfg.sta.password), + cfg.wifi_password.c_str()); + + // DHCP is the default; malformed saved static values intentionally fall + // back to DHCP so a hand-edited NVS blob cannot strand the device. + (void)apply_sta_ipv4(cfg.wifi_static_ip, cfg.wifi_gateway, cfg.wifi_dns); ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &sta_cfg)); + // Always start the driver. With an empty SSID this keeps the STA + // interface ready for first-run scans/temporary connections; the event + // handler suppresses the automatic connect. ESP_ERROR_CHECK(esp_wifi_start()); - ESP_LOGI(kTag, "connecting to SSID: %s", cfg.wifi_ssid.c_str()); + + if (cfg.wifi_ssid.empty()) { + ESP_LOGI(kTag, "no SSID configured — Wi-Fi driver started, STA connect suppressed"); + } else if (g_setup_mode.load(std::memory_order_acquire)) { + ESP_LOGI(kTag, "setup mode active — stored SSID '%s' connect suppressed", + cfg.wifi_ssid.c_str()); + } else { + ESP_LOGI(kTag, "connecting to SSID: %s", cfg.wifi_ssid.c_str()); + } } bool wifi_is_connected() @@ -250,21 +374,139 @@ bool wifi_is_connected() return g_connected.load(std::memory_order_acquire); } +std::vector wifi_scan() +{ + std::vector results; + if (!g_wifi_inited.load(std::memory_order_acquire)) return results; + + stop_retry_timer(); + wifi_scan_config_t scan_cfg{}; + scan_cfg.show_hidden = false; + esp_err_t err = esp_wifi_scan_start(&scan_cfg, true); + if (err != ESP_OK) { + ESP_LOGW(kTag, "scan start failed: %s", esp_err_to_name(err)); + return results; + } + + std::uint16_t count = 0; + err = esp_wifi_scan_get_ap_num(&count); + if (err != ESP_OK || count == 0) { + ESP_LOGI(kTag, "scan complete: no networks"); + return results; + } + std::vector aps(count); + err = esp_wifi_scan_get_ap_records(&count, aps.data()); + if (err != ESP_OK) { + ESP_LOGW(kTag, "scan get records failed: %s", esp_err_to_name(err)); + return results; + } + + results.reserve(count); + for (std::uint16_t i = 0; i < count; ++i) { + const auto* ssid = reinterpret_cast(aps[i].ssid); + if (ssid[0] == '\0') continue; + results.push_back(WifiScanResult{ + .ssid = ssid, + .rssi = aps[i].rssi, + .secure = aps[i].authmode != WIFI_AUTH_OPEN, + }); + } + // AP records contain one entry per BSSID. Sort strongest first, then drop + // repeated SSIDs so the setup page offers each network only once. + std::sort(results.begin(), results.end(), [](const auto& a, const auto& b) { + return a.rssi > b.rssi; + }); + std::unordered_set seen; + results.erase(std::remove_if(results.begin(), results.end(), + [&seen](const WifiScanResult& result) { + return !seen.insert(result.ssid).second; + }), + results.end()); + ESP_LOGI(kTag, "scan complete: %u unique networks", static_cast(results.size())); + return results; +} + +bool wifi_connect_temporary(const std::string& ssid, const std::string& password, + const std::string& static_ip, const std::string& gateway, + const std::string& dns) +{ + if (!g_wifi_inited.load(std::memory_order_acquire) || g_sta_netif == nullptr) { + return false; + } + if (ssid.empty() || ssid.size() > 32 || password.size() > 64) { + ESP_LOGW(kTag, "temporary connect rejected: invalid SSID/password length"); + return false; + } + + g_setup_mode.store(true, std::memory_order_release); + stop_retry_timer(); + g_connected.store(false, std::memory_order_release); + esp_err_t err = esp_wifi_disconnect(); + if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_CONNECT) { + ESP_LOGW(kTag, "disconnect before setup returned %s", esp_err_to_name(err)); + } + + wifi_config_t sta_cfg{}; + copy_field(sta_cfg.sta.ssid, sizeof(sta_cfg.sta.ssid), ssid.c_str()); + copy_field(sta_cfg.sta.password, sizeof(sta_cfg.sta.password), password.c_str()); + if (esp_wifi_set_config(WIFI_IF_STA, &sta_cfg) != ESP_OK) return false; + (void)apply_sta_ipv4(static_ip, gateway, dns); + + err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGW(kTag, "temporary connect start failed: %s", esp_err_to_name(err)); + return false; + } + + for (int i = 0; i < 200; ++i) { + if (g_connected.load(std::memory_order_acquire)) { + ESP_LOGI(kTag, "temporary connection succeeded: SSID=%s IP=%s", + ssid.c_str(), wifi_ip_address().c_str()); + return true; + } + vTaskDelay(pdMS_TO_TICKS(100)); + } + ESP_LOGW(kTag, "temporary connection timed out after 20 s: SSID=%s", ssid.c_str()); + return false; +} + +std::string wifi_ip_address() +{ + if (g_sta_netif == nullptr) return {}; + esp_netif_ip_info_t info{}; + if (esp_netif_get_ip_info(g_sta_netif, &info) != ESP_OK || info.ip.addr == 0) { + return {}; + } + char ip[16] = {}; + std::snprintf(ip, sizeof(ip), IPSTR, IP2STR(&info.ip)); + return ip; +} + +void wifi_set_setup_mode(bool active) +{ + const bool was_active = g_setup_mode.exchange(active, std::memory_order_acq_rel); + if (active) { + stop_retry_timer(); + ESP_LOGI(kTag, "setup mode enabled — automatic STA reconnect paused"); + return; + } + if (was_active) { + ESP_LOGI(kTag, "setup mode disabled — automatic STA reconnect resumed"); + if (!g_connected.load(std::memory_order_acquire) && + !g_ap_active.load(std::memory_order_acquire) && + g_configured_ssid[0] != '\0' && + g_wifi_inited.load(std::memory_order_acquire)) { + (void)esp_wifi_connect(); + } + } +} + +bool wifi_setup_mode() +{ + return g_setup_mode.load(std::memory_order_acquire); +} + // --- SoftAP provisioning ---------------------------------------------------- -// -// Triggered from the on-device Settings UI when home Wi-Fi credentials are -// missing/wrong. Brings up an isolated AP on top of the existing STA driver -// (APSTA dual mode) so the user can join the AP from an iPhone — which has -// no Web Bluetooth — and configure home Wi-Fi through the same settings_wifi -// HTTP page used over STA. STA stays attempting reconnect in parallel; we -// intentionally do not tear the AP down on STA_GOT_IP because the user is -// often mid-edit when their changes take effect. -// -// Credentials are derived from the STA MAC (same low 3 bytes already used -// for the BLE advertising name and mDNS hostname) so they are stable across -// reboots and printed/displayed on the QR screen: -// SSID = "Stackchan-AABBCC" (6 hex chars) -// PSK = "sc-AABBCCDD" (8 hex chars; 11 char total; WPA2 minimum 8) namespace { @@ -279,24 +521,17 @@ void derive_ap_credentials() "sc-%02x%02x%02x%02x", mac[2], mac[3], mac[4], mac[5]); } -} // namespace - -// Worker task body — runs the parts of AP bring-up that may take O(seconds) -// (HTTP server start + handle polling) off the caller's task so a tap -// handler in demo_loop returns quickly and the QR screen appears within -// one render frame. void ap_post_start_worker(void*) { if (g_boot_cfg != nullptr) { // wifi_config::start is idempotent — returns AlreadyStarted if the - // server is already up from a previous STA_GOT_IP. The server - // binds to INADDR_ANY so it serves both AP and STA interfaces. + // server is already up from a previous STA_GOT_IP. The server binds + // to INADDR_ANY so it serves both AP and STA interfaces. (void)wifi_config::start(*g_boot_cfg); } wifi_config::set_provisioning_mode(true); + wifi_config::set_first_run_mode(g_ap_first_run.load(std::memory_order_acquire)); captive_portal::start_dns(); - // Poll up to ~2 s for the init task to publish its server handle. - // Done off the caller so demo_loop isn't blocked. for (int i = 0; i < 40; ++i) { if (auto h = wifi_config::handle(); h != nullptr) { captive_portal::register_http_catchall(h); @@ -307,46 +542,37 @@ void ap_post_start_worker(void*) vTaskDelete(nullptr); } -void wifi_enable_ap_mode() +} // namespace + +void wifi_enable_ap_mode(bool first_run) { - if (g_ap_active.load(std::memory_order_acquire)) return; + if (g_ap_active.load(std::memory_order_acquire)) { + if (first_run) g_ap_first_run.store(true, std::memory_order_release); + return; + } + g_ap_first_run.store(first_run, std::memory_order_release); + if (first_run) wifi_set_setup_mode(true); derive_ap_credentials(); if (g_ap_netif == nullptr) { g_ap_netif = esp_netif_create_default_wifi_ap(); } - // APSTA keeps the STA side of the driver running so reconnect attempts - // continue in background; the user can fix credentials and watch them - // take effect without rebooting. - // - // Use error-returns instead of ESP_ERROR_CHECK throughout: set_mode can - // legitimately fail when STA is mid-reconnect-storm (wrong password - // configured → tight WIFI_EVENT_STA_DISCONNECTED → esp_wifi_connect() - // loop) and aborting the entire firmware on a transient set_mode error - // looks to the user like "tap did nothing" (the device just reboots). - // set_mode(APSTA) can transiently fail while STA is mid-reconnect-storm - // (wrong password → tight esp_wifi_connect() loop). Retry a few times - // before giving up so a single "AP モード" tap reliably brings the AP up. - // Same retry budget / task-blocking rationale as wifi_disable_ap_mode. - { - constexpr int kMaxRetries = 5; - esp_err_t err = ESP_OK; - for (int i = 0; i < kMaxRetries; ++i) { - err = esp_wifi_set_mode(WIFI_MODE_APSTA); - if (err == ESP_OK) break; - ESP_LOGW(kTag, "set_mode(APSTA) failed (try %d/%d): %s", - i + 1, kMaxRetries, esp_err_to_name(err)); - if (i + 1 < kMaxRetries) vTaskDelay(pdMS_TO_TICKS(100)); - } - if (err != ESP_OK) { - // Nothing enabled yet: leave the driver as-is (STA) and bail so a - // later tap can retry. g_ap_active stays false — consistent. - ESP_LOGE(kTag, "set_mode(APSTA) failed after %d tries: %s" - " — AP not enabled", - kMaxRetries, esp_err_to_name(err)); - return; - } + // APSTA keeps the STA side alive for candidate-network scans/connects. + constexpr int kMaxRetries = 5; + esp_err_t err = ESP_OK; + for (int i = 0; i < kMaxRetries; ++i) { + err = esp_wifi_set_mode(WIFI_MODE_APSTA); + if (err == ESP_OK) break; + ESP_LOGW(kTag, "set_mode(APSTA) failed (try %d/%d): %s", + i + 1, kMaxRetries, esp_err_to_name(err)); + if (i + 1 < kMaxRetries) vTaskDelay(pdMS_TO_TICKS(100)); + } + if (err != ESP_OK) { + ESP_LOGE(kTag, "set_mode(APSTA) failed after %d tries: %s — AP not enabled", + kMaxRetries, esp_err_to_name(err)); + g_ap_first_run.store(false, std::memory_order_release); + return; } wifi_config_t ap_cfg{}; @@ -357,22 +583,19 @@ void wifi_enable_ap_mode() ap_cfg.ap.authmode = WIFI_AUTH_WPA2_PSK; ap_cfg.ap.max_connection = 4; ap_cfg.ap.pmf_cfg.required = false; - if (esp_err_t err = esp_wifi_set_config(WIFI_IF_AP, &ap_cfg); err != ESP_OK) { + if (esp_err_t cfg_err = esp_wifi_set_config(WIFI_IF_AP, &ap_cfg); cfg_err != ESP_OK) { ESP_LOGE(kTag, "set_config(AP) failed: %s — reverting to STA", - esp_err_to_name(err)); + esp_err_to_name(cfg_err)); (void)esp_wifi_set_mode(WIFI_MODE_STA); + g_ap_first_run.store(false, std::memory_order_release); return; } - // esp_wifi_start() is idempotent; calling it after set_mode picks up the - // new AP interface even when STA was already running. - esp_err_t st = esp_wifi_start(); + const esp_err_t st = esp_wifi_start(); if (st != ESP_OK && st != ESP_ERR_WIFI_NOT_INIT) { ESP_LOGW(kTag, "esp_wifi_start (AP) returned %s", esp_err_to_name(st)); } - // Read the AP IP back so the QR screen reflects whatever the driver - // chose (default 192.168.4.1 unless reconfigured elsewhere). if (g_ap_netif != nullptr) { esp_netif_ip_info_t ip{}; if (esp_netif_get_ip_info(g_ap_netif, &ip) == ESP_OK && ip.ip.addr != 0) { @@ -381,34 +604,27 @@ void wifi_enable_ap_mode() } g_ap_active.store(true, std::memory_order_release); - ESP_LOGI(kTag, "AP up: SSID=\"%s\" PW=\"%s\" IP=%s", - g_ap_ssid, g_ap_pw, g_ap_ip); - - // Hand off the rest (HTTP server / mDNS / captive portal) to a worker - // task. demo_loop returns immediately so the next render-task tick - // sees ap_active() = true and ap_screen paints. Stack 3 KiB internal - // RAM — flash-safe (no OTA / NVS writes happen here). - xTaskCreate(&ap_post_start_worker, "ap-post-start", 3 * 1024, nullptr, - tskIDLE_PRIORITY + 2, nullptr); + // Never log the AP password. It is shown on-device / in the QR payload. + ESP_LOGI(kTag, "AP up: SSID=\"%s\" IP=%s first_run=%d", + g_ap_ssid, g_ap_ip, static_cast(first_run)); + + if (xTaskCreate(&ap_post_start_worker, "ap-post-start", 3 * 1024, nullptr, + tskIDLE_PRIORITY + 2, nullptr) != pdPASS) { + ESP_LOGE(kTag, "failed to spawn AP HTTP worker"); + g_ap_active.store(false, std::memory_order_release); + g_ap_first_run.store(false, std::memory_order_release); + } } -void wifi_disable_ap_mode() +bool wifi_disable_ap_mode() { - if (!g_ap_active.load(std::memory_order_acquire)) return; - - // Drop back to STA-only *first*, and only tear down the portal / clear the - // provisioning + ap_active flags once that actually succeeds. Otherwise a - // failed set_mode leaves the AP radio up while the UI reports it closed — - // the "画面は閉じたが AP は生きている" inconsistency. set_mode(STA) can - // transiently fail mid-reconnect-storm (the typical provisioning state: - // wrong password → tight esp_wifi_connect() retry loop), so retry a few - // times before giving up. - // - // Called from the app_main touch loop (ap_screen::handle_tap / - // device_ui). Rendering lives on a separate pinned task, so a short block - // here only stalls touch polling / M5.update(). The common path succeeds - // on the first try (no delay); the 5×100 ms = 500 ms worst case only hits - // on total failure, which is rare and preferable to a stuck AP. + if (!g_ap_active.load(std::memory_order_acquire)) return true; + + const bool was_first_run = g_ap_first_run.load(std::memory_order_acquire); + + // Drop back to STA-only first, and only tear down the portal once that + // succeeds. A failed set_mode leaves the AP fully alive so the UI can + // retry rather than claiming success. constexpr int kMaxRetries = 5; esp_err_t err = ESP_OK; for (int i = 0; i < kMaxRetries; ++i) { @@ -419,17 +635,12 @@ void wifi_disable_ap_mode() if (i + 1 < kMaxRetries) vTaskDelay(pdMS_TO_TICKS(100)); } if (err != ESP_OK) { - // Keep the AP fully alive so the next "終了" tap can retry. Nothing - // has been torn down yet, so there's no state to restore. ESP_LOGE(kTag, "set_mode(STA) on AP-disable failed after %d tries: %s" " — leaving AP up so it can be retried", kMaxRetries, esp_err_to_name(err)); - return; + return false; } - // STA-only confirmed. Now tear down the captive portal so STA-mode clients - // don't see the 404-to-root redirect anymore (the catch-all is harmless - // over STA, but it'd hijack legitimate 404s from poorly-written clients). captive_portal::stop_dns(); if (auto h = wifi_config::handle(); h != nullptr) { captive_portal::unregister_http_catchall(h); @@ -437,12 +648,20 @@ void wifi_disable_ap_mode() wifi_config::set_provisioning_mode(false); g_ap_active.store(false, std::memory_order_release); g_ap_client_count.store(0, std::memory_order_release); - // Resume the full-rate STA reconnect immediately: cancel any throttled - // retry and kick a connect now (harmless error return if an attempt is - // already in flight or the STA is currently paused-while-client). - if (g_sta_retry_timer != nullptr) esp_timer_stop(g_sta_retry_timer); - if (!g_connected.load(std::memory_order_acquire)) esp_wifi_connect(); + stop_retry_timer(); + + // First-run finish deliberately keeps setup mode active and preserves the + // accepted candidate association until the caller reboots into the saved + // config. The normal settings-AP path resumes reconnecting immediately. + if (!was_first_run) { + wifi_set_setup_mode(false); + if (!g_connected.load(std::memory_order_acquire)) { + (void)esp_wifi_connect(); + } + } + g_ap_first_run.store(false, std::memory_order_release); ESP_LOGI(kTag, "AP down"); + return true; } bool wifi_ap_active() @@ -450,6 +669,11 @@ bool wifi_ap_active() return g_ap_active.load(std::memory_order_acquire); } +bool wifi_ap_first_run() +{ + return g_ap_first_run.load(std::memory_order_acquire); +} + bool wifi_ap_info(char* ssid, std::size_t ssid_cap, char* password, std::size_t pw_cap, char* ip, std::size_t ip_cap) diff --git a/main/wifi_sta.hpp b/main/wifi_sta.hpp index 94e062b..4ef3266 100644 --- a/main/wifi_sta.hpp +++ b/main/wifi_sta.hpp @@ -4,6 +4,9 @@ #pragma once #include +#include +#include +#include #include @@ -19,6 +22,37 @@ void wifi_start(const config::DeviceConfig& cfg); // True once Wi-Fi has an IP address. Becomes false again on disconnect. bool wifi_is_connected(); +// Current STA IPv4 address as a dotted-decimal string, or empty when no lease +// has been assigned yet. Used by first-run status and connection reporting. +std::string wifi_ip_address(); + +// --- First-run setup helpers ------------------------------------------------ +// These are called from the first-run worker, never directly from the HTTP +// task. While setup mode is active automatic STA reconnect is suppressed so a +// failed password test cannot disrupt the SoftAP page. The station remains in +// the successful candidate network after connect_temporary(), allowing the +// caller to tear down the AP without dropping the association. +struct WifiScanResult { + std::string ssid; + std::int8_t rssi = 0; + bool secure = true; +}; + +std::vector wifi_scan(); + +// Apply DHCP/static IPv4 settings, disconnect any previous attempt, configure +// and join the candidate network. Blocks for up to 20 seconds waiting for an +// IPv4 address. `static_ip` empty selects DHCP; otherwise a /24 prefix and a +// gateway are required. `dns` may be empty, in which case the gateway is used. +bool wifi_connect_temporary(const std::string& ssid, const std::string& password, + const std::string& static_ip, const std::string& gateway, + const std::string& dns); + +// Enter/leave first-run setup mode. Entering cancels pending normal reconnect +// timers; leaving re-enables normal reconnect behaviour. +void wifi_set_setup_mode(bool active); +bool wifi_setup_mode(); + // --- SoftAP provisioning --- // Switch Wi-Fi into APSTA so iOS (no Web Bluetooth) can join the device's own // AP and use the existing wifi_config HTTP settings page to configure home @@ -33,9 +67,12 @@ bool wifi_is_connected(); // - SSID = "Stackchan-XXXXXX" (last 3 STA MAC bytes — same as BLE / mDNS). // - WPA2 PSK = "sc-XXXXXXXX" (last 4 STA MAC bytes, lower hex). // Both idempotent. -void wifi_enable_ap_mode(); -void wifi_disable_ap_mode(); +void wifi_enable_ap_mode(bool first_run = false); +// Returns false when the driver could not leave APSTA mode. The AP remains up +// in that case so the caller can retry instead of reporting a false success. +bool wifi_disable_ap_mode(); bool wifi_ap_active(); +bool wifi_ap_first_run(); // AP join info for the on-device QR screen. Returns false if the AP isn't up // or the buffers are too small. diff --git a/tools/settings_i18n/catalog.json b/tools/settings_i18n/catalog.json index c45125b..ff1b517 100644 --- a/tools/settings_i18n/catalog.json +++ b/tools/settings_i18n/catalog.json @@ -1978,6 +1978,13 @@ "閉": "closed", "開": "Open", "頭": "head", - "風": "wind" + "風": "wind", + "静的 IP 設定": "Static IP", + "静的 IP アドレス": "Static IP address", + "ゲートウェイ": "Gateway", + "DNS (任意)": "DNS (optional)", + "(空欄 = DHCP)": " (blank = DHCP)", + "空欄 = ゲートウェイ": "Blank = gateway", + "空欄なら DHCP を使用します。静的 IP は /24 サブネットで設定され、ゲートウェイは必須です。\n DNS が空欄の場合はゲートウェイを使用します。保存後の再起動で反映されます。\n ": "Leave blank to use DHCP. Static IP uses a /24 subnet and requires a gateway. If DNS is blank, the gateway is used. Takes effect after saving and rebooting.\n " } } From 52d7c2c8b6d0a48871e447540afca96a411d580e Mon Sep 17 00:00:00 2001 From: Dylan Luo Date: Fri, 23 Oct 2026 09:42:55 +0800 Subject: [PATCH 2/3] fix: polish first-run setup UI --- components/config_service/config_service.cpp | 3 +- components/config_service/dis.cpp | 18 +- .../include/config_service/config_service.hpp | 3 +- .../include/config_service/product.hpp | 7 + .../wifi_config_service.cpp | 4 +- main/ap_screen.cpp | 308 ++++++++++++------ main/ap_screen.hpp | 9 +- main/conversation_task.cpp | 16 +- main/first_run.cpp | 72 +++- main/first_run.hpp | 5 + main/screens.cpp | 4 +- main/web/first_run_setup.html | 32 +- main/wifi_sta.cpp | 4 +- main/wifi_sta.hpp | 3 +- tools/settings_i18n/translate.py | 8 +- 15 files changed, 342 insertions(+), 154 deletions(-) diff --git a/components/config_service/config_service.cpp b/components/config_service/config_service.cpp index 4ae9653..8eac1ed 100644 --- a/components/config_service/config_service.cpp +++ b/components/config_service/config_service.cpp @@ -375,7 +375,8 @@ static void on_sync() uint8_t mac[6] = {}; esp_read_mac(mac, ESP_MAC_WIFI_STA); std::snprintf(g_device_name, sizeof(g_device_name), - "Stackchan-%02X%02X%02X", mac[3], mac[4], mac[5]); + "%s-%02X%02X%02X", device_name_prefix(), + mac[3], mac[4], mac[5]); } ble_svc_gap_device_name_set(g_device_name); diff --git a/components/config_service/dis.cpp b/components/config_service/dis.cpp index bebceb2..beaf663 100644 --- a/components/config_service/dis.cpp +++ b/components/config_service/dis.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -19,13 +20,18 @@ namespace { constexpr const char* kTag = "cfg-dis"; -// Compile-time identity. Manufacturer / Model / Hardware are fixed for this -// firmware family; Firmware Revision and Software Revision are filled at -// init() time from the running app's description. +// Manufacturer / Hardware are fixed for this firmware family; Firmware +// Revision and Software Revision are filled at init() time from the running +// app's description. The model follows the Gemini customer product name +// without changing the upstream-compatible profile. constexpr const char* kManufacturer = "Kenta IDA"; -constexpr const char* kModelNumber = "Stack-chan CoreS3"; constexpr const char* kHardwareRev = "CoreS3 + SCS0009"; +const char* model_number() +{ + return gemini_only() ? "StackChan CoreS3" : "Stack-chan CoreS3"; +} + // 0x180A — Device Information Service (Bluetooth SIG-assigned). static const ble_uuid16_t kDisUuid = BLE_UUID16_INIT(0x180A); static const ble_uuid16_t kMfrUuid = BLE_UUID16_INIT(0x2A29); // Manufacturer Name @@ -52,7 +58,7 @@ static int access_cb(uint16_t /*conn_handle*/, uint16_t /*attr_handle*/, } const ble_uuid_t* uuid = ctxt->chr->uuid; if (ble_uuid_cmp(uuid, &kMfrUuid.u) == 0) return append(ctxt->om, kManufacturer); - if (ble_uuid_cmp(uuid, &kModelUuid.u) == 0) return append(ctxt->om, kModelNumber); + if (ble_uuid_cmp(uuid, &kModelUuid.u) == 0) return append(ctxt->om, model_number()); if (ble_uuid_cmp(uuid, &kHwRevUuid.u) == 0) return append(ctxt->om, kHardwareRev); if (ble_uuid_cmp(uuid, &kFwRevUuid.u) == 0) return append(ctxt->om, g_fw_rev); if (ble_uuid_cmp(uuid, &kSwRevUuid.u) == 0) return append(ctxt->om, g_sw_rev); @@ -110,7 +116,7 @@ void init() std::snprintf(g_sw_rev, sizeof(g_sw_rev), "unknown"); } ESP_LOGI(kTag, "DIS: %s / %s / %s / fw=%s / sw=%s", - kManufacturer, kModelNumber, kHardwareRev, g_fw_rev, g_sw_rev); + kManufacturer, model_number(), kHardwareRev, g_fw_rev, g_sw_rev); int rc = ble_gatts_count_cfg(kSvcs); if (rc != 0) { diff --git a/components/config_service/include/config_service/config_service.hpp b/components/config_service/include/config_service/config_service.hpp index b08fe6d..025bb82 100644 --- a/components/config_service/include/config_service/config_service.hpp +++ b/components/config_service/include/config_service/config_service.hpp @@ -325,7 +325,8 @@ struct DeviceConfig { // Operator-configurable device name. Becomes the BLE advertising name AND // the source for the mDNS hostname (after RFC-1123 sanitization: lowercase // alphanumerics + hyphen). Empty → fall back to the autogenerated - // "Stackchan-XXXXXX" derived from the Wi-Fi STA MAC lower 3 bytes. Takes + // "Stackchan-XXXXXX" derived from the Wi-Fi STA MAC lower 3 bytes + // ("StackChan-XXXXXX" in Gemini builds). Takes // effect on the next boot. Capped at 24 bytes plaintext so the full // advertising name + Stack-chan- prefix on hostname fits the BLE scan // response (31 byte adv frame budget). diff --git a/components/config_service/include/config_service/product.hpp b/components/config_service/include/config_service/product.hpp index 210a650..f9d339b 100644 --- a/components/config_service/include/config_service/product.hpp +++ b/components/config_service/include/config_service/product.hpp @@ -25,6 +25,13 @@ inline constexpr bool gemini_only() #endif } +// Customer-visible default device / SoftAP name prefix. Keep the upstream +// spelling in non-Gemini builds so shared components remain sync-friendly. +inline const char* device_name_prefix() +{ + return gemini_only() ? "StackChan" : "Stackchan"; +} + inline Provider effective_provider(const DeviceConfig& cfg) { return gemini_only() ? Provider::Gemini : cfg.provider; diff --git a/components/wifi_config_service/wifi_config_service.cpp b/components/wifi_config_service/wifi_config_service.cpp index d05f92c..8204ad7 100644 --- a/components/wifi_config_service/wifi_config_service.cpp +++ b/components/wifi_config_service/wifi_config_service.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -83,7 +84,8 @@ tl::expected start_mdns() } mdns_hostname_set(g_hostname); - mdns_instance_name_set("Stack-chan config"); + mdns_instance_name_set(config::gemini_only() ? "StackChan config" + : "Stack-chan config"); // Plain HTTP on 80. TLS turned out too heavy on internal RAM (each // active TLS session was contending with the conversation task's diff --git a/main/ap_screen.cpp b/main/ap_screen.cpp index 0ffb2a7..7742f56 100644 --- a/main/ap_screen.cpp +++ b/main/ap_screen.cpp @@ -4,10 +4,15 @@ #include "ap_screen.hpp" #include +#include +#include +#include #include #include #include +#include +#include "first_run.hpp" #include "wifi_sta.hpp" namespace stackchan::app::ap_screen { @@ -24,7 +29,7 @@ char g_last_ip[16] = {0}; bool g_last_first_run = false; bool g_painted = false; -// Exit-button rect in panel coords (set by the most recent draw call). +// Action-button rect in panel coords (set by the most recent draw call). // handle_tap hit-tests against these. Boards without LCD touch (AtomS3R / // AtomS3 — the "tiny" branch) leave btn_w = 0; handle_tap then never matches // and still consumes the tap to keep the touchless gesture path clean. @@ -35,11 +40,12 @@ int g_btn_h = 0; // Build the standard Wi-Fi QR payload. Format documented by the Wi-Fi // Alliance and supported natively by iOS Camera / Android scanners: -// WIFI:T:;S:;P:;H:;; +// WIFI:T:;S:;P:;; // Special chars `\`, `;`, `,`, `"`, `:` must be escaped with `\` inside SSID // and password — our derived SSID/PW use only [A-Za-z0-9-], so no escaping // is actually needed; we still apply it for safety should the credential -// derivation ever change. +// derivation ever change. `H:false` is omitted because hidden is already the +// default; shortening the payload keeps it at QR version 4 / 33 modules. std::size_t append_escaped(char* dst, std::size_t cap, std::size_t pos, const char* src) { for (; *src != '\0' && pos + 2 < cap; ++src) { @@ -58,7 +64,7 @@ void build_wifi_qr_payload(const char* ssid, const char* pw, char* out, std::siz std::size_t p = 0; constexpr const char* kPrefix = "WIFI:T:WPA;S:"; constexpr const char* kMid = ";P:"; - constexpr const char* kSuffix = ";H:false;;"; + constexpr const char* kSuffix = ";;"; p += std::snprintf(out + p, cap - p, "%s", kPrefix); p = append_escaped(out, cap, p, ssid); p += std::snprintf(out + p, cap - p, "%s", kMid); @@ -66,6 +72,81 @@ void build_wifi_qr_payload(const char* ssid, const char* pw, char* out, std::siz std::snprintf(out + p, cap - p, "%s", kSuffix); } +// CoreS3's generated SSID is "StackChan-XXXXXX" in Gemini builds. Splitting at the hyphen +// makes the credential readable in the narrow column beside the QR without +// reducing the QR size or using an unreadably small font. +void draw_ssid_lines(M5GFX& display, const char* ssid, int x, int y, int line_h, int width) +{ + const char* split = std::strrchr(ssid, '-'); + if (split != nullptr && split != ssid && display.textWidth(ssid) > width) { + char first[33]; + const std::size_t first_len = static_cast(split - ssid) + 1; + if (first_len < sizeof(first)) { + std::memcpy(first, ssid, first_len); + first[first_len] = '\0'; + display.drawString(first, x, y); + display.drawString(split + 1, x, y + line_h); + return; + } + } + display.drawString(ssid, x, y); +} + +// M5GFX's qrcode() helper hard-codes ECC low. Render the same QR utility +// directly at quartile ECC so the LCD code has more tolerance for glare, +// moire, and phone-camera noise. The Wi-Fi payload fits version 4 (33 +// modules), preserving the 5 px module size in the CoreS3 layout. +bool draw_wifi_qrcode(M5GFX& display, const char* payload, int x, int y, int box) +{ + std::array modules{}; + constexpr std::uint8_t kMaxVersion = 10; // 512 B covers this payload range. + + for (std::uint8_t version = 1; version <= kMaxVersion; ++version) { + QRCode qrcode{}; + if (lgfx_qrcode_initText(&qrcode, modules.data(), version, + ECC_QUARTILE, payload) != 0) { + continue; + } + + const int thickness = box / qrcode.size; + if (thickness <= 0) return false; + const int code_size = qrcode.size * thickness; + const int offset = (box - code_size) / 2; + + display.fillRect(x, y, box, box, TFT_WHITE); + for (int iy = 0; iy < qrcode.size; ++iy) { + for (int ix = 0; ix < qrcode.size; ++ix) { + const auto colour = lgfx_qrcode_getModule(&qrcode, ix, iy) + ? TFT_BLACK : TFT_WHITE; + display.fillRect(x + offset + ix * thickness, + y + offset + iy * thickness, + thickness, thickness, colour); + } + } + return true; + } + return false; +} + +void draw_action_button(M5GFX& display, bool first_run) +{ + if (g_btn_w <= 0 || g_btn_h <= 0) return; + + const std::uint16_t red = display.color565(200, 90, 60); + const std::uint16_t grey = display.color565(224, 230, 236); + const std::uint16_t ink = display.color565(20, 35, 52); + const std::uint16_t background = first_run ? grey : red; + const std::uint16_t foreground = first_run ? ink : TFT_WHITE; + + display.fillRoundRect(g_btn_x, g_btn_y, g_btn_w, g_btn_h, 6, background); + display.setFont(&fonts::lgfxJapanGothic_20); + display.setTextColor(foreground, background); + display.setTextDatum(lgfx::textdatum_t::middle_center); + display.drawString(first_run ? config::tr("Skip", "スキップ") + : config::tr("Exit AP", "AP 停止"), + g_btn_x + g_btn_w / 2, g_btn_y + g_btn_h / 2); +} + } // namespace bool active() @@ -102,58 +183,19 @@ bool draw(M5GFX& display) const std::int32_t H = display.height(); // Layout breakpoints: - // - tiny (≤160 px): 128×128 atom panels — no touch, so no exit button; - // atom_status's 5 s long-press gesture dismisses AP. - // - mid: CoreS3 320×240 — QR centred with title above, three - // text lines below, exit-button band at the bottom. - // - round (≥400): StopWatch 466×466 AMOLED — content kept inside the - // inscribed square (~0.707 R) horizontally but allowed - // closer to the panel edge along the vertical centre - // axis (where the visible circle reaches almost full - // height). - const bool tiny = W <= 160; + // - tiny (≤160 px): 128×128 atom panels — no touch action; the Atom + // status overlay owns the physical-button gestures. + // - mid: CoreS3 320×240 — large QR on the left, enlarged + // credentials and action button in the right column. + // - round (≥400): StopWatch 466×466 AMOLED — centred QR with larger + // text and a bottom action button. + const bool tiny = W <= 160; const bool round_ = W >= 400; - const int inset = round_ - ? static_cast(W * (1.0f - 0.70710678f) * 0.5f) + 4 - : 4; - - // Bottom exit-button band for the existing settings AP. First-run setup - // deliberately has no exit action: closing that AP would strand setup. - // Height ~ 1/6 of panel height on touch boards. - // On round panels the band can extend slightly below the inscribed - // square because the button is centred horizontally — the visible - // circle is widest along the vertical axis, narrowest at the corners. - const int btn_h = (tiny || first_run) ? 0 - : round_ ? 40 - : 30; - const int btn_band_y = tiny ? H : (H - btn_h - (round_ ? 18 : 4)); - const int btn_w = (tiny || first_run) ? 0 - : round_ ? 200 - : (W - 24); - const int btn_x = (W - btn_w) / 2; - - // QR + caption block fills the area above the button band. First-run - // adds the phone CTA and URL while removing the exit button; the normal - // settings AP keeps its three credential lines plus the exit action. - const int caption_lines = tiny ? 2 : (first_run ? 4 : 3); - const int caption_line_h = round_ ? 22 : (tiny ? 10 : 14); - const int caption_h = caption_lines * caption_line_h + 4; - const int title_h = tiny ? 0 : (round_ ? 32 : 20); - const int top_margin = tiny ? 8 : (round_ ? inset : 4); - - const int avail_y_for_qr = btn_band_y - top_margin - title_h - caption_h; - const int qr_max_by_w = tiny ? (W - 28) - : round_ ? (W * 220 / 466) - : 150; - const int qr_w = (avail_y_for_qr < qr_max_by_w) ? avail_y_for_qr : qr_max_by_w; - const int qr_x = (W - qr_w) / 2; - const int qr_y = top_margin + title_h; - - g_btn_x = btn_x; - g_btn_y = btn_band_y; - g_btn_w = btn_w; - g_btn_h = btn_h; + g_btn_x = 0; + g_btn_y = 0; + g_btn_w = 0; + g_btn_h = 0; char qr_payload[160]; build_wifi_qr_payload(ssid, pw, qr_payload, sizeof(qr_payload)); @@ -161,25 +203,61 @@ bool draw(M5GFX& display) display.startWrite(); display.fillScreen(TFT_WHITE); - // Title — skipped on the tiny panel (no space). - if (!tiny) { - display.setFont(round_ ? &fonts::lgfxJapanGothic_24 - : &fonts::lgfxJapanGothic_16); + if (tiny) { + // 128×128: the largest code that still leaves room for SSID/PW. + const int qr_w = W - 28; + const int qr_x = (W - qr_w) / 2; + const int qr_y = 8; + // Version 1 lets M5GFX auto-increment to the smallest version that + // fits. Version 0 never attempts an encode (the loop starts there). + if (!draw_wifi_qrcode(display, qr_payload, qr_x, qr_y, qr_w)) { + display.qrcode(qr_payload, qr_x, qr_y, qr_w, 1, false); + } + + display.setFont(&fonts::Font0); display.setTextColor(TFT_BLACK, TFT_WHITE); display.setTextDatum(lgfx::textdatum_t::top_center); - display.drawString(config::tr("Wi-Fi setup", "Wi-Fi 設定モード"), W / 2, top_margin); - } + char buf[48]; + std::snprintf(buf, sizeof(buf), "SSID %s", ssid); + display.drawString(buf, W / 2, qr_y + qr_w + 2); + std::snprintf(buf, sizeof(buf), "PW %s", pw); + display.drawString(buf, W / 2, qr_y + qr_w + 12); + } else if (round_) { + const int inset = static_cast(W * (1.0f - 0.70710678f) * 0.5f) + 4; + const int title_h = 32; + const int top_margin = inset; + const int btn_h = 44; + const int btn_band_y = H - btn_h - 18; + const int btn_w = 200; + const int caption_lines = 3; + const int caption_line_h = 22; + const int caption_h = caption_lines * caption_line_h + 4; + + const int avail_y_for_qr = + btn_band_y - top_margin - title_h - caption_h; + const int qr_max_by_w = W * 220 / 466; + const int qr_w = (avail_y_for_qr < qr_max_by_w) ? avail_y_for_qr : qr_max_by_w; + const int qr_x = (W - qr_w) / 2; + const int qr_y = top_margin + title_h; - // QR. Version 0 = auto-pick by payload length. The payload is ≤ 80 - // chars in practice, well within an auto-sized QR at the default ECC. - display.qrcode(qr_payload, qr_x, qr_y, qr_w, 0); + g_btn_x = (W - btn_w) / 2; + g_btn_y = btn_band_y; + g_btn_w = btn_w; + g_btn_h = btn_h; - // Caption block between the QR and the exit button. - if (!tiny) { - display.setFont(round_ ? &fonts::lgfxJapanGothic_20 - : &fonts::lgfxJapanGothic_12); + display.setFont(&fonts::lgfxJapanGothic_24); display.setTextColor(TFT_BLACK, TFT_WHITE); display.setTextDatum(lgfx::textdatum_t::top_center); + display.drawString(config::tr("Wi-Fi setup", "Wi-Fi 設定モード"), W / 2, top_margin); + + // Explicit white space around the code plus stopWatch's white panel + // gives the renderer a full quiet zone; no M5GFX `margin=true` path. + if (!draw_wifi_qrcode(display, qr_payload, qr_x, qr_y, qr_w)) { + display.qrcode(qr_payload, qr_x, qr_y, qr_w, 1, false); + } + + display.setFont(&fonts::lgfxJapanGothic_20); + display.setTextDatum(lgfx::textdatum_t::top_center); int ty = qr_y + qr_w + 4; char buf[64]; std::snprintf(buf, sizeof(buf), "SSID: %s", ssid); @@ -188,38 +266,65 @@ bool draw(M5GFX& display) std::snprintf(buf, sizeof(buf), "PW: %s", pw); display.drawString(buf, W / 2, ty); ty += caption_line_h; - if (first_run) { - display.drawString(config::tr("Continue on your phone.", "スマートフォンで続行してください。"), - W / 2, ty); - ty += caption_line_h; - } std::snprintf(buf, sizeof(buf), "http://%s/", ip); display.drawString(buf, W / 2, ty); + + draw_action_button(display, first_run); } else { - // 128×128: SSID + password below the QR. The captive portal carries - // the setup URL after joining, so it is omitted here for legibility. - display.setFont(&fonts::Font0); + // CoreS3. A 194 px box yields 5 px modules for the 33-module + // version-4 payload: a 165 px printed code with 14–15 px of white + // space inside the box. The surrounding white panel and 12 px gap to + // the right column keep the required quiet zone intact. + constexpr int kOuterMargin = 8; + constexpr int kTitleY = 2; + constexpr int kTitleH = 34; + constexpr int kQrBox = 194; + constexpr int kColumnGap = 12; + constexpr int kButtonH = 44; + constexpr int kButtonBottom = 12; + + const int qr_x = kOuterMargin; + const int qr_y = kTitleY + kTitleH; + const int column_x = qr_x + kQrBox + kColumnGap; + const int column_w = W - column_x - kOuterMargin; + const int button_y = H - kButtonH - kButtonBottom; + + g_btn_x = column_x; + g_btn_y = button_y; + g_btn_w = column_w; + g_btn_h = kButtonH; + + display.setFont(&fonts::lgfxJapanGothic_24); display.setTextColor(TFT_BLACK, TFT_WHITE); display.setTextDatum(lgfx::textdatum_t::top_center); - char buf[48]; - std::snprintf(buf, sizeof(buf), "SSID %s", ssid); - display.drawString(buf, W / 2, qr_y + qr_w + 2); - std::snprintf(buf, sizeof(buf), "PW %s", pw); - display.drawString(buf, W / 2, qr_y + qr_w + 2 + caption_line_h); - } + display.drawString(config::tr("Wi-Fi setup", "Wi-Fi 設定モード"), W / 2, kTitleY); + + // Quartile ECC at version 4 keeps 5 px modules. The surrounding white + // panel provides the quiet zone, and the title/text are spaced beyond + // four module widths from the printed code. + if (!draw_wifi_qrcode(display, qr_payload, qr_x, qr_y, kQrBox)) { + display.qrcode(qr_payload, qr_x, qr_y, kQrBox, 1, false); + } - // Exit button (touch boards only). Red so it reads as "stop/cancel" - // rather than a neutral CTA — same colour treatment as device_ui's - // "AP モード(停止)" button when AP is up. - if (btn_w > 0 && btn_h > 0) { - const std::uint16_t red = display.color565(200, 90, 60); - display.fillRoundRect(btn_x, btn_band_y, btn_w, btn_h, 6, red); - display.setFont(round_ ? &fonts::lgfxJapanGothic_20 - : &fonts::lgfxJapanGothic_16); - display.setTextColor(TFT_WHITE, red); - display.setTextDatum(lgfx::textdatum_t::middle_center); - display.drawString(config::tr("Exit (stop AP)", "終了 (AP モード停止)"), - btn_x + btn_w / 2, btn_band_y + btn_h / 2); + display.setFont(&fonts::lgfxJapanGothic_16); + display.setTextColor(TFT_BLACK, TFT_WHITE); + display.setTextDatum(lgfx::textdatum_t::top_left); + + int ty = qr_y; + display.drawString("SSID", column_x, ty); + ty += 19; + draw_ssid_lines(display, ssid, column_x, ty, 18, column_w); + ty += 47; + + display.drawString("Password", column_x, ty); + ty += 19; + display.drawString(pw, column_x, ty); + + ty = button_y - 36; + display.drawString(first_run ? "Scan with" : "Open setup", column_x, ty); + display.drawString(first_run ? "camera" : "in browser", column_x, ty + 17); + + draw_action_button(display, first_run); } display.endWrite(); @@ -231,19 +336,20 @@ bool handle_tap(int x, int y) { if (!wifi_ap_active()) return false; // While AP is up we own the touch input — swallow every tap even if it - // missed the exit button. Otherwise the tap would propagate to the + // missed the action button. Otherwise the tap would propagate to the // hidden device_ui underneath (whose top-right corner opens the 5-tab // settings UI), which would surprise the user when AP is later // dismissed and the UI reappears already open. - if (!wifi_ap_first_run() && - g_btn_w > 0 && g_btn_h > 0 && + if (g_btn_w > 0 && g_btn_h > 0 && x >= g_btn_x && x < g_btn_x + g_btn_w && y >= g_btn_y && y < g_btn_y + g_btn_h) { - wifi_disable_ap_mode(); - // Mark the cached snapshot stale so the next render task tick that - // sees ap_screen::active() == false → repaints the avatar; nothing - // else to do here. - g_painted = false; + if (wifi_ap_first_run()) { + if (first_run::skip_setup()) { + g_painted = false; + } + } else if (wifi_disable_ap_mode()) { + g_painted = false; + } } return true; } diff --git a/main/ap_screen.hpp b/main/ap_screen.hpp index be6936b..a7a41fe 100644 --- a/main/ap_screen.hpp +++ b/main/ap_screen.hpp @@ -6,7 +6,7 @@ #include // Full-screen Wi-Fi join QR screen shown while SoftAP provisioning mode is -// active. Renders a QR encoding `WIFI:T:WPA;S:;P:;H:false;;` (iOS +// active. Renders a QR encoding `WIFI:T:WPA;S:;P:;;` (iOS // Camera and modern Android decode this and offer one-tap join), plus the // SSID / PW / URL in human-readable text below it. Takes over the panel // outright — no avatar / battery / atom_status compositing — and yields the @@ -21,9 +21,10 @@ bool active(); bool draw(M5GFX& display); // Touch handling. Returns true when AP screen owns the tap (= AP is up). -// Inside, taps on the on-screen "終了" button disable AP mode; taps that -// missed the button are still swallowed so they don't leak through to the -// device_ui underneath. +// Inside, taps on the on-screen action button either skip first-run setup +// (stopping the SoftAP and returning to the avatar) or disable the normal +// settings AP. Taps that missed the button are still swallowed so they don't +// leak through to the device_ui underneath. bool handle_tap(int x, int y); } // namespace stackchan::app::ap_screen diff --git a/main/conversation_task.cpp b/main/conversation_task.cpp index 48a4310..a14904e 100644 --- a/main/conversation_task.cpp +++ b/main/conversation_task.cpp @@ -103,11 +103,17 @@ const char* kInstructionsJa = "ものまね・効果音・繰り返しなど演出的に使ってください(ツール呼び出しのターンでは普通の声で続けて喋らなくて構いません)。"; const char* kInstructionsEn = - "You are Stack-chan, a small desktop robot running on M5Stack. Be friendly, " - "cheerful, and a little childlike. Keep replies short. Speak English with the " - "user (switch only if they clearly ask for another language). You can change " - "your facial expression and head pose; use set_expression and set_head_pose " - "to match the mood."; + config::gemini_only() + ? "You are StackChan, a small desktop robot running on M5Stack. Be friendly, " + "cheerful, and a little childlike. Keep replies short. Speak English with the " + "user (switch only if they clearly ask for another language). You can change " + "your facial expression and head pose; use set_expression and set_head_pose " + "to match the mood." + : "You are Stack-chan, a small desktop robot running on M5Stack. Be friendly, " + "cheerful, and a little childlike. Keep replies short. Speak English with the " + "user (switch only if they clearly ask for another language). You can change " + "your facial expression and head pose; use set_expression and set_head_pose " + "to match the mood."; const char* kInstructions() { diff --git a/main/first_run.cpp b/main/first_run.cpp index da8d761..3ab178a 100644 --- a/main/first_run.cpp +++ b/main/first_run.cpp @@ -316,12 +316,38 @@ void notify_resolved_once() if (callback) callback(); } -void begin_skip() +bool begin_skip() { - if (!g_enabled.load()) return; + if (!g_enabled.load()) return false; + + bool setup_started; + { + LockGuard lock{g_lock}; + setup_started = g_state.setup_started; + } + if (setup_started || wifi_ap_active()) { + if (wifi_ap_active() && !wifi_disable_ap_mode()) { + ESP_LOGE(kTag, "could not stop the first-run SoftAP; staying on setup"); + return false; + } + // wifi_disable_ap_mode() preserves first-run setup mode on purpose for + // the finish/reboot path. A Skip returns to the normal avatar instead. + wifi_config::set_first_run_mode(false); + wifi_set_setup_mode(false); + } + { + LockGuard lock{g_lock}; + g_state.setup_started = false; + g_state.finish_pending = false; + g_state.wifi_state = "idle"; + g_state.gemini_state = "idle"; + g_state.scan_state = "idle"; + } g_ui_prompt.store(false); g_ui_success.store(false); notify_resolved_once(); + ESP_LOGI(kTag, "setup skipped; returning to avatar"); + return true; } void begin_setup() @@ -586,7 +612,7 @@ void gemini_test_task(void* arg) config.model = job->model; config.voice = job->voice; config.instructions = job->prompt.empty() - ? "You are Stack-chan. Reply briefly. Do not call tools during this setup test." + ? "You are StackChan. Reply briefly. Do not call tools during this setup test." : job->prompt; config.input_sample_rate_hz = 16000; config.output_sample_rate_hz = 24000; @@ -925,12 +951,12 @@ void draw_prompt(avatar::RichCanvas& canvas) canvas.setTextColor(ink, paper); canvas.setTextDatum(lgfx::textdatum_t::top_left); canvas.setFont(&fonts::Font0); - if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_16); - canvas.drawString("Stack-chan setup", compact ? 6 : 18, compact ? 8 : 18); + if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_24); + canvas.drawString("StackChan setup", compact ? 6 : 18, compact ? 8 : 14); canvas.setTextColor(muted, paper); canvas.setFont(&fonts::Font0); - if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_12); + if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_16); bool missing_wifi; bool missing_key; { @@ -945,17 +971,21 @@ void draw_prompt(avatar::RichCanvas& canvas) 6, 32); canvas.drawString("A: setup hold A: skip", 6, h - 44); } else { - const char* line1 = missing_wifi && missing_key - ? "Wi-Fi and Gemini API key are not configured." - : missing_wifi ? "Wi-Fi is not configured." - : "Gemini API key is not configured."; - canvas.drawString(line1, 18, 66); - canvas.drawString("Start setup opens a guided phone setup page.", 18, 88); + if (missing_wifi && missing_key) { + canvas.drawString("Wi-Fi and Gemini API key", 18, 64); + canvas.drawString("are not configured.", 18, 84); + } else { + canvas.drawString(missing_wifi ? "Wi-Fi is not configured." + : "Gemini API key is not configured.", + 18, 64); + } + canvas.drawString("Start setup opens a guided", 18, 112); + canvas.drawString("phone setup page.", 18, 132); } const std::int32_t margin = compact ? 6 : 14; const std::int32_t gap = compact ? 4 : 10; - const std::int32_t button_h = compact ? 24 : 38; + const std::int32_t button_h = compact ? 24 : 44; const std::int32_t button_y = h - button_h - (compact ? 7 : 14); const std::int32_t total_w = w - margin * 2; const std::int32_t start_w = (total_w - gap) * 3 / 5; @@ -973,11 +1003,12 @@ void draw_prompt(avatar::RichCanvas& canvas) canvas.setTextColor(ink, grey); canvas.setTextDatum(lgfx::textdatum_t::middle_center); canvas.setFont(&fonts::Font0); - if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_12); + if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_20); canvas.drawString("Skip", g_skip_x + g_skip_w / 2, g_skip_y + g_skip_h / 2); canvas.fillRoundRect(g_start_x, g_start_y, g_start_w, g_start_h, 6, blue); canvas.setTextColor(TFT_WHITE, blue); + if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_20); canvas.drawString("Start setup", g_start_x + g_start_w / 2, g_start_y + g_start_h / 2); canvas.setTextDatum(lgfx::textdatum_t::top_left); @@ -994,11 +1025,11 @@ void draw_success(avatar::RichCanvas& canvas) canvas.setTextColor(TFT_WHITE, blue); canvas.setTextDatum(lgfx::textdatum_t::middle_center); canvas.setFont(&fonts::Font0); - if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_16); - canvas.drawString("Setup complete", w / 2, h / 2 - 12); + if (!compact) canvas.setFont(&fonts::lgfxJapanGothic_24); + canvas.drawString("Setup complete", w / 2, h / 2 - 14); if (!compact) { - canvas.setFont(&fonts::lgfxJapanGothic_12); - canvas.drawString("Restarting...", w / 2, h / 2 + 16); + canvas.setFont(&fonts::lgfxJapanGothic_16); + canvas.drawString("Restarting...", w / 2, h / 2 + 18); } canvas.setTextDatum(lgfx::textdatum_t::top_left); } @@ -1101,6 +1132,11 @@ bool handle_tap(int x, int y) return true; // prompt owns the panel and must not leak taps underneath } +bool skip_setup() +{ + return begin_skip(); +} + void poll_button() { if (!g_button_only) return; diff --git a/main/first_run.hpp b/main/first_run.hpp index 61822de..71a56eb 100644 --- a/main/first_run.hpp +++ b/main/first_run.hpp @@ -28,6 +28,11 @@ bool draw(avatar::RichCanvas& canvas); bool handle_tap(int x, int y); void poll_button(); +// Leave setup and return to the normal avatar UI. If the first-run SoftAP is +// active this tears it down first and only reports success after the AP has +// stopped. Safe to call from the prompt or the on-device QR page. +bool skip_setup(); + // True while the first-run prompt or setup flow owns boot. app_main uses this // to suppress conversation startup until Skip. Completion reboots. bool active(); diff --git a/main/screens.cpp b/main/screens.cpp index 96df1ca..6c187d1 100644 --- a/main/screens.cpp +++ b/main/screens.cpp @@ -18,8 +18,8 @@ namespace { // --- Adapters over the existing overlay modules --------------------------- // SoftAP provisioning QR screen. Draws straight to the panel (M5GFX::qrcode) -// and swallows every tap while AP mode is up (the on-screen 終了 button is -// the dismiss path on touch boards). +// and swallows every tap while AP mode is up. The on-screen action is Skip +// during first-run setup and Exit AP for the normal settings SoftAP. class ApScreen final : public Screen { public: explicit ApScreen(M5GFX& display) : display_{display} {} diff --git a/main/web/first_run_setup.html b/main/web/first_run_setup.html index d21f6e7..bde65c9 100644 --- a/main/web/first_run_setup.html +++ b/main/web/first_run_setup.html @@ -5,7 +5,7 @@ -Stack-chan setup +StackChan setup