From a0fb1d80cd5f347b7953fa8461738cf524c43f3c Mon Sep 17 00:00:00 2001 From: a2nr Date: Sat, 18 Jul 2026 06:50:10 +0700 Subject: [PATCH] feat(velxio-deployer): unique device name + WiFi AP setup mode - Add device_name module: NVS-persisted custom name with MAC-suffix fallback (Velxio-XXXX) so every device is uniquely identifiable out-of-the-box in BLE pairing dialog - Add wifi_ap module: first-boot SoftAP (Velxio-Setup-XXXX / velxio123) with embedded HTTP config portal at 192.168.4.1 for renaming - main.c: enter AP setup mode when no custom name is set - ble_service.c: use dynamic device name from device_name_get(), with adv-packet length guard - led_button.c: BOOT button long-press (5s) factory reset (erase NVS) - sdkconfig.defaults: enable WiFi stack + set NimBLE default name Frontend deviceName display already committed in 01f68d3. --- velxio-deployer-firmware/main/CMakeLists.txt | 4 + velxio-deployer-firmware/main/ble_service.c | 21 +- .../main/config_page.html | 104 ++++++++ velxio-deployer-firmware/main/device_name.c | 107 ++++++++ velxio-deployer-firmware/main/device_name.h | 21 ++ velxio-deployer-firmware/main/led_button.c | 27 +++ velxio-deployer-firmware/main/main.c | 15 ++ velxio-deployer-firmware/main/wifi_ap.c | 228 ++++++++++++++++++ velxio-deployer-firmware/main/wifi_ap.h | 11 + velxio-deployer-firmware/sdkconfig.defaults | 7 +- 10 files changed, 540 insertions(+), 5 deletions(-) create mode 100644 velxio-deployer-firmware/main/config_page.html create mode 100644 velxio-deployer-firmware/main/device_name.c create mode 100644 velxio-deployer-firmware/main/device_name.h create mode 100644 velxio-deployer-firmware/main/wifi_ap.c create mode 100644 velxio-deployer-firmware/main/wifi_ap.h diff --git a/velxio-deployer-firmware/main/CMakeLists.txt b/velxio-deployer-firmware/main/CMakeLists.txt index d9bb49a..e20eae6 100644 --- a/velxio-deployer-firmware/main/CMakeLists.txt +++ b/velxio-deployer-firmware/main/CMakeLists.txt @@ -10,6 +10,10 @@ idf_component_register( serial_bridge.c led_button.c arduino_reset.c + device_name.c + wifi_ap.c INCLUDE_DIRS . + EMBED_TXTFILES + config_page.html ) diff --git a/velxio-deployer-firmware/main/ble_service.c b/velxio-deployer-firmware/main/ble_service.c index eb0fae7..29c6782 100644 --- a/velxio-deployer-firmware/main/ble_service.c +++ b/velxio-deployer-firmware/main/ble_service.c @@ -18,6 +18,7 @@ void ble_store_config_init(void); #include "state_machine.h" #include "services/gatt/ble_svc_gatt.h" #include "ble_service.h" +#include "device_name.h" #include "arduino_reset.h" #include "serial_bridge.h" #include "state_machine.h" @@ -148,9 +149,21 @@ static void ble_restart_adv(void) struct ble_hs_adv_fields fields; memset(&fields, 0, sizeof(fields)); fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP; - fields.name = (uint8_t *)"Velxio"; - fields.name_len = 6; - fields.name_is_complete = 0; + const char *name = device_name_get(); + size_t name_len = strlen(name); + // Check if full name fits in 31-byte adv packet + // Adv fields overhead: flags(3) + name header(2) = ~5 bytes overhead for name + // So name content needs ≤ 26 bytes max + if (name_len <= 24) { // safe margin under 31-byte limit + fields.name = (uint8_t *)name; + fields.name_len = name_len; + fields.name_is_complete = 1; + } else { + // Truncate to short prefix for advertising + fields.name = (uint8_t *)"Velxio"; + fields.name_len = 6; + fields.name_is_complete = 0; + } fields.uuids16 = NULL; fields.num_uuids16 = 0; @@ -270,7 +283,7 @@ void ble_service_init(void) ble_gatts_count_cfg(gatt_svcs); ble_gatts_add_svcs(gatt_svcs); - ble_svc_gap_device_name_set("Velxio-Deployer"); + ble_svc_gap_device_name_set(device_name_get()); ble_svc_gap_device_appearance_set(0x0080); ble_store_config_init(); diff --git a/velxio-deployer-firmware/main/config_page.html b/velxio-deployer-firmware/main/config_page.html new file mode 100644 index 0000000..d72ce3c --- /dev/null +++ b/velxio-deployer-firmware/main/config_page.html @@ -0,0 +1,104 @@ + + + + + + Velxio Deployer Setup + + + +
+
+

