Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion components/config_service/config_service.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
18 changes: 12 additions & 6 deletions components/config_service/dis.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <cstdio>
#include <cstring>

#include <config_service/product.hpp>
#include <esp_app_desc.h>
#include <esp_log.h>

Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ static_assert(static_cast<int>(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
Expand Down Expand Up @@ -318,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).
Expand Down
7 changes: 7 additions & 0 deletions components/config_service/include/config_service/product.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions components/config_service/settings_registry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const std::array<SettingDescriptor, kSettingCount> 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),
Expand Down
23 changes: 23 additions & 0 deletions components/wifi_config_service/http_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ std::atomic<bool> 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<bool> 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<bool> 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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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") + ",";
Expand Down Expand Up @@ -2264,6 +2273,14 @@ 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) {
ESP_LOGI(kTag, "GET %s -> /setup (first-run portal)", req->uri);
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;
Expand Down Expand Up @@ -2426,6 +2443,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;
Expand Down
3 changes: 3 additions & 0 deletions components/wifi_config_service/http_handlers.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
37 changes: 36 additions & 1 deletion components/wifi_config_service/web/settings_wifi.html
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,27 @@ <h2 class="section-title" style="margin-top:0.5rem">動作モード</h2>
<label for="password">Wi-Fi パスワード</label>
<input type="password" id="password" maxlength="64" placeholder="(変更しない場合は空欄)">

<h2 class="section-title" style="margin-top:0.5rem">静的 IP 設定</h2>
<p class="hint" style="margin-bottom:0.5rem">
空欄なら DHCP を使用します。静的 IP は /24 サブネットで設定され、ゲートウェイは必須です。
DNS が空欄の場合はゲートウェイを使用します。保存後の再起動で反映されます。
</p>
<label for="wifi-static-ip">静的 IP アドレス</label>
<input type="text" id="wifi-static-ip" maxlength="15" inputmode="decimal"
autocomplete="off" placeholder="192.168.1.50(空欄 = DHCP)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.7rem;margin-top:0.7rem">
<div>
<label for="wifi-gateway">ゲートウェイ</label>
<input type="text" id="wifi-gateway" maxlength="15" inputmode="decimal"
autocomplete="off" placeholder="192.168.1.1">
</div>
<div>
<label for="wifi-dns">DNS (任意)</label>
<input type="text" id="wifi-dns" maxlength="15" inputmode="decimal"
autocomplete="off" placeholder="空欄 = ゲートウェイ">
</div>
</div>

<h2 class="section-title" style="margin-top:0.5rem">デバイス名 (BLE / mDNS)</h2>
<p class="hint" style="margin-bottom:0.5rem">
BLE アドバタイズ名と mDNS ホスト名のもとになります。空欄なら自動生成 (Stackchan-XXXXXX)。
Expand Down Expand Up @@ -1641,6 +1662,9 @@ <h3 class="section-title" style="margin-top:1rem; font-size:0.95rem">
+ (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;
Expand Down Expand Up @@ -1769,6 +1793,15 @@ <h3 class="section-title" style="margin-top:1rem; font-size:0.95rem">

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) {
Expand Down Expand Up @@ -2292,7 +2325,9 @@ <h3 class="section-title" style="margin-top:1rem; font-size:0.95rem">
// 手書きヒントのように陳腐化しない。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',
Expand Down
11 changes: 9 additions & 2 deletions components/wifi_config_service/wifi_config_service.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <cstdio>
#include <cstring>

#include <config_service/product.hpp>
#include <esp_app_desc.h>
#include <esp_http_server.h>
#include <esp_log.h>
Expand Down Expand Up @@ -83,7 +84,8 @@ tl::expected<void, Error> 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
Expand Down Expand Up @@ -137,7 +139,7 @@ tl::expected<httpd_handle_t, Error> 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
Expand Down Expand Up @@ -271,6 +273,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;
Expand Down
8 changes: 8 additions & 0 deletions main/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ set(_main_priv_reqs
esp_wifi
esp_netif
esp_event
esp_http_server
app_update
lwip
esp_http_client
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading