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.
This commit is contained in:
a2nr 2026-07-18 06:50:10 +07:00
parent f164134222
commit a0fb1d80cd
10 changed files with 540 additions and 5 deletions

View File

@ -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
)

View File

@ -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();

View File

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Velxio Deployer Setup</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, system-ui, sans-serif; background: #0f172a; color: #e2e8f0; display: flex; min-height: 100vh; align-items: center; justify-content: center; }
.container { max-width: 420px; width: 90%; padding: 24px; }
.card { background: #1e293b; border-radius: 12px; padding: 28px; box-shadow: 0 4px 24px rgba(0,0,0,0.3); }
h1 { font-size: 1.4rem; margin-bottom: 4px; color: #3b82f6; }
.subtitle { font-size: 0.85rem; color: #94a3b8; margin-bottom: 20px; }
.current-name { background: #334155; border-radius: 8px; padding: 12px; margin-bottom: 20px; }
.current-label { font-size: 0.75rem; color: #94a3b8; margin-bottom: 4px; }
.current-value { font-size: 1rem; font-weight: 600; color: #22c55e; font-family: monospace; }
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 6px; }
.input-group { display: flex; gap: 0; margin-bottom: 20px; }
.prefix { background: #475569; padding: 10px 12px; border-radius: 8px 0 0 8px; font-family: monospace; font-size: 0.9rem; color: #cbd5e1; border: 1px solid #475569; border-right: none; }
input[type="text"] { flex: 1; padding: 10px 12px; border: 1px solid #475569; border-radius: 0 8px 8px 0; background: #0f172a; color: #e2e8f0; font-size: 0.9rem; font-family: monospace; outline: none; }
input[type="text"]:focus { border-color: #3b82f6; }
button { width: 100%; padding: 12px; background: #3b82f6; color: white; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: background 0.2s; }
button:hover { background: #2563eb; }
button:disabled { background: #475569; cursor: not-allowed; }
.status { margin-top: 16px; padding: 10px; border-radius: 8px; font-size: 0.85rem; text-align: center; }
.status.success { background: #14532d; color: #22c55e; }
.status.error { background: #450a0a; color: #ef4444; }
.status.loading { background: #1e3a5f; color: #3b82f6; }
.hint { font-size: 0.75rem; color: #64748b; margin-top: 16px; text-align: center; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>Velxio Deployer</h1>
<p class="subtitle">Konfigurasi nama perangkat</p>
<div class="current-name">
<div class="current-label">Nama saat ini</div>
<div class="current-value" id="currentName">--</div>
</div>
<label for="nameInput">Nama baru</label>
<div class="input-group">
<span class="prefix">Velxio-</span>
<input type="text" id="nameInput" placeholder="LAB-01" maxlength="24" autocomplete="off">
</div>
<button id="saveBtn" onclick="saveName()">Simpan & Reboot</button>
<div id="status"></div>
<p class="hint">Nama akan tampil di pairing Bluetooth. Maks 24 karakter.</p>
</div>
</div>
<script>
const currentEl = document.getElementById('currentName');
const inputEl = document.getElementById('nameInput');
const btnEl = document.getElementById('saveBtn');
const statusEl = document.getElementById('status');
async function loadCurrent() {
try {
const res = await fetch('/api/name');
const data = await res.json();
currentEl.textContent = data.name;
const stripped = data.name.startsWith('Velxio-') ? data.name.slice(7) : data.name;
inputEl.value = stripped;
} catch (e) {
currentEl.textContent = 'Error';
}
}
async function saveName() {
const name = inputEl.value.trim();
if (!name) {
statusEl.className = 'status error';
statusEl.textContent = 'Nama tidak boleh kosong';
return;
}
btnEl.disabled = true;
statusEl.className = 'status loading';
statusEl.textContent = 'Menyimpan...';
try {
const res = await fetch('/api/name', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Velxio-' + name })
});
const data = await res.json();
if (data.success) {
statusEl.className = 'status success';
statusEl.textContent = 'Tersimpan! Rebooting...';
} else {
statusEl.className = 'status error';
statusEl.textContent = 'Error: ' + (data.error || 'unknown');
btnEl.disabled = false;
}
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Error: ' + e.message;
btnEl.disabled = false;
}
}
loadCurrent();
</script>
</body>
</html>

View File

@ -0,0 +1,107 @@
#include "device_name.h"
#include <string.h>
#include <stdio.h>
#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);
}

View File

@ -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 <stdbool.h>
#include <stddef.h>
#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);

View File

@ -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 &&

View File

@ -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();

View File

@ -0,0 +1,228 @@
#include "wifi_ap.h"
#include "device_name.h"
#include <string.h>
#include <stdio.h>
#include <ctype.h>
#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));
}
}

View File

@ -0,0 +1,11 @@
#pragma once
#include <stdbool.h>
/**
* 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));

View File

@ -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