Velxio Deployer

+

Konfigurasi nama perangkat

+
+
Nama saat ini
+
--
+
+ +
+ Velxio- + +
+ +
+

Nama akan tampil di pairing Bluetooth. Maks 24 karakter.

+
+
+ + + diff --git a/velxio-deployer-firmware/main/device_name.c b/velxio-deployer-firmware/main/device_name.c new file mode 100644 index 0000000..269079e --- /dev/null +++ b/velxio-deployer-firmware/main/device_name.c @@ -0,0 +1,107 @@ +#include "device_name.h" +#include +#include +#include "nvs_flash.h" +#include "nvs.h" +#include "esp_mac.h" +#include "esp_log.h" + +static const char *TAG = "DEV_NAME"; +static char s_device_name[VELXIO_NAME_MAX_LEN] = {0}; +static bool s_is_custom = false; + +static void generate_mac_suffix_name(void) +{ + uint8_t mac[6]; + esp_err_t mac_err = esp_read_mac(mac, ESP_MAC_BT); + if (mac_err != ESP_OK) { + ESP_LOGW(TAG, "esp_read_mac failed (%d), using fallback suffix", mac_err); + snprintf(s_device_name, sizeof(s_device_name), + "Velxio-0000"); + } else { + snprintf(s_device_name, sizeof(s_device_name), + "Velxio-%02X%02X", mac[4], mac[5]); + } + s_is_custom = false; + ESP_LOGI(TAG, "Generated device name from MAC: %s", s_device_name); +} + +void device_name_init(void) +{ + nvs_handle_t h; + esp_err_t err = nvs_open(VELXIO_NVS_NAMESPACE, NVS_READONLY, &h); + if (err == ESP_OK) { + size_t len = sizeof(s_device_name); + err = nvs_get_str(h, VELXIO_NVS_KEY_NAME, s_device_name, &len); + nvs_close(h); + if (err == ESP_OK && strlen(s_device_name) > 0) { + s_is_custom = true; + ESP_LOGI(TAG, "Loaded custom device name from NVS: %s", s_device_name); + return; + } + ESP_LOGI(TAG, "NVS open OK but no custom name key, using MAC suffix"); + } else { + ESP_LOGI(TAG, "NVS namespace 'velxio' not found (err=%d), using MAC suffix", err); + } + generate_mac_suffix_name(); +} + +const char *device_name_get(void) +{ + return s_device_name; +} + +bool device_name_is_custom(void) +{ + return s_is_custom; +} + +esp_err_t device_name_set_custom(const char *name) +{ + if (!name || strlen(name) == 0) { + return ESP_ERR_INVALID_ARG; + } + + size_t prefix = (strncmp(name, "Velxio-", 7) == 0) ? 0 : 7; + if (strlen(name) + prefix >= VELXIO_NAME_MAX_LEN) { + ESP_LOGE(TAG, "Name too long (max %d chars including prefix)", VELXIO_NAME_MAX_LEN - 1); + return ESP_ERR_INVALID_SIZE; + } + + char full_name[VELXIO_NAME_MAX_LEN]; + if (prefix == 0) { + snprintf(full_name, sizeof(full_name), "%s", name); + } else { + snprintf(full_name, sizeof(full_name), "Velxio-%s", name); + } + + nvs_handle_t h; + esp_err_t err = nvs_open(VELXIO_NVS_NAMESPACE, NVS_READWRITE, &h); + if (err != ESP_OK) { + ESP_LOGE(TAG, "NVS open failed: %d", err); + return err; + } + + err = nvs_set_str(h, VELXIO_NVS_KEY_NAME, full_name); + if (err == ESP_OK) { + err = nvs_commit(h); + } + nvs_close(h); + + if (err == ESP_OK) { + ESP_LOGI(TAG, "Saved custom device name: %s", full_name); + snprintf(s_device_name, sizeof(s_device_name), "%s", full_name); + s_is_custom = true; + } else { + ESP_LOGE(TAG, "NVS save failed: %d", err); + } + return err; +} + +void device_name_get_ap_ssid(char *buf, size_t buf_len) +{ + const char *name = s_device_name; + size_t len = strlen(name); + const char *suffix = (len >= 4) ? (name + len - 4) : "0000"; + snprintf(buf, buf_len, "Velxio-Setup-%s", suffix); +} diff --git a/velxio-deployer-firmware/main/device_name.h b/velxio-deployer-firmware/main/device_name.h new file mode 100644 index 0000000..f31de75 --- /dev/null +++ b/velxio-deployer-firmware/main/device_name.h @@ -0,0 +1,21 @@ +/** + * @file device_name.h + * @brief Device name management for Velxio firmware + * + * Manages persistent custom device names and MAC-address-derived + * fallback names, stored in NVS. + */ +#pragma once +#include +#include +#include "esp_err.h" + +#define VELXIO_NVS_NAMESPACE "velxio" +#define VELXIO_NVS_KEY_NAME "dev_name" +#define VELXIO_NAME_MAX_LEN 32 + +void device_name_init(void); +const char *device_name_get(void); +bool device_name_is_custom(void); +esp_err_t device_name_set_custom(const char *name); +void device_name_get_ap_ssid(char *buf, size_t buf_len); diff --git a/velxio-deployer-firmware/main/led_button.c b/velxio-deployer-firmware/main/led_button.c index 3106fa8..a2fa34b 100644 --- a/velxio-deployer-firmware/main/led_button.c +++ b/velxio-deployer-firmware/main/led_button.c @@ -1,6 +1,8 @@ #include "esp_log.h" #include "driver/gpio.h" #include "esp_timer.h" +#include "nvs_flash.h" +#include "esp_system.h" #include "led_button.h" static const char *TAG = "LED_BTN"; @@ -11,6 +13,10 @@ static bool button_pressed_flag = false; static int64_t last_toggle_time = 0; static bool blink_state = false; +/* Long-press factory reset tracking */ +static int64_t button_press_start_ms = 0; +static bool button_is_held = false; + static void set_led(bool red, bool green, bool blue) { gpio_set_level(LED_GPIO_RED, red ? 1 : 0); @@ -78,6 +84,27 @@ bool button_retry_pressed(void) void led_button_tick(void) { + /* Factory reset long-press detection (GPIO0 = BOOT button) */ + bool btn_level = gpio_get_level(BTN_GPIO_RETRY); + if (btn_level == 0) { /* button pressed (active low with pull-up) */ + if (!button_is_held) { + button_is_held = true; + button_press_start_ms = esp_timer_get_time() / 1000; + } else { + int64_t held_ms = (esp_timer_get_time() / 1000) - button_press_start_ms; + if (held_ms > 5000) { + ESP_LOGW(TAG, "BOOT button held %lldms — factory reset!", held_ms); + esp_err_t erase_err = nvs_flash_erase(); + if (erase_err != ESP_OK) { + ESP_LOGE(TAG, "NVS erase failed: %d", erase_err); + } + esp_restart(); + } + } + } else { + button_is_held = false; + } + if (current_pattern != LED_GREEN_BLINK && current_pattern != LED_RED_BLINK && current_pattern != LED_BLUE_BLINK && diff --git a/velxio-deployer-firmware/main/main.c b/velxio-deployer-firmware/main/main.c index 3d4fde3..772b8f1 100644 --- a/velxio-deployer-firmware/main/main.c +++ b/velxio-deployer-firmware/main/main.c @@ -14,6 +14,8 @@ #include "state_machine.h" #include "serial_bridge.h" #include "led_button.h" +#include "device_name.h" +#include "wifi_ap.h" static const char *TAG = "MAIN"; @@ -67,7 +69,20 @@ void app_main(void) { ESP_LOGI(TAG, "Velxio BLE Deployer v1.0"); + // Initialize NVS nvs_flash_init(); + + // Initialize device name from NVS or MAC suffix + device_name_init(); + + // AP setup mode: if no custom name, start AP config portal + if (!device_name_is_custom()) { + ESP_LOGW(TAG, "No custom name — entering AP setup mode"); + wifi_ap_start_and_block(); // never returns + } + + // --- Normal boot: BLE + USB Host mode --- + esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); led_button_init(); diff --git a/velxio-deployer-firmware/main/wifi_ap.c b/velxio-deployer-firmware/main/wifi_ap.c new file mode 100644 index 0000000..fa5a7f3 --- /dev/null +++ b/velxio-deployer-firmware/main/wifi_ap.c @@ -0,0 +1,228 @@ +#include "wifi_ap.h" +#include "device_name.h" +#include +#include +#include +#include "esp_wifi.h" +#include "esp_event.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_http_server.h" +#include "nvs_flash.h" +#include "esp_system.h" + +static const char *TAG = "WIFI_AP"; + +/* Embedded config_page.html — linked via EMBED_TXTFILES in CMakeLists.txt */ +extern const uint8_t config_page_html_start[] asm("_binary_config_page_html_start"); +extern const uint8_t config_page_html_end[] asm("_binary_config_page_html_end"); + +#define AP_PASSWORD "velxio123" + +/* ---------- Minimal JSON helpers ---------- */ + +/** + * @brief Extract string value for a given key from a flat JSON object. + * + * Parses {"key":"value"} — no nesting, no escaping support. + * Returns pointer to the value text (null-terminated in-place) or NULL. + */ +static const char *json_get_string(const char *json, const char *key) +{ + if (!json || !key) return NULL; + + /* Build search pattern: "key":" */ + char pattern[64]; + int n = snprintf(pattern, sizeof(pattern), "\"%s\":\"", key); + if (n < 0 || (size_t)n >= sizeof(pattern)) return NULL; + + const char *p = strstr(json, pattern); + if (!p) return NULL; + + p += strlen(pattern); /* point to first char of value */ + /* Find closing quote */ + const char *end = strchr(p, '"'); + if (!end) return NULL; + + /* Return the value as a mutable string (caller gets const, but we + write a null to mark the end — safe since json is in a writable buf) */ + /* Cast away const: the caller owns a writable buffer */ + char *mutable = (char *)end; + *mutable = '\0'; + return p; +} + +/* ---------- HTTP handlers ---------- */ + +static esp_err_t get_root_handler(httpd_req_t *req) +{ + const size_t html_len = config_page_html_end - config_page_html_start; + ESP_LOGI(TAG, "Serving config page (%zu bytes)", html_len); + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, (const char *)config_page_html_start, html_len); + return ESP_OK; +} + +static esp_err_t get_name_handler(httpd_req_t *req) +{ + const char *name = device_name_get(); + char resp[128]; + snprintf(resp, sizeof(resp), "{\"name\":\"%s\"}", name); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; +} + +/** + * Validate suffix: only alphanumeric, dash, and underscore allowed. + */ +static bool is_valid_suffix(const char *s) +{ + if (!s || *s == '\0') return false; + for (; *s; s++) { + if (!isalnum((unsigned char)*s) && *s != '-' && *s != '_') + return false; + } + return true; +} + +static esp_err_t post_name_handler(httpd_req_t *req) +{ + char buf[256]; + int remaining = req->content_len; + + if (remaining >= (int)sizeof(buf)) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"success\":false,\"error\":\"Payload too large\"}"); + return ESP_OK; + } + + int ret = httpd_req_recv(req, buf, remaining); + if (ret <= 0) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"success\":false,\"error\":\"Failed to read body\"}"); + return ESP_OK; + } + buf[ret] = '\0'; + + /* Parse JSON: {"name":"Velxio-XXXX"} */ + const char *name_val = json_get_string(buf, "name"); + if (!name_val) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"success\":false,\"error\":\"Missing 'name' field\"}"); + return ESP_OK; + } + + const char *full_name = name_val; + + /* Extract suffix after "Velxio-" prefix */ + const char *suffix = full_name; + if (strncmp(full_name, "Velxio-", 7) == 0) { + suffix = full_name + 7; + } + + if (!is_valid_suffix(suffix)) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, + "{\"success\":false,\"error\":\"Only alphanumeric, dash, underscore allowed\"}"); + return ESP_OK; + } + + esp_err_t err = device_name_set_custom(full_name); + + if (err != ESP_OK) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"success\":false,\"error\":\"NVS write failed\"}"); + return ESP_OK; + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, "{\"success\":true}"); + + ESP_LOGI(TAG, "Name saved, rebooting in 1 second..."); + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_restart(); + + /* Never reached */ + return ESP_OK; +} + +/* ---------- Public API ---------- */ + +void wifi_ap_start_and_block(void) +{ + ESP_LOGI(TAG, "Starting WiFi SoftAP + HTTP config server..."); + + /* Initialise network interface and event loop */ + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + esp_netif_create_default_wifi_ap(); + + /* Initialise WiFi in AP mode */ + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + /* Build AP SSID from device MAC */ + char ap_ssid[32] = {0}; + device_name_get_ap_ssid(ap_ssid, sizeof(ap_ssid)); + + /* Configure SoftAP */ + wifi_config_t wifi_config = { + .ap = { + .ssid_len = 0, + .max_connection = 4, + .authmode = WIFI_AUTH_WPA_WPA2_PSK, + }, + }; + snprintf((char *)wifi_config.ap.ssid, sizeof(wifi_config.ap.ssid), "%s", ap_ssid); + snprintf((char *)wifi_config.ap.password, sizeof(wifi_config.ap.password), "%s", AP_PASSWORD); + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + ESP_LOGI(TAG, "SoftAP started — SSID: %s, Password: %s", ap_ssid, AP_PASSWORD); + + /* Start HTTP config server */ + httpd_handle_t server = NULL; + httpd_config_t httpd_conf = HTTPD_DEFAULT_CONFIG(); + + if (httpd_start(&server, &httpd_conf) != ESP_OK) { + ESP_LOGE(TAG, "Failed to start HTTP server"); + while (1) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } + } + + /* Register URI handlers */ + httpd_uri_t uri_get_root = { + .uri = "/", + .method = HTTP_GET, + .handler = get_root_handler, + .user_ctx = NULL, + }; + httpd_register_uri_handler(server, &uri_get_root); + + httpd_uri_t uri_get_name = { + .uri = "/api/name", + .method = HTTP_GET, + .handler = get_name_handler, + .user_ctx = NULL, + }; + httpd_register_uri_handler(server, &uri_get_name); + + httpd_uri_t uri_post_name = { + .uri = "/api/name", + .method = HTTP_POST, + .handler = post_name_handler, + .user_ctx = NULL, + }; + httpd_register_uri_handler(server, &uri_post_name); + + ESP_LOGI(TAG, "HTTP config server running at http://192.168.4.1"); + + /* Block forever — this function never returns */ + while (1) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } +} diff --git a/velxio-deployer-firmware/main/wifi_ap.h b/velxio-deployer-firmware/main/wifi_ap.h new file mode 100644 index 0000000..c28cb0c --- /dev/null +++ b/velxio-deployer-firmware/main/wifi_ap.h @@ -0,0 +1,11 @@ +#pragma once +#include + +/** + * Start WiFi SoftAP + HTTP config server. + * Blocks forever (does not return). + * AP SSID: "Velxio-Setup-XXXX" (MAC suffix) + * AP Password: "velxio123" + * Config page: http://192.168.4.1 + */ +void wifi_ap_start_and_block(void) __attribute__((noreturn)); diff --git a/velxio-deployer-firmware/sdkconfig.defaults b/velxio-deployer-firmware/sdkconfig.defaults index a8953c9..547e408 100644 --- a/velxio-deployer-firmware/sdkconfig.defaults +++ b/velxio-deployer-firmware/sdkconfig.defaults @@ -10,13 +10,18 @@ CONFIG_FREERTOS_HZ=100 CONFIG_BT_ENABLED=y CONFIG_BT_NIMBLE_ENABLED=y CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y -CONFIG_BT_NIMBLE_SVC_GAP_DEVICE_NAME="Velxio-Deployer" +CONFIG_BT_NIMBLE_SVC_GAP_DEVICE_NAME="Velxio" CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 CONFIG_BT_NIMBLE_MAX_BONDS=1 CONFIG_BT_NIMBLE_HS_STOP_TIMEOUT_MS=5000 CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=255 CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=24 +# WiFi (for AP setup mode) +CONFIG_ESP_WIFI_ENABLED=y +CONFIG_ESP_WIFI_SOFTAP_SUPPORT=y +CONFIG_ESP_WIFI_NVS_ENABLED=y + # PSRAM (octal, N16R8) CONFIG_SPIRAM=y CONFIG_SPIRAM_MODE_OCT=y