Initial kesp32_app_base component
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# ESP-IDF generated output and local configuration
|
||||
build/
|
||||
managed_components/
|
||||
sdkconfig
|
||||
sdkconfig.old
|
||||
*.log
|
||||
@@ -0,0 +1,16 @@
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"src/esp32_app_base.c"
|
||||
"src/wifi_store.c"
|
||||
"src/web_provisioning.c"
|
||||
"src/portal_self_test.c"
|
||||
"src/time_sync.c"
|
||||
"src/ota_update.c"
|
||||
"src/ota_version.c"
|
||||
"src/mqtt_transport.c"
|
||||
"src/dns_server/dns_server.c"
|
||||
INCLUDE_DIRS "include" "src" "src/dns_server/include"
|
||||
EMBED_FILES "src/provisioning.html"
|
||||
REQUIRES app_update esp_driver_gpio esp_event esp_http_server esp_https_ota esp_netif esp_wifi mqtt nvs_flash
|
||||
PRIV_REQUIRES espressif__cjson
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
menu "SESE Controller"
|
||||
|
||||
config SESE_NTP_SERVER
|
||||
string "NTP server"
|
||||
default "pool.ntp.org"
|
||||
help
|
||||
Hostname of the SNTP server used to set the system clock after the
|
||||
station receives an IP address.
|
||||
|
||||
config SESE_TIMEZONE
|
||||
string "POSIX timezone"
|
||||
default "KST-9"
|
||||
help
|
||||
POSIX TZ string used when formatting local timestamps. KST-9 means
|
||||
Korea Standard Time (UTC+09:00). The system epoch remains UTC.
|
||||
|
||||
config SESE_NTP_INITIAL_WAIT_SECONDS
|
||||
int "Initial NTP synchronization wait (seconds)"
|
||||
default 30
|
||||
range 5 120
|
||||
help
|
||||
Maximum boot-time wait for the first NTP response. A timeout does
|
||||
not stop the SNTP client's periodic background retries.
|
||||
|
||||
config SESE_MQTT_ENABLE
|
||||
bool "Enable MQTT client"
|
||||
default y
|
||||
help
|
||||
Connect to the configured broker after Wi-Fi and initial NTP setup.
|
||||
The client publishes retained online/offline state, periodic
|
||||
heartbeats, and implements a bounded ping/pong test command.
|
||||
|
||||
config SESE_MQTT_BROKER_URI
|
||||
string "MQTT broker URI"
|
||||
default ""
|
||||
depends on SESE_MQTT_ENABLE
|
||||
help
|
||||
Broker URI such as mqtt://host:1883 or mqtts://host:8883. Leave
|
||||
empty to keep the client disabled. Do not embed usernames,
|
||||
passwords, tokens, or other credentials in this build setting.
|
||||
|
||||
config SESE_MQTT_HEARTBEAT_SECONDS
|
||||
int "MQTT heartbeat interval (seconds)"
|
||||
default 60
|
||||
range 5 86400
|
||||
depends on SESE_MQTT_ENABLE
|
||||
|
||||
config SESE_OTA_ENABLE
|
||||
bool "Enable automatic HTTPS OTA"
|
||||
default y
|
||||
help
|
||||
Check a configured HTTPS endpoint for a newer firmware image after
|
||||
Wi-Fi connects and then periodically while the device is running.
|
||||
|
||||
config SESE_OTA_URL
|
||||
string "HTTPS OTA firmware URL"
|
||||
default ""
|
||||
depends on SESE_OTA_ENABLE
|
||||
help
|
||||
Direct HTTPS URL to an ESP-IDF application .bin image. Leave empty
|
||||
to disable network checks. Do not place credentials or secret query
|
||||
parameters in this build-time URL.
|
||||
|
||||
config SESE_OTA_INITIAL_DELAY_SECONDS
|
||||
int "Delay before first OTA check (seconds)"
|
||||
default 30
|
||||
range 5 3600
|
||||
depends on SESE_OTA_ENABLE
|
||||
|
||||
config SESE_OTA_CHECK_INTERVAL_MINUTES
|
||||
int "Successful/no-update check interval (minutes)"
|
||||
default 360
|
||||
range 5 10080
|
||||
depends on SESE_OTA_ENABLE
|
||||
|
||||
config SESE_OTA_RETRY_INTERVAL_MINUTES
|
||||
int "Failed-check retry interval (minutes)"
|
||||
default 15
|
||||
range 1 1440
|
||||
depends on SESE_OTA_ENABLE
|
||||
|
||||
endmenu
|
||||
@@ -0,0 +1,9 @@
|
||||
# kesp32_app_base
|
||||
|
||||
Reusable ESP-IDF application-base component for SESE consumers. The source keeps
|
||||
its public compatibility API, including `esp32_app_base_start()` and the
|
||||
`SESE_*` Kconfig symbols; the repository/component name is `kesp32_app_base`.
|
||||
|
||||
This checkout is consumed locally by `sese-controller` as a Git submodule. The
|
||||
parent project supplies product policy, board settings, partitions, and build
|
||||
configuration.
|
||||
@@ -0,0 +1,5 @@
|
||||
dependencies:
|
||||
idf:
|
||||
version: '>=6.0.2'
|
||||
espressif/mqtt: ^1.1.0
|
||||
espressif/cjson: ^1.7.19
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.h"
|
||||
|
||||
typedef esp_err_t (*esp32_app_base_product_start_fn_t)(void);
|
||||
typedef esp_err_t (*esp32_app_base_health_check_fn_t)(void);
|
||||
|
||||
typedef struct {
|
||||
const char *service_prefix;
|
||||
int reset_gpio;
|
||||
bool reset_active_low;
|
||||
uint32_t reset_hold_ms;
|
||||
esp32_app_base_product_start_fn_t product_start;
|
||||
esp32_app_base_health_check_fn_t health_check;
|
||||
const char *ntp_server;
|
||||
const char *timezone;
|
||||
uint32_t ntp_initial_wait_seconds;
|
||||
bool ota_enabled;
|
||||
const char *ota_url;
|
||||
uint32_t ota_initial_delay_seconds;
|
||||
uint32_t ota_check_interval_minutes;
|
||||
uint32_t ota_retry_interval_minutes;
|
||||
} esp32_app_base_config_t;
|
||||
|
||||
/** Owns NVS, Wi-Fi browser provisioning, NTP, OTA rollback and reset button. */
|
||||
esp_err_t esp32_app_base_start(const esp32_app_base_config_t *config);
|
||||
|
||||
/** Network readiness event group for optional product services. */
|
||||
EventGroupHandle_t esp32_app_base_network_events(void);
|
||||
EventBits_t esp32_app_base_network_ready_bit(void);
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef void (*mqtt_transport_connected_fn_t)(void *ctx);
|
||||
typedef void (*mqtt_transport_message_fn_t)(const char *topic, size_t topic_len,
|
||||
const char *data, size_t data_len, void *ctx);
|
||||
typedef void (*mqtt_transport_heartbeat_fn_t)(void *ctx);
|
||||
typedef struct {
|
||||
const char *broker_uri;
|
||||
const char *client_id;
|
||||
const char *last_will_topic;
|
||||
const char *last_will_payload;
|
||||
uint32_t heartbeat_interval_seconds;
|
||||
mqtt_transport_connected_fn_t on_connected;
|
||||
mqtt_transport_message_fn_t on_message;
|
||||
mqtt_transport_heartbeat_fn_t on_heartbeat;
|
||||
void *ctx;
|
||||
} mqtt_transport_config_t;
|
||||
esp_err_t mqtt_transport_start(const mqtt_transport_config_t *config);
|
||||
int mqtt_transport_publish(const char *topic, const char *payload, int qos, bool retain);
|
||||
int mqtt_transport_subscribe(const char *topic, int qos);
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.h"
|
||||
|
||||
/** Mark an OTA image valid after application-level network diagnostics pass. */
|
||||
esp_err_t ota_mark_running_app_valid(void);
|
||||
|
||||
/** Start the periodic HTTPS OTA worker when OTA is configured. */
|
||||
esp_err_t ota_start_task(EventGroupHandle_t network_events, EventBits_t connected_bit);
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
/** Compare strict major.minor.patch versions. Returns false for invalid input. */
|
||||
bool ota_version_compare(const char *left, const char *right, int *order);
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/** Run one local HTTP health check against the captive portal. */
|
||||
esp_err_t portal_self_test_start(void);
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
|
||||
/** Initialize and start periodic SNTP synchronization. */
|
||||
esp_err_t time_sync_start(void);
|
||||
|
||||
/** Wait for the first successful synchronization and log local time. */
|
||||
esp_err_t time_sync_wait(TickType_t timeout);
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "wifi_store.h"
|
||||
|
||||
typedef esp_err_t (*web_provisioning_apply_cb_t)(const wifi_store_credentials_t *credentials);
|
||||
typedef void (*web_provisioning_expired_cb_t)(void);
|
||||
|
||||
typedef enum {
|
||||
WEB_PROV_IDLE,
|
||||
WEB_PROV_SCANNING,
|
||||
WEB_PROV_CONNECTING,
|
||||
WEB_PROV_CONNECTED,
|
||||
WEB_PROV_FAILED,
|
||||
} web_provisioning_state_t;
|
||||
|
||||
typedef struct {
|
||||
const char *service_name;
|
||||
uint32_t timeout_seconds;
|
||||
web_provisioning_apply_cb_t apply_credentials;
|
||||
web_provisioning_expired_cb_t expired;
|
||||
} web_provisioning_config_t;
|
||||
|
||||
esp_err_t web_provisioning_start(const web_provisioning_config_t *config);
|
||||
void web_provisioning_stop(void);
|
||||
bool web_provisioning_is_active(void);
|
||||
bool web_provisioning_is_connecting(void);
|
||||
void web_provisioning_report_connected(const char *ip_address);
|
||||
void web_provisioning_report_failure(const char *reason);
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#define WIFI_STORE_SSID_MAX_LEN 32
|
||||
#define WIFI_STORE_PASSWORD_MAX_LEN 64
|
||||
|
||||
typedef struct {
|
||||
char ssid[WIFI_STORE_SSID_MAX_LEN + 1];
|
||||
char password[WIFI_STORE_PASSWORD_MAX_LEN + 1];
|
||||
} wifi_store_credentials_t;
|
||||
|
||||
esp_err_t wifi_store_load(wifi_store_credentials_t *credentials, bool *found);
|
||||
esp_err_t wifi_store_save(const wifi_store_credentials_t *credentials);
|
||||
esp_err_t wifi_store_erase(void);
|
||||
@@ -0,0 +1,3 @@
|
||||
idf_component_register(SRCS dns_server.c
|
||||
INCLUDE_DIRS include
|
||||
PRIV_REQUIRES esp_netif)
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021-2023 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Unlicense OR CC0-1.0
|
||||
*/
|
||||
|
||||
#include <sys/param.h>
|
||||
#include <inttypes.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_check.h"
|
||||
#include "esp_netif.h"
|
||||
|
||||
#include "lwip/err.h"
|
||||
#include "lwip/sockets.h"
|
||||
#include "lwip/sys.h"
|
||||
#include "lwip/netdb.h"
|
||||
#include "dns_server.h"
|
||||
|
||||
#define DNS_PORT (53)
|
||||
#define DNS_MAX_LEN (256)
|
||||
|
||||
#define OPCODE_MASK (0x7800)
|
||||
#define QR_FLAG (1 << 7)
|
||||
#define QD_TYPE_A (0x0001)
|
||||
#define ANS_TTL_SEC (300)
|
||||
|
||||
static const char *TAG = "example_dns_redirect_server";
|
||||
|
||||
// DNS Header Packet
|
||||
typedef struct __attribute__((__packed__))
|
||||
{
|
||||
uint16_t id;
|
||||
uint16_t flags;
|
||||
uint16_t qd_count;
|
||||
uint16_t an_count;
|
||||
uint16_t ns_count;
|
||||
uint16_t ar_count;
|
||||
} dns_header_t;
|
||||
|
||||
// DNS Question Packet
|
||||
typedef struct {
|
||||
uint16_t type;
|
||||
uint16_t class;
|
||||
} dns_question_t;
|
||||
|
||||
// DNS Answer Packet
|
||||
typedef struct __attribute__((__packed__))
|
||||
{
|
||||
uint16_t ptr_offset;
|
||||
uint16_t type;
|
||||
uint16_t class;
|
||||
uint32_t ttl;
|
||||
uint16_t addr_len;
|
||||
uint32_t ip_addr;
|
||||
} dns_answer_t;
|
||||
|
||||
// DNS server handle
|
||||
struct dns_server_handle {
|
||||
bool started;
|
||||
TaskHandle_t task;
|
||||
int num_of_entries;
|
||||
dns_entry_pair_t entry[];
|
||||
};
|
||||
|
||||
/*
|
||||
Parse the name from the packet from the DNS name format to a regular .-seperated name
|
||||
returns the pointer to the next part of the packet
|
||||
*/
|
||||
static char *parse_dns_name(char *raw_name, char *parsed_name, size_t parsed_name_max_len)
|
||||
{
|
||||
|
||||
char *label = raw_name;
|
||||
char *name_itr = parsed_name;
|
||||
int name_len = 0;
|
||||
|
||||
do {
|
||||
int sub_name_len = *label;
|
||||
// (len + 1) since we are adding a '.'
|
||||
name_len += (sub_name_len + 1);
|
||||
if (name_len > parsed_name_max_len) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Copy the sub name that follows the the label
|
||||
memcpy(name_itr, label + 1, sub_name_len);
|
||||
name_itr[sub_name_len] = '.';
|
||||
name_itr += (sub_name_len + 1);
|
||||
label += sub_name_len + 1;
|
||||
} while (*label != 0);
|
||||
|
||||
// Terminate the final string, replacing the last '.'
|
||||
parsed_name[name_len - 1] = '\0';
|
||||
// Return pointer to first char after the name
|
||||
return label + 1;
|
||||
}
|
||||
|
||||
// Parses the DNS request and prepares a DNS response with the IP of the softAP
|
||||
static int parse_dns_request(char *req, size_t req_len, char *dns_reply, size_t dns_reply_max_len, dns_server_handle_t h)
|
||||
{
|
||||
if (req_len > dns_reply_max_len) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Prepare the reply
|
||||
memset(dns_reply, 0, dns_reply_max_len);
|
||||
memcpy(dns_reply, req, req_len);
|
||||
|
||||
// Endianess of NW packet different from chip
|
||||
dns_header_t *header = (dns_header_t *)dns_reply;
|
||||
ESP_LOGD(TAG, "DNS query with header id: 0x%X, flags: 0x%X, qd_count: %d",
|
||||
ntohs(header->id), ntohs(header->flags), ntohs(header->qd_count));
|
||||
|
||||
// Not a standard query
|
||||
if ((header->flags & OPCODE_MASK) != 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Set question response flag
|
||||
header->flags |= QR_FLAG;
|
||||
|
||||
uint16_t qd_count = ntohs(header->qd_count);
|
||||
header->an_count = htons(qd_count);
|
||||
|
||||
int reply_len = qd_count * sizeof(dns_answer_t) + req_len;
|
||||
if (reply_len > dns_reply_max_len) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Pointer to current answer and question
|
||||
char *cur_ans_ptr = dns_reply + req_len;
|
||||
char *cur_qd_ptr = dns_reply + sizeof(dns_header_t);
|
||||
char name[128];
|
||||
|
||||
// Respond to all questions based on configured rules
|
||||
for (int qd_i = 0; qd_i < qd_count; qd_i++) {
|
||||
char *name_end_ptr = parse_dns_name(cur_qd_ptr, name, sizeof(name));
|
||||
if (name_end_ptr == NULL) {
|
||||
ESP_LOGE(TAG, "Failed to parse DNS question: %s", cur_qd_ptr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
dns_question_t *question = (dns_question_t *)(name_end_ptr);
|
||||
uint16_t qd_type = ntohs(question->type);
|
||||
uint16_t qd_class = ntohs(question->class);
|
||||
|
||||
ESP_LOGD(TAG, "Received type: %d | Class: %d | Question for: %s", qd_type, qd_class, name);
|
||||
|
||||
if (qd_type == QD_TYPE_A) {
|
||||
esp_ip4_addr_t ip = { .addr = IPADDR_ANY };
|
||||
// Check the configured rules to decide whether to answer this question or not
|
||||
for (int i = 0; i < h->num_of_entries; ++i) {
|
||||
// check if the name either corresponds to the entry, or if we should answer to all queries ("*")
|
||||
if (strcmp(h->entry[i].name, "*") == 0 || strcmp(h->entry[i].name, name) == 0) {
|
||||
if (h->entry[i].if_key) {
|
||||
esp_netif_ip_info_t ip_info;
|
||||
esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey(h->entry[i].if_key), &ip_info);
|
||||
ip.addr = ip_info.ip.addr;
|
||||
break;
|
||||
} else if (h->entry->ip.addr != IPADDR_ANY) {
|
||||
ip.addr = h->entry[i].ip.addr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ip.addr == IPADDR_ANY) { // no rule applies, continue with another question
|
||||
continue;
|
||||
}
|
||||
dns_answer_t *answer = (dns_answer_t *)cur_ans_ptr;
|
||||
|
||||
answer->ptr_offset = htons(0xC000 | (cur_qd_ptr - dns_reply));
|
||||
answer->type = htons(qd_type);
|
||||
answer->class = htons(qd_class);
|
||||
answer->ttl = htonl(ANS_TTL_SEC);
|
||||
|
||||
ESP_LOGD(TAG, "Answer with PTR offset: 0x%" PRIX16 " and IP 0x%" PRIX32, ntohs(answer->ptr_offset), ip.addr);
|
||||
|
||||
answer->addr_len = htons(sizeof(ip.addr));
|
||||
answer->ip_addr = ip.addr;
|
||||
}
|
||||
}
|
||||
return reply_len;
|
||||
}
|
||||
|
||||
/*
|
||||
Sets up a socket and listen for DNS queries,
|
||||
replies to all type A queries with the IP of the softAP
|
||||
*/
|
||||
void dns_server_task(void *pvParameters)
|
||||
{
|
||||
char rx_buffer[128];
|
||||
char addr_str[128];
|
||||
int addr_family;
|
||||
int ip_protocol;
|
||||
dns_server_handle_t handle = pvParameters;
|
||||
|
||||
while (handle->started) {
|
||||
|
||||
struct sockaddr_in dest_addr;
|
||||
dest_addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||
dest_addr.sin_family = AF_INET;
|
||||
dest_addr.sin_port = htons(DNS_PORT);
|
||||
addr_family = AF_INET;
|
||||
ip_protocol = IPPROTO_IP;
|
||||
inet_ntoa_r(dest_addr.sin_addr, addr_str, sizeof(addr_str) - 1);
|
||||
|
||||
int sock = socket(addr_family, SOCK_DGRAM, ip_protocol);
|
||||
if (sock < 0) {
|
||||
ESP_LOGE(TAG, "Unable to create socket: errno %d", errno);
|
||||
break;
|
||||
}
|
||||
ESP_LOGI(TAG, "Socket created");
|
||||
|
||||
int err = bind(sock, (struct sockaddr *)&dest_addr, sizeof(dest_addr));
|
||||
if (err < 0) {
|
||||
ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno);
|
||||
}
|
||||
ESP_LOGI(TAG, "Socket bound, port %d", DNS_PORT);
|
||||
|
||||
while (handle->started) {
|
||||
ESP_LOGI(TAG, "Waiting for data");
|
||||
struct sockaddr_in6 source_addr; // Large enough for both IPv4 or IPv6
|
||||
socklen_t socklen = sizeof(source_addr);
|
||||
int len = recvfrom(sock, rx_buffer, sizeof(rx_buffer) - 1, 0, (struct sockaddr *)&source_addr, &socklen);
|
||||
|
||||
// Error occurred during receiving
|
||||
if (len < 0) {
|
||||
ESP_LOGE(TAG, "recvfrom failed: errno %d", errno);
|
||||
close(sock);
|
||||
break;
|
||||
}
|
||||
// Data received
|
||||
else {
|
||||
// Get the sender's ip address as string
|
||||
if (source_addr.sin6_family == PF_INET) {
|
||||
inet_ntoa_r(((struct sockaddr_in *)&source_addr)->sin_addr.s_addr, addr_str, sizeof(addr_str) - 1);
|
||||
} else if (source_addr.sin6_family == PF_INET6) {
|
||||
inet6_ntoa_r(source_addr.sin6_addr, addr_str, sizeof(addr_str) - 1);
|
||||
}
|
||||
|
||||
// Null-terminate whatever we received and treat like a string...
|
||||
rx_buffer[len] = 0;
|
||||
|
||||
char reply[DNS_MAX_LEN];
|
||||
int reply_len = parse_dns_request(rx_buffer, len, reply, DNS_MAX_LEN, handle);
|
||||
|
||||
ESP_LOGI(TAG, "Received %d bytes from %s | DNS reply with len: %d", len, addr_str, reply_len);
|
||||
if (reply_len <= 0) {
|
||||
ESP_LOGE(TAG, "Failed to prepare a DNS reply");
|
||||
} else {
|
||||
int err = sendto(sock, reply, reply_len, 0, (struct sockaddr *)&source_addr, sizeof(source_addr));
|
||||
if (err < 0) {
|
||||
ESP_LOGE(TAG, "Error occurred during sending: errno %d", errno);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sock != -1) {
|
||||
ESP_LOGE(TAG, "Shutting down socket");
|
||||
shutdown(sock, 0);
|
||||
close(sock);
|
||||
}
|
||||
}
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
dns_server_handle_t start_dns_server(dns_server_config_t *config)
|
||||
{
|
||||
dns_server_handle_t handle = calloc(1, sizeof(struct dns_server_handle) + config->num_of_entries * sizeof(dns_entry_pair_t));
|
||||
ESP_RETURN_ON_FALSE(handle, NULL, TAG, "Failed to allocate dns server handle");
|
||||
|
||||
handle->started = true;
|
||||
handle->num_of_entries = config->num_of_entries;
|
||||
memcpy(handle->entry, config->item, config->num_of_entries * sizeof(dns_entry_pair_t));
|
||||
|
||||
xTaskCreate(dns_server_task, "dns_server", 4096, handle, 5, &handle->task);
|
||||
return handle;
|
||||
}
|
||||
|
||||
void stop_dns_server(dns_server_handle_t handle)
|
||||
{
|
||||
if (handle) {
|
||||
handle->started = false;
|
||||
vTaskDelete(handle->task);
|
||||
free(handle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021-2023 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Unlicense OR CC0-1.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_netif.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#ifndef DNS_SERVER_MAX_ITEMS
|
||||
#define DNS_SERVER_MAX_ITEMS 1
|
||||
#endif
|
||||
|
||||
#define DNS_SERVER_CONFIG_SINGLE(queried_name, netif_key) { \
|
||||
.num_of_entries = 1, \
|
||||
.item = { { .name = queried_name, .if_key = netif_key } } \
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Definition of one DNS entry: NAME - IP (or the netif whose IP to answer)
|
||||
*
|
||||
* @note Please use string literals (or ensure they are valid during dns_server lifetime) as names, since
|
||||
* we don't take copies of the config values `name` and `if_key`
|
||||
*/
|
||||
typedef struct dns_entry_pair {
|
||||
const char* name; /**<! Exact match of the name field of the DNS query to answer */
|
||||
const char* if_key; /**<! Use this network interface IP to answer, only if NULL, use the static IP below */
|
||||
esp_ip4_addr_t ip; /**<! Constant IP address to answer this query, if "if_key==NULL" */
|
||||
} dns_entry_pair_t;
|
||||
|
||||
/**
|
||||
* @brief DNS server config struct defining the rules for answering DNS (A type) queries
|
||||
*
|
||||
* @note If you want to define more rules, you can set `DNS_SERVER_MAX_ITEMS` before including this header
|
||||
* Example of using 2 entries with constant IP addresses
|
||||
* \code{.c}
|
||||
* #define DNS_SERVER_MAX_ITEMS 2
|
||||
* #include "dns_server.h"
|
||||
*
|
||||
* dns_server_config_t config = {
|
||||
* .num_of_entries = 2,
|
||||
* .item = { {.name = "my-esp32.com", .ip = { .addr = ESP_IP4TOADDR( 192, 168, 4, 1) } } ,
|
||||
* {.name = "my-utils.com", .ip = { .addr = ESP_IP4TOADDR( 192, 168, 4, 100) } } } };
|
||||
* start_dns_server(&config);
|
||||
* \endcode
|
||||
*/
|
||||
typedef struct dns_server_config {
|
||||
int num_of_entries; /**<! Number of rules specified in the config struct */
|
||||
dns_entry_pair_t item[DNS_SERVER_MAX_ITEMS]; /**<! Array of pairs */
|
||||
} dns_server_config_t;
|
||||
|
||||
/**
|
||||
* @brief DNS server handle
|
||||
*/
|
||||
typedef struct dns_server_handle *dns_server_handle_t;
|
||||
|
||||
/**
|
||||
* @brief Set ups and starts a simple DNS server that will respond to all A queries (IPv4)
|
||||
* based on configured rules, pairs of name and either IPv4 address or a netif ID (to respond by it's IPv4 add)
|
||||
*
|
||||
* @param config Configuration structure listing the pairs of (name, IP/netif-id)
|
||||
* @return dns_server's handle on success, NULL on failure
|
||||
*/
|
||||
dns_server_handle_t start_dns_server(dns_server_config_t *config);
|
||||
|
||||
/**
|
||||
* @brief Stops and destroys DNS server's task and structs
|
||||
* @param handle DNS server's handle to destroy
|
||||
*/
|
||||
void stop_dns_server(dns_server_handle_t handle);
|
||||
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,451 @@
|
||||
#include <inttypes.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "ota_update.h"
|
||||
#include "esp32_app_base.h"
|
||||
#include "portal_self_test.h"
|
||||
|
||||
#include "time_sync.h"
|
||||
#include "web_provisioning.h"
|
||||
#include "wifi_store.h"
|
||||
|
||||
#define WIFI_CONNECTED_BIT BIT0
|
||||
|
||||
#define RECONNECT_MAX_DELAY_SECONDS 30
|
||||
#define RECOVERY_PORTAL_AFTER_FAILURES 5
|
||||
#define PORTAL_TIMEOUT_SECONDS 600
|
||||
#define PORTAL_SUCCESS_DISPLAY_SECONDS 5
|
||||
#if CONFIG_IDF_TARGET_ESP32C3
|
||||
#define RESET_BUTTON_GPIO GPIO_NUM_9
|
||||
#else
|
||||
#define RESET_BUTTON_GPIO GPIO_NUM_0
|
||||
#endif
|
||||
#define RESET_BUTTON_HOLD_MS 5000
|
||||
|
||||
static const char *TAG = "esp32_app_base";
|
||||
static EventGroupHandle_t s_wifi_events;
|
||||
static SemaphoreHandle_t s_credentials_mutex;
|
||||
static esp_timer_handle_t s_reconnect_timer;
|
||||
static bool s_wifi_started;
|
||||
static bool s_portal_mode;
|
||||
static bool s_recovery_requested;
|
||||
static unsigned int s_reconnect_attempt;
|
||||
static char s_service_name[16];
|
||||
static wifi_store_credentials_t s_saved_credentials;
|
||||
static bool s_saved_credentials_found;
|
||||
static wifi_store_credentials_t s_pending_credentials;
|
||||
static int s_reset_gpio = RESET_BUTTON_GPIO;
|
||||
static bool s_reset_active_low = true;
|
||||
static uint32_t s_reset_hold_ms = RESET_BUTTON_HOLD_MS;
|
||||
|
||||
static void start_browser_portal(void);
|
||||
|
||||
static void clear_pending_credentials(void)
|
||||
{
|
||||
xSemaphoreTake(s_credentials_mutex, portMAX_DELAY);
|
||||
memset(&s_pending_credentials, 0, sizeof(s_pending_credentials));
|
||||
xSemaphoreGive(s_credentials_mutex);
|
||||
}
|
||||
|
||||
static void reconnect_timer_callback(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
if (!s_portal_mode) {
|
||||
esp_err_t err = esp_wifi_connect();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Wi-Fi reconnect request failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void recovery_portal_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
start_browser_portal();
|
||||
s_recovery_requested = false;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static void schedule_reconnect(void)
|
||||
{
|
||||
if (s_reconnect_attempt >= RECOVERY_PORTAL_AFTER_FAILURES && !s_recovery_requested) {
|
||||
s_recovery_requested = true;
|
||||
ESP_LOGW(TAG, "Repeated Wi-Fi failures; starting browser recovery portal");
|
||||
if (xTaskCreate(recovery_portal_task, "recovery_portal", 6144, NULL, 5, NULL) != pdPASS) {
|
||||
s_recovery_requested = false;
|
||||
ESP_LOGE(TAG, "Could not create recovery portal task");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t delay_seconds = 1U << (s_reconnect_attempt < 5U ? s_reconnect_attempt : 5U);
|
||||
if (delay_seconds > RECONNECT_MAX_DELAY_SECONDS) {
|
||||
delay_seconds = RECONNECT_MAX_DELAY_SECONDS;
|
||||
}
|
||||
s_reconnect_attempt++;
|
||||
|
||||
esp_err_t err = esp_timer_stop(s_reconnect_timer);
|
||||
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGW(TAG, "Could not stop reconnect timer: %s", esp_err_to_name(err));
|
||||
}
|
||||
ESP_LOGW(TAG, "Wi-Fi disconnected; reconnecting in %" PRIu32 " seconds", delay_seconds);
|
||||
ESP_ERROR_CHECK(esp_timer_start_once(s_reconnect_timer, delay_seconds * 1000000ULL));
|
||||
}
|
||||
|
||||
static const char *disconnect_message(uint8_t reason)
|
||||
{
|
||||
switch (reason) {
|
||||
case WIFI_REASON_AUTH_FAIL:
|
||||
case WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT:
|
||||
case WIFI_REASON_HANDSHAKE_TIMEOUT:
|
||||
return "Wi-Fi 인증에 실패했습니다. 비밀번호를 확인해 주세요.";
|
||||
case WIFI_REASON_NO_AP_FOUND:
|
||||
return "선택한 Wi-Fi를 찾지 못했습니다.";
|
||||
default:
|
||||
return "Wi-Fi에 연결하지 못했습니다. 다시 시도해 주세요.";
|
||||
}
|
||||
}
|
||||
|
||||
static void portal_shutdown_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
vTaskDelay(pdMS_TO_TICKS(PORTAL_SUCCESS_DISPLAY_SECONDS * 1000U));
|
||||
web_provisioning_stop();
|
||||
s_portal_mode = false;
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_LOGI(TAG, "Provisioning SoftAP stopped; station mode remains active");
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
int32_t event_id, void *event_data)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
if (event_base == WIFI_EVENT) {
|
||||
if (event_id == WIFI_EVENT_STA_START && !s_portal_mode) {
|
||||
ESP_ERROR_CHECK(esp_wifi_connect());
|
||||
} else if (event_id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||
xEventGroupClearBits(s_wifi_events, WIFI_CONNECTED_BIT);
|
||||
const wifi_event_sta_disconnected_t *event = event_data;
|
||||
if (web_provisioning_is_connecting()) {
|
||||
ESP_LOGW(TAG, "Browser-provided Wi-Fi connection failed, reason=%u", event->reason);
|
||||
clear_pending_credentials();
|
||||
web_provisioning_report_failure(disconnect_message(event->reason));
|
||||
} else if (!s_portal_mode && s_saved_credentials_found) {
|
||||
schedule_reconnect();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
||||
const ip_event_got_ip_t *event = event_data;
|
||||
s_reconnect_attempt = 0;
|
||||
esp_err_t err = esp_timer_stop(s_reconnect_timer);
|
||||
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGW(TAG, "Could not stop reconnect timer: %s", esp_err_to_name(err));
|
||||
}
|
||||
ESP_LOGI(TAG, "Wi-Fi connected, IPv4 address: " IPSTR, IP2STR(&event->ip_info.ip));
|
||||
|
||||
if (web_provisioning_is_connecting()) {
|
||||
xSemaphoreTake(s_credentials_mutex, portMAX_DELAY);
|
||||
wifi_store_credentials_t credentials = s_pending_credentials;
|
||||
memset(&s_pending_credentials, 0, sizeof(s_pending_credentials));
|
||||
xSemaphoreGive(s_credentials_mutex);
|
||||
|
||||
err = wifi_store_save(&credentials);
|
||||
if (err != ESP_OK) {
|
||||
memset(&credentials, 0, sizeof(credentials));
|
||||
ESP_LOGE(TAG, "Could not persist verified Wi-Fi settings: %s", esp_err_to_name(err));
|
||||
web_provisioning_report_failure("설정을 저장하지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
s_saved_credentials = credentials;
|
||||
s_saved_credentials_found = true;
|
||||
memset(&credentials, 0, sizeof(credentials));
|
||||
|
||||
char ip_address[16];
|
||||
snprintf(ip_address, sizeof(ip_address), IPSTR, IP2STR(&event->ip_info.ip));
|
||||
web_provisioning_report_connected(ip_address);
|
||||
ESP_LOGI(TAG, "Verified Wi-Fi settings stored in NVS");
|
||||
if (xTaskCreate(portal_shutdown_task, "portal_shutdown", 4096, NULL, 5, NULL) != pdPASS) {
|
||||
ESP_LOGE(TAG, "Could not create portal shutdown task");
|
||||
}
|
||||
}
|
||||
xEventGroupSetBits(s_wifi_events, WIFI_CONNECTED_BIT);
|
||||
}
|
||||
}
|
||||
|
||||
static void get_service_name(void)
|
||||
{
|
||||
uint8_t mac[6];
|
||||
ESP_ERROR_CHECK(esp_read_mac(mac, ESP_MAC_WIFI_STA));
|
||||
snprintf(s_service_name, sizeof(s_service_name), "APP_%02X%02X%02X",
|
||||
mac[3], mac[4], mac[5]);
|
||||
}
|
||||
|
||||
static void init_nvs(void)
|
||||
{
|
||||
esp_err_t err = nvs_flash_init();
|
||||
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
err = nvs_flash_init();
|
||||
}
|
||||
ESP_ERROR_CHECK(err);
|
||||
}
|
||||
|
||||
static void init_network_stack(void)
|
||||
{
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
|
||||
s_wifi_events = xEventGroupCreate();
|
||||
s_credentials_mutex = xSemaphoreCreateMutex();
|
||||
ESP_ERROR_CHECK(s_wifi_events == NULL || s_credentials_mutex == NULL ? ESP_ERR_NO_MEM : ESP_OK);
|
||||
|
||||
const esp_timer_create_args_t timer_args = {
|
||||
.callback = reconnect_timer_callback,
|
||||
.name = "wifi_reconnect",
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_timer_create(&timer_args, &s_reconnect_timer));
|
||||
|
||||
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID,
|
||||
event_handler, NULL));
|
||||
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP,
|
||||
event_handler, NULL));
|
||||
|
||||
ESP_ERROR_CHECK(esp_netif_create_default_wifi_sta() == NULL ? ESP_FAIL : ESP_OK);
|
||||
ESP_ERROR_CHECK(esp_netif_create_default_wifi_ap() == NULL ? ESP_FAIL : ESP_OK);
|
||||
|
||||
wifi_init_config_t wifi_config = WIFI_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&wifi_config));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));
|
||||
}
|
||||
|
||||
static esp_err_t make_station_config(const wifi_store_credentials_t *credentials,
|
||||
wifi_config_t *config)
|
||||
{
|
||||
if (credentials == NULL || config == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
size_t ssid_len = strnlen(credentials->ssid, sizeof(credentials->ssid));
|
||||
size_t password_len = strnlen(credentials->password, sizeof(credentials->password));
|
||||
if (ssid_len == 0 || ssid_len > sizeof(config->sta.ssid) ||
|
||||
password_len > sizeof(config->sta.password)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
memcpy(config->sta.ssid, credentials->ssid, ssid_len);
|
||||
memcpy(config->sta.password, credentials->password, password_len);
|
||||
config->sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
|
||||
config->sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
|
||||
config->sta.threshold.authmode = WIFI_AUTH_OPEN;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t verify_station_config_boundaries(void)
|
||||
{
|
||||
wifi_store_credentials_t credentials = {0};
|
||||
memset(credentials.ssid, 'S', WIFI_STORE_SSID_MAX_LEN);
|
||||
memset(credentials.password, 'a', WIFI_STORE_PASSWORD_MAX_LEN);
|
||||
|
||||
wifi_config_t config;
|
||||
esp_err_t err = make_station_config(&credentials, &config);
|
||||
if (err != ESP_OK ||
|
||||
memcmp(config.sta.ssid, credentials.ssid, sizeof(config.sta.ssid)) != 0 ||
|
||||
memcmp(config.sta.password, credentials.password, sizeof(config.sta.password)) != 0) {
|
||||
memset(&credentials, 0, sizeof(credentials));
|
||||
memset(&config, 0, sizeof(config));
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
memset(&credentials, 0, sizeof(credentials));
|
||||
memset(&config, 0, sizeof(config));
|
||||
ESP_LOGI(TAG, "32-byte SSID and 64-byte PSK boundary self-test passed");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t apply_browser_credentials(const wifi_store_credentials_t *credentials)
|
||||
{
|
||||
if (credentials == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_credentials_mutex, portMAX_DELAY);
|
||||
s_pending_credentials = *credentials;
|
||||
xSemaphoreGive(s_credentials_mutex);
|
||||
|
||||
wifi_config_t station_config;
|
||||
esp_err_t err = make_station_config(credentials, &station_config);
|
||||
if (err == ESP_OK) {
|
||||
err = esp_wifi_set_config(WIFI_IF_STA, &station_config);
|
||||
}
|
||||
memset(&station_config, 0, sizeof(station_config));
|
||||
if (err == ESP_OK) {
|
||||
err = esp_wifi_connect();
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
clear_pending_credentials();
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
static void portal_expired(void)
|
||||
{
|
||||
web_provisioning_stop();
|
||||
s_portal_mode = false;
|
||||
if (s_saved_credentials_found) {
|
||||
ESP_LOGI(TAG, "Restoring station connection after portal timeout");
|
||||
wifi_config_t station_config;
|
||||
ESP_ERROR_CHECK(make_station_config(&s_saved_credentials, &station_config));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &station_config));
|
||||
memset(&station_config, 0, sizeof(station_config));
|
||||
ESP_ERROR_CHECK(esp_wifi_connect());
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No Wi-Fi settings; SoftAP disabled until reboot or BOOT long-press");
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_NULL));
|
||||
}
|
||||
}
|
||||
|
||||
static void start_browser_portal(void)
|
||||
{
|
||||
get_service_name();
|
||||
s_portal_mode = true;
|
||||
|
||||
wifi_config_t ap_config = {0};
|
||||
strlcpy((char *)ap_config.ap.ssid, s_service_name, sizeof(ap_config.ap.ssid));
|
||||
ap_config.ap.ssid_len = strlen(s_service_name);
|
||||
ap_config.ap.channel = 1;
|
||||
ap_config.ap.max_connection = 1;
|
||||
ap_config.ap.authmode = WIFI_AUTH_OPEN;
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_config));
|
||||
if (!s_wifi_started) {
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
s_wifi_started = true;
|
||||
}
|
||||
|
||||
const web_provisioning_config_t portal_config = {
|
||||
.service_name = s_service_name,
|
||||
.timeout_seconds = PORTAL_TIMEOUT_SECONDS,
|
||||
.apply_credentials = apply_browser_credentials,
|
||||
.expired = portal_expired,
|
||||
};
|
||||
ESP_ERROR_CHECK(web_provisioning_start(&portal_config));
|
||||
ESP_ERROR_CHECK(portal_self_test_start());
|
||||
|
||||
wifi_config_t running_ap = {0};
|
||||
ESP_ERROR_CHECK(esp_wifi_get_config(WIFI_IF_AP, &running_ap));
|
||||
ESP_ERROR_CHECK(running_ap.ap.authmode == WIFI_AUTH_OPEN ? ESP_OK : ESP_FAIL);
|
||||
ESP_LOGW(TAG, "Browser Wi-Fi setup required");
|
||||
ESP_LOGW(TAG, "Provisioning SoftAP SSID: %s", s_service_name);
|
||||
ESP_LOGW(TAG, "Provisioning SoftAP security: open (no Wi-Fi password)");
|
||||
ESP_LOGW(TAG, "Open http://192.168.4.1 if the setup page does not appear automatically");
|
||||
}
|
||||
|
||||
static void start_wifi_station(void)
|
||||
{
|
||||
wifi_config_t station_config;
|
||||
ESP_ERROR_CHECK(make_station_config(&s_saved_credentials, &station_config));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &station_config));
|
||||
memset(&station_config, 0, sizeof(station_config));
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
s_wifi_started = true;
|
||||
}
|
||||
|
||||
static void reset_button_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
const gpio_config_t config = {
|
||||
.pin_bit_mask = 1ULL << RESET_BUTTON_GPIO,
|
||||
.mode = GPIO_MODE_INPUT,
|
||||
.pull_up_en = GPIO_PULLUP_DISABLE,
|
||||
.pull_down_en = GPIO_PULLDOWN_DISABLE,
|
||||
.intr_type = GPIO_INTR_DISABLE,
|
||||
};
|
||||
ESP_ERROR_CHECK(gpio_config(&config));
|
||||
|
||||
uint32_t held_ms = 0;
|
||||
while (true) {
|
||||
bool pressed = gpio_get_level(s_reset_gpio) == (s_reset_active_low ? 0 : 1);
|
||||
if (pressed) {
|
||||
held_ms += 100;
|
||||
if (held_ms >= s_reset_hold_ms) {
|
||||
ESP_LOGW(TAG, "BOOT long-press detected; erasing Wi-Fi settings and restarting");
|
||||
ESP_ERROR_CHECK(wifi_store_erase());
|
||||
vTaskDelay(pdMS_TO_TICKS(200));
|
||||
esp_restart();
|
||||
}
|
||||
} else {
|
||||
held_ms = 0;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
}
|
||||
}
|
||||
|
||||
EventGroupHandle_t esp32_app_base_network_events(void) { return s_wifi_events; }
|
||||
EventBits_t esp32_app_base_network_ready_bit(void) { return WIFI_CONNECTED_BIT; }
|
||||
|
||||
esp_err_t esp32_app_base_start(const esp32_app_base_config_t *config)
|
||||
{
|
||||
if (config != NULL) {
|
||||
s_reset_gpio = config->reset_gpio >= 0 ? config->reset_gpio : RESET_BUTTON_GPIO;
|
||||
s_reset_active_low = config->reset_active_low;
|
||||
s_reset_hold_ms = config->reset_hold_ms != 0 ? config->reset_hold_ms : RESET_BUTTON_HOLD_MS;
|
||||
}
|
||||
init_nvs();
|
||||
init_network_stack();
|
||||
ESP_ERROR_CHECK(verify_station_config_boundaries());
|
||||
ESP_ERROR_CHECK(wifi_store_load(&s_saved_credentials, &s_saved_credentials_found));
|
||||
ESP_ERROR_CHECK(xTaskCreate(reset_button_task, "wifi_reset_button", 3072, NULL, 4, NULL) == pdPASS
|
||||
? ESP_OK : ESP_ERR_NO_MEM);
|
||||
|
||||
if (s_saved_credentials_found) {
|
||||
ESP_LOGI(TAG, "Stored Wi-Fi settings found; starting station mode");
|
||||
start_wifi_station();
|
||||
} else {
|
||||
ESP_LOGI(TAG, "No stored Wi-Fi settings; starting browser provisioning");
|
||||
start_browser_portal();
|
||||
}
|
||||
|
||||
xEventGroupWaitBits(s_wifi_events, WIFI_CONNECTED_BIT, pdFALSE, pdTRUE, portMAX_DELAY);
|
||||
ESP_LOGI(TAG, "network is ready (service=%s reset_gpio=%d hold_ms=%lu)",
|
||||
config != NULL && config->service_prefix != NULL ? config->service_prefix : "default",
|
||||
config != NULL ? config->reset_gpio : RESET_BUTTON_GPIO,
|
||||
(unsigned long)(config != NULL ? config->reset_hold_ms : RESET_BUTTON_HOLD_MS));
|
||||
ESP_ERROR_CHECK(ota_mark_running_app_valid());
|
||||
ESP_ERROR_CHECK(time_sync_start());
|
||||
(void)time_sync_wait(pdMS_TO_TICKS((config && config->ntp_initial_wait_seconds ? config->ntp_initial_wait_seconds : 30) * 1000U));
|
||||
if (config != NULL && config->product_start != NULL) {
|
||||
ESP_ERROR_CHECK(config->product_start());
|
||||
}
|
||||
if (config == NULL || config->health_check == NULL || config->health_check() == ESP_OK) {
|
||||
ESP_ERROR_CHECK(ota_start_task(s_wifi_events, WIFI_CONNECTED_BIT));
|
||||
}
|
||||
|
||||
while (true) {
|
||||
vTaskDelay(pdMS_TO_TICKS(30000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#include "mqtt_transport.h"
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
#include "esp_crt_bundle.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "mqtt_client.h"
|
||||
|
||||
static const char *TAG = "mqtt_transport";
|
||||
static esp_mqtt_client_handle_t s_client;
|
||||
static mqtt_transport_config_t s_config;
|
||||
static volatile bool s_connected;
|
||||
|
||||
static void handler(void *args, esp_event_base_t base, int32_t id, void *data)
|
||||
{
|
||||
(void)args; (void)base;
|
||||
esp_mqtt_event_handle_t e = data;
|
||||
switch ((esp_mqtt_event_id_t)id) {
|
||||
case MQTT_EVENT_CONNECTED:
|
||||
s_connected = true;
|
||||
if (s_config.on_connected) s_config.on_connected(s_config.ctx);
|
||||
break;
|
||||
case MQTT_EVENT_DISCONNECTED: s_connected = false; break;
|
||||
case MQTT_EVENT_DATA:
|
||||
if (s_config.on_message) s_config.on_message(e->topic, e->topic_len, e->data, e->data_len, s_config.ctx);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
static void heartbeat_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
while (true) {
|
||||
vTaskDelay(pdMS_TO_TICKS((s_config.heartbeat_interval_seconds ? s_config.heartbeat_interval_seconds : 60) * 1000U));
|
||||
if (s_connected && s_config.on_heartbeat) s_config.on_heartbeat(s_config.ctx);
|
||||
}
|
||||
}
|
||||
int mqtt_transport_publish(const char *topic, const char *payload, int qos, bool retain)
|
||||
{ return s_connected ? esp_mqtt_client_publish(s_client, topic, payload, 0, qos, retain ? 1 : 0) : -1; }
|
||||
int mqtt_transport_subscribe(const char *topic, int qos)
|
||||
{ return s_connected ? esp_mqtt_client_subscribe(s_client, topic, qos) : -1; }
|
||||
esp_err_t mqtt_transport_start(const mqtt_transport_config_t *config)
|
||||
{
|
||||
if (!config || !config->broker_uri || !config->broker_uri[0] || s_client) return ESP_OK;
|
||||
s_config = *config;
|
||||
bool tls = strncmp(config->broker_uri, "mqtts://", 8) == 0 || strncmp(config->broker_uri, "wss://", 6) == 0;
|
||||
esp_mqtt_client_config_t mc = {
|
||||
.broker.address.uri = config->broker_uri,
|
||||
.broker.verification.crt_bundle_attach = tls ? esp_crt_bundle_attach : NULL,
|
||||
.credentials.client_id = config->client_id,
|
||||
.session.last_will.topic = config->last_will_topic,
|
||||
.session.last_will.msg = config->last_will_payload,
|
||||
.session.last_will.qos = 1, .session.last_will.retain = 1,
|
||||
.network.reconnect_timeout_ms = 5000,
|
||||
};
|
||||
s_client = esp_mqtt_client_init(&mc);
|
||||
if (!s_client) return ESP_ERR_NO_MEM;
|
||||
esp_err_t err = esp_mqtt_client_register_event(s_client, ESP_EVENT_ANY_ID, handler, NULL);
|
||||
if (err == ESP_OK) err = esp_mqtt_client_start(s_client);
|
||||
if (err != ESP_OK) { esp_mqtt_client_destroy(s_client); s_client = NULL; return err; }
|
||||
if (xTaskCreate(heartbeat_task, "mqtt_heartbeat", 4096, NULL, 4, NULL) != pdPASS) return ESP_ERR_NO_MEM;
|
||||
ESP_LOGI(TAG, "generic MQTT transport started");
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
#include "ota_update.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_check.h"
|
||||
#include "esp_crt_bundle.h"
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_https_ota.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_system.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "ota_version.h"
|
||||
|
||||
#define OTA_TASK_STACK_SIZE 8192
|
||||
#define OTA_TASK_PRIORITY 5
|
||||
#define OTA_PROGRESS_LOG_STEP (128 * 1024)
|
||||
|
||||
static const char *TAG = "sese_ota";
|
||||
static EventGroupHandle_t s_network_events;
|
||||
static EventBits_t s_connected_bit;
|
||||
static TaskHandle_t s_ota_task_handle;
|
||||
|
||||
typedef enum {
|
||||
OTA_CHECK_NO_UPDATE,
|
||||
OTA_CHECK_FAILED,
|
||||
OTA_CHECK_INSTALLED,
|
||||
} ota_check_result_t;
|
||||
|
||||
|
||||
static esp_err_t validate_candidate(const esp_app_desc_t *candidate)
|
||||
{
|
||||
const esp_partition_t *running_partition = esp_ota_get_running_partition();
|
||||
esp_app_desc_t running;
|
||||
ESP_RETURN_ON_ERROR(esp_ota_get_partition_description(running_partition, &running),
|
||||
TAG, "Could not read running image descriptor");
|
||||
|
||||
if (strncmp(candidate->project_name, running.project_name,
|
||||
sizeof(candidate->project_name)) != 0) {
|
||||
ESP_LOGE(TAG, "Rejecting image for project '%s'; expected '%s'",
|
||||
candidate->project_name, running.project_name);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
int version_order = 0;
|
||||
if (!ota_version_compare(candidate->version, running.version, &version_order)) {
|
||||
ESP_LOGE(TAG, "OTA versions must use major.minor.patch: running='%s', candidate='%s'",
|
||||
running.version, candidate->version);
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
if (version_order <= 0) {
|
||||
ESP_LOGI(TAG, "No update: running=%s, candidate=%s", running.version, candidate->version);
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "New firmware accepted: %s -> %s", running.version, candidate->version);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static ota_check_result_t perform_ota_check(void)
|
||||
{
|
||||
esp_http_client_config_t http_config = {
|
||||
.url = CONFIG_SESE_OTA_URL,
|
||||
.crt_bundle_attach = esp_crt_bundle_attach,
|
||||
.timeout_ms = 15000,
|
||||
.keep_alive_enable = true,
|
||||
};
|
||||
esp_https_ota_config_t ota_config = {
|
||||
.http_config = &http_config,
|
||||
};
|
||||
|
||||
ESP_LOGI(TAG, "Checking HTTPS OTA endpoint");
|
||||
esp_https_ota_handle_t handle = NULL;
|
||||
esp_err_t err = esp_https_ota_begin(&ota_config, &handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "OTA connection failed: %s", esp_err_to_name(err));
|
||||
return OTA_CHECK_FAILED;
|
||||
}
|
||||
|
||||
esp_app_desc_t candidate = {0};
|
||||
err = esp_https_ota_get_img_desc(handle, &candidate);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Could not read OTA image descriptor: %s", esp_err_to_name(err));
|
||||
esp_https_ota_abort(handle);
|
||||
return OTA_CHECK_FAILED;
|
||||
}
|
||||
|
||||
err = validate_candidate(&candidate);
|
||||
if (err == ESP_ERR_INVALID_VERSION) {
|
||||
esp_https_ota_abort(handle);
|
||||
return OTA_CHECK_NO_UPDATE;
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
esp_https_ota_abort(handle);
|
||||
return OTA_CHECK_FAILED;
|
||||
}
|
||||
|
||||
int next_progress_log = OTA_PROGRESS_LOG_STEP;
|
||||
do {
|
||||
err = esp_https_ota_perform(handle);
|
||||
int bytes_read = esp_https_ota_get_image_len_read(handle);
|
||||
if (bytes_read >= next_progress_log) {
|
||||
ESP_LOGI(TAG, "OTA download progress: %d bytes", bytes_read);
|
||||
next_progress_log += OTA_PROGRESS_LOG_STEP;
|
||||
}
|
||||
} while (err == ESP_ERR_HTTPS_OTA_IN_PROGRESS);
|
||||
|
||||
if (err != ESP_OK || !esp_https_ota_is_complete_data_received(handle)) {
|
||||
ESP_LOGE(TAG, "OTA download incomplete: %s", esp_err_to_name(err));
|
||||
esp_https_ota_abort(handle);
|
||||
return OTA_CHECK_FAILED;
|
||||
}
|
||||
|
||||
err = esp_https_ota_finish(handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "OTA image validation/activation failed: %s", esp_err_to_name(err));
|
||||
return OTA_CHECK_FAILED;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "OTA update installed; rebooting");
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
esp_restart();
|
||||
return OTA_CHECK_INSTALLED;
|
||||
}
|
||||
|
||||
static void ota_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
vTaskDelay(pdMS_TO_TICKS(CONFIG_SESE_OTA_INITIAL_DELAY_SECONDS * 1000));
|
||||
|
||||
while (true) {
|
||||
xEventGroupWaitBits(s_network_events, s_connected_bit, pdFALSE, pdTRUE, portMAX_DELAY);
|
||||
ota_check_result_t result = perform_ota_check();
|
||||
uint32_t delay_minutes = result == OTA_CHECK_FAILED
|
||||
? CONFIG_SESE_OTA_RETRY_INTERVAL_MINUTES
|
||||
: CONFIG_SESE_OTA_CHECK_INTERVAL_MINUTES;
|
||||
vTaskDelay(pdMS_TO_TICKS(delay_minutes * 60U * 1000U));
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t ota_mark_running_app_valid(void)
|
||||
{
|
||||
#if CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
esp_ota_img_states_t state;
|
||||
esp_err_t err = esp_ota_get_state_partition(running, &state);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
if (state == ESP_OTA_IMG_PENDING_VERIFY) {
|
||||
ESP_LOGI(TAG, "Network diagnostic passed; confirming OTA image");
|
||||
return esp_ota_mark_app_valid_cancel_rollback();
|
||||
}
|
||||
#endif
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t ota_start_task(EventGroupHandle_t network_events, EventBits_t connected_bit)
|
||||
{
|
||||
#if !CONFIG_SESE_OTA_ENABLE
|
||||
ESP_LOGI(TAG, "Automatic OTA is disabled by configuration");
|
||||
return ESP_OK;
|
||||
#else
|
||||
if (network_events == NULL || connected_bit == 0) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (CONFIG_SESE_OTA_URL[0] == '\0') {
|
||||
ESP_LOGW(TAG, "Automatic OTA is enabled but SESE_OTA_URL is empty; checks are disabled");
|
||||
return ESP_OK;
|
||||
}
|
||||
if (strncmp(CONFIG_SESE_OTA_URL, "https://", strlen("https://")) != 0) {
|
||||
ESP_LOGE(TAG, "SESE_OTA_URL must use HTTPS");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_ota_task_handle != NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
s_network_events = network_events;
|
||||
s_connected_bit = connected_bit;
|
||||
BaseType_t created = xTaskCreate(ota_task, "https_ota", OTA_TASK_STACK_SIZE,
|
||||
NULL, OTA_TASK_PRIORITY, &s_ota_task_handle);
|
||||
return created == pdPASS ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#include "ota_version.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
static bool parse_version(const char *version, uint32_t parsed[3])
|
||||
{
|
||||
char trailing;
|
||||
return sscanf(version, "%u.%u.%u%c", &parsed[0], &parsed[1], &parsed[2], &trailing) == 3;
|
||||
}
|
||||
|
||||
bool ota_version_compare(const char *left, const char *right, int *order)
|
||||
{
|
||||
if (!left || !right || !order) return false;
|
||||
uint32_t a[3], b[3];
|
||||
if (!parse_version(left, a) || !parse_version(right, b)) return false;
|
||||
*order = 0;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
if (a[i] > b[i]) { *order = 1; return true; }
|
||||
if (a[i] < b[i]) { *order = -1; return true; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#include "portal_self_test.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
static const char *TAG = "portal_self_test";
|
||||
|
||||
static esp_err_t check_endpoint(const char *url, const char *sentinel)
|
||||
{
|
||||
esp_http_client_config_t config = {
|
||||
.url = url,
|
||||
.timeout_ms = 3000,
|
||||
.disable_auto_redirect = true,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||
if (client == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_http_client_open(client, 0);
|
||||
if (err != ESP_OK) {
|
||||
esp_http_client_cleanup(client);
|
||||
return err;
|
||||
}
|
||||
int64_t content_length = esp_http_client_fetch_headers(client);
|
||||
if (content_length < 0 || esp_http_client_get_status_code(client) != 200) {
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
char response[512] = {0};
|
||||
int read = esp_http_client_read_response(client, response, sizeof(response) - 1);
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
if (read <= 0 || strstr(response, sentinel) == NULL) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void self_test_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
|
||||
esp_err_t root_result = check_endpoint("http://192.168.4.1/", "hidden-ssid");
|
||||
esp_err_t status_result = check_endpoint("http://192.168.4.1/api/v1/status", "\"state\":\"idle\"");
|
||||
esp_err_t networks_result = check_endpoint("http://192.168.4.1/api/v1/networks", "\"networks\"");
|
||||
if (root_result == ESP_OK && status_result == ESP_OK && networks_result == ESP_OK) {
|
||||
ESP_LOGI(TAG, "Captive portal HTTP and Wi-Fi scan API self-test passed");
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Captive portal self-test failed: root=%s status=%s networks=%s",
|
||||
esp_err_to_name(root_result), esp_err_to_name(status_result),
|
||||
esp_err_to_name(networks_result));
|
||||
}
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
esp_err_t portal_self_test_start(void)
|
||||
{
|
||||
return xTaskCreate(self_test_task, "portal_self_test", 4096, NULL, 3, NULL) == pdPASS
|
||||
? ESP_OK : ESP_ERR_NO_MEM;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="sese-features" content="hidden-ssid">
|
||||
<title>SESE Controller Wi-Fi 설정</title>
|
||||
<style>
|
||||
:root{color-scheme:light;font-family:system-ui,sans-serif}body{margin:0;background:#f3f5f7;color:#17202a}.card{max-width:480px;margin:24px auto;padding:24px;background:#fff;border-radius:16px;box-shadow:0 4px 18px #0002}h1{font-size:1.45rem;margin:0 0 8px}.muted{color:#5d6d7e;font-size:.93rem}label{display:block;margin:18px 0 6px;font-weight:650}select,input,button{box-sizing:border-box;width:100%;font-size:1rem;padding:12px;border-radius:9px;border:1px solid #aeb6bf}button{margin-top:16px;border:0;background:#0866c6;color:white;font-weight:700}button:disabled{background:#9aa5b1}.row{display:flex;gap:8px}.row select{flex:1}.row button{width:auto;margin:0}.status{margin-top:18px;padding:12px;border-radius:9px;background:#eef4fa;white-space:pre-line}.error{background:#fdecec;color:#922b21}.success{background:#e9f7ef;color:#196f3d}.hidden{display:none}</style>
|
||||
</head>
|
||||
<body><main class="card">
|
||||
<h1>SESE Controller Wi-Fi 설정</h1>
|
||||
<p id="device" class="muted">장치 정보를 확인하고 있습니다.</p>
|
||||
<label for="network">연결할 2.4GHz Wi-Fi</label>
|
||||
<div class="row"><select id="network"><option>검색 중…</option></select><button id="scan" type="button">재검색</button></div>
|
||||
<label><input id="hidden-network" type="checkbox" style="width:auto"> 숨겨진 Wi-Fi 직접 입력</label>
|
||||
<div id="hidden-fields" class="hidden">
|
||||
<label for="hidden-ssid">숨겨진 Wi-Fi 이름(SSID)</label>
|
||||
<input id="hidden-ssid" type="text" maxlength="32" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="대소문자를 정확히 입력">
|
||||
<p class="muted">검색 목록에 나타나지 않는 SSID를 정확히 입력해 주세요.</p>
|
||||
</div>
|
||||
<label for="password">Wi-Fi 비밀번호</label>
|
||||
<input id="password" type="password" maxlength="64" autocomplete="new-password">
|
||||
<label><input id="show" type="checkbox" style="width:auto"> 비밀번호 표시</label>
|
||||
<button id="connect" type="button">연결</button>
|
||||
<div id="status" class="status">Wi-Fi를 선택해 주세요.</div>
|
||||
<p class="muted">이 페이지는 인터넷이 아닌 SESE Controller에서 직접 제공됩니다.</p>
|
||||
</main>
|
||||
<script>
|
||||
let nonce='';let pollTimer=null;
|
||||
const q=id=>document.getElementById(id);
|
||||
function setStatus(text,kind=''){const e=q('status');e.textContent=text;e.className='status '+kind}
|
||||
async function json(url,opt){const r=await fetch(url,opt);const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.error||'요청에 실패했습니다.');return d}
|
||||
async function status(){try{const d=await json('/api/v1/status');nonce=d.nonce||nonce;q('device').textContent='설정 대상: '+d.device;if(d.state==='connecting'){setStatus('Wi-Fi 연결을 확인하고 있습니다…');q('connect').disabled=true}else if(d.state==='connected'){setStatus('설정이 완료되었습니다.\n이 창은 닫아도 됩니다.','success');q('connect').disabled=true;clearInterval(pollTimer)}else if(d.state==='failed'){setStatus(d.message||'연결하지 못했습니다. 다시 시도해 주세요.','error');q('connect').disabled=false}}catch(e){}}
|
||||
async function scan(){q('scan').disabled=true;setStatus('주변 Wi-Fi를 검색하고 있습니다…');try{const d=await json('/api/v1/networks');const s=q('network');s.textContent='';for(const n of d.networks){const o=document.createElement('option');o.value=n.ssid;o.textContent=n.ssid+' '+n.rssi+' dBm'+(n.secure?' 🔒':'');s.appendChild(o)}if(!d.networks.length){const o=document.createElement('option');o.value='';o.disabled=true;o.textContent='검색된 Wi-Fi가 없습니다';s.appendChild(o)}setStatus('Wi-Fi를 선택하거나 숨겨진 Wi-Fi를 직접 입력해 주세요.')}catch(e){setStatus(e.message,'error')}finally{q('scan').disabled=q('hidden-network').checked}}
|
||||
async function connect(){const manual=q('hidden-network').checked;const ssid=manual?q('hidden-ssid').value:q('network').value;const bytes=new TextEncoder().encode(ssid).length;if(!ssid){setStatus(manual?'숨겨진 Wi-Fi 이름을 입력해 주세요.':'Wi-Fi를 선택해 주세요.','error');return}if(bytes>32){setStatus('Wi-Fi 이름은 UTF-8 기준 32바이트 이하여야 합니다.','error');return}q('connect').disabled=true;setStatus('설정을 전송하고 있습니다…');try{await json('/api/v1/config',{method:'POST',headers:{'Content-Type':'application/json','X-SESE-Nonce':nonce},body:JSON.stringify({ssid,password:q('password').value})});q('password').value='';setStatus('Wi-Fi 연결을 확인하고 있습니다…')}catch(e){setStatus(e.message,'error');q('connect').disabled=false}}
|
||||
q('hidden-network').onchange=()=>{const manual=q('hidden-network').checked;q('hidden-fields').classList.toggle('hidden',!manual);q('network').disabled=manual;q('scan').disabled=manual;if(manual)q('hidden-ssid').focus()};q('show').onchange=()=>q('password').type=q('show').checked?'text':'password';q('scan').onclick=scan;q('connect').onclick=connect;
|
||||
(async()=>{await status();await scan();pollTimer=setInterval(status,1000)})();
|
||||
</script></body></html>
|
||||
@@ -0,0 +1,74 @@
|
||||
#include "time_sync.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif_sntp.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
static const char *TAG = "sese_time";
|
||||
static bool s_initialized;
|
||||
|
||||
static void log_local_time(const char *prefix)
|
||||
{
|
||||
time_t now;
|
||||
struct tm local_time;
|
||||
char timestamp[40];
|
||||
|
||||
time(&now);
|
||||
localtime_r(&now, &local_time);
|
||||
if (strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S%z", &local_time) == 0) {
|
||||
ESP_LOGW(TAG, "%s, but timestamp formatting failed", prefix);
|
||||
return;
|
||||
}
|
||||
ESP_LOGI(TAG, "%s: %s", prefix, timestamp);
|
||||
}
|
||||
|
||||
static void time_sync_notification(struct timeval *time_value)
|
||||
{
|
||||
(void)time_value;
|
||||
log_local_time("NTP time synchronized");
|
||||
}
|
||||
|
||||
esp_err_t time_sync_start(void)
|
||||
{
|
||||
if (s_initialized) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
if (setenv("TZ", CONFIG_SESE_TIMEZONE, 1) != 0) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
tzset();
|
||||
|
||||
esp_sntp_config_t config = ESP_NETIF_SNTP_DEFAULT_CONFIG(CONFIG_SESE_NTP_SERVER);
|
||||
config.sync_cb = time_sync_notification;
|
||||
esp_err_t err = esp_netif_sntp_init(&config);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
s_initialized = true;
|
||||
ESP_LOGI(TAG, "NTP started with server '%s' and timezone '%s'",
|
||||
CONFIG_SESE_NTP_SERVER, CONFIG_SESE_TIMEZONE);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t time_sync_wait(TickType_t timeout)
|
||||
{
|
||||
if (!s_initialized) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_netif_sntp_sync_wait(timeout);
|
||||
if (err == ESP_OK) {
|
||||
log_local_time("Initial system time ready");
|
||||
} else if (err == ESP_ERR_TIMEOUT) {
|
||||
ESP_LOGW(TAG, "Initial NTP synchronization timed out; periodic retries remain active");
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Initial NTP synchronization did not complete: %s", esp_err_to_name(err));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
#include "web_provisioning.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "dns_server.h"
|
||||
#include "esp_check.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_random.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "lwip/inet.h"
|
||||
|
||||
#define REQUEST_BODY_MAX 256
|
||||
#define NONCE_HEX_LENGTH 32
|
||||
#define MAX_SCAN_RESULTS 20
|
||||
|
||||
extern const char provisioning_start[] asm("_binary_provisioning_html_start");
|
||||
extern const char provisioning_end[] asm("_binary_provisioning_html_end");
|
||||
|
||||
static const char *TAG = "web_provisioning";
|
||||
static httpd_handle_t s_http_server;
|
||||
static dns_server_handle_t s_dns_server;
|
||||
static TaskHandle_t s_timeout_task;
|
||||
static web_provisioning_config_t s_config;
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static bool s_active;
|
||||
static web_provisioning_state_t s_state = WEB_PROV_IDLE;
|
||||
static char s_nonce[NONCE_HEX_LENGTH + 1];
|
||||
static char s_message[96];
|
||||
static char s_ip_address[16];
|
||||
static char s_captive_uri[32];
|
||||
|
||||
static const char *state_name(web_provisioning_state_t state)
|
||||
{
|
||||
switch (state) {
|
||||
case WEB_PROV_SCANNING: return "scanning";
|
||||
case WEB_PROV_CONNECTING: return "connecting";
|
||||
case WEB_PROV_CONNECTED: return "connected";
|
||||
case WEB_PROV_FAILED: return "failed";
|
||||
case WEB_PROV_IDLE:
|
||||
default: return "idle";
|
||||
}
|
||||
}
|
||||
|
||||
static void set_state(web_provisioning_state_t state, const char *message)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_state = state;
|
||||
strlcpy(s_message, message == NULL ? "" : message, sizeof(s_message));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static void send_json(httpd_req_t *req, cJSON *root, const char *status)
|
||||
{
|
||||
char *payload = cJSON_PrintUnformatted(root);
|
||||
httpd_resp_set_status(req, status);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
|
||||
if (payload == NULL) {
|
||||
httpd_resp_send(req, "{\"error\":\"out_of_memory\"}", HTTPD_RESP_USE_STRLEN);
|
||||
} else {
|
||||
httpd_resp_send(req, payload, HTTPD_RESP_USE_STRLEN);
|
||||
cJSON_free(payload);
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t root_get_handler(httpd_req_t *req)
|
||||
{
|
||||
httpd_resp_set_type(req, "text/html; charset=utf-8");
|
||||
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
|
||||
return httpd_resp_send(req, provisioning_start, provisioning_end - provisioning_start);
|
||||
}
|
||||
|
||||
static esp_err_t status_get_handler(httpd_req_t *req)
|
||||
{
|
||||
web_provisioning_state_t state;
|
||||
char message[sizeof(s_message)];
|
||||
char ip_address[sizeof(s_ip_address)];
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
state = s_state;
|
||||
strlcpy(message, s_message, sizeof(message));
|
||||
strlcpy(ip_address, s_ip_address, sizeof(ip_address));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
ESP_RETURN_ON_FALSE(root, ESP_ERR_NO_MEM, TAG, "Could not allocate status JSON");
|
||||
cJSON_AddStringToObject(root, "state", state_name(state));
|
||||
cJSON_AddStringToObject(root, "device", s_config.service_name);
|
||||
cJSON_AddStringToObject(root, "nonce", s_nonce);
|
||||
if (message[0] != '\0') {
|
||||
cJSON_AddStringToObject(root, "message", message);
|
||||
}
|
||||
if (ip_address[0] != '\0') {
|
||||
cJSON_AddStringToObject(root, "ip", ip_address);
|
||||
}
|
||||
send_json(req, root, "200 OK");
|
||||
cJSON_Delete(root);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int compare_ap(const void *left, const void *right)
|
||||
{
|
||||
const wifi_ap_record_t *a = left;
|
||||
const wifi_ap_record_t *b = right;
|
||||
return b->rssi - a->rssi;
|
||||
}
|
||||
|
||||
static esp_err_t networks_get_handler(httpd_req_t *req)
|
||||
{
|
||||
if (web_provisioning_is_connecting()) {
|
||||
httpd_resp_set_status(req, "409 Conflict");
|
||||
httpd_resp_send(req, "Connection attempt in progress", HTTPD_RESP_USE_STRLEN);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
set_state(WEB_PROV_SCANNING, "");
|
||||
esp_err_t err = esp_wifi_scan_start(NULL, true);
|
||||
if (err != ESP_OK) {
|
||||
set_state(WEB_PROV_FAILED, "Wi-Fi 검색을 시작하지 못했습니다.");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Wi-Fi scan failed");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
uint16_t count = 0;
|
||||
ESP_ERROR_CHECK_WITHOUT_ABORT(esp_wifi_scan_get_ap_num(&count));
|
||||
if (count > MAX_SCAN_RESULTS) {
|
||||
count = MAX_SCAN_RESULTS;
|
||||
}
|
||||
wifi_ap_record_t records[MAX_SCAN_RESULTS] = {0};
|
||||
if (count > 0) {
|
||||
err = esp_wifi_scan_get_ap_records(&count, records);
|
||||
if (err != ESP_OK) {
|
||||
set_state(WEB_PROV_FAILED, "Wi-Fi 검색 결과를 읽지 못했습니다.");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Wi-Fi scan result failed");
|
||||
return ESP_OK;
|
||||
}
|
||||
qsort(records, count, sizeof(records[0]), compare_ap);
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON *networks = cJSON_AddArrayToObject(root, "networks");
|
||||
ESP_RETURN_ON_FALSE(root && networks, ESP_ERR_NO_MEM, TAG, "Could not allocate scan JSON");
|
||||
|
||||
for (uint16_t i = 0; i < count; ++i) {
|
||||
if (records[i].ssid[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
bool duplicate = false;
|
||||
for (uint16_t j = 0; j < i; ++j) {
|
||||
if (strcmp((const char *)records[i].ssid, (const char *)records[j].ssid) == 0) {
|
||||
duplicate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (duplicate) {
|
||||
continue;
|
||||
}
|
||||
cJSON *network = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(network, "ssid", (const char *)records[i].ssid);
|
||||
cJSON_AddNumberToObject(network, "rssi", records[i].rssi);
|
||||
cJSON_AddBoolToObject(network, "secure", records[i].authmode != WIFI_AUTH_OPEN);
|
||||
cJSON_AddItemToArray(networks, network);
|
||||
}
|
||||
|
||||
set_state(WEB_PROV_IDLE, "");
|
||||
send_json(req, root, "200 OK");
|
||||
cJSON_Delete(root);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static bool nonce_is_valid(httpd_req_t *req)
|
||||
{
|
||||
size_t length = httpd_req_get_hdr_value_len(req, "X-SESE-Nonce");
|
||||
if (length != NONCE_HEX_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
char value[NONCE_HEX_LENGTH + 1];
|
||||
return httpd_req_get_hdr_value_str(req, "X-SESE-Nonce", value, sizeof(value)) == ESP_OK &&
|
||||
strcmp(value, s_nonce) == 0;
|
||||
}
|
||||
|
||||
static esp_err_t config_post_handler(httpd_req_t *req)
|
||||
{
|
||||
if (!nonce_is_valid(req)) {
|
||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "Invalid session");
|
||||
return ESP_OK;
|
||||
}
|
||||
if (req->content_len <= 0 || req->content_len > REQUEST_BODY_MAX) {
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid request size");
|
||||
return ESP_OK;
|
||||
}
|
||||
if (web_provisioning_is_connecting()) {
|
||||
httpd_resp_set_status(req, "409 Conflict");
|
||||
httpd_resp_send(req, "Connection attempt in progress", HTTPD_RESP_USE_STRLEN);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
char body[REQUEST_BODY_MAX + 1];
|
||||
size_t received = 0;
|
||||
while (received < req->content_len) {
|
||||
int result = httpd_req_recv(req, body + received, req->content_len - received);
|
||||
if (result <= 0) {
|
||||
memset(body, 0, sizeof(body));
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Incomplete request");
|
||||
return ESP_OK;
|
||||
}
|
||||
received += result;
|
||||
}
|
||||
body[received] = '\0';
|
||||
|
||||
cJSON *root = cJSON_ParseWithLength(body, received);
|
||||
memset(body, 0, sizeof(body));
|
||||
if (root == NULL) {
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
cJSON *ssid = cJSON_GetObjectItemCaseSensitive(root, "ssid");
|
||||
cJSON *password = cJSON_GetObjectItemCaseSensitive(root, "password");
|
||||
if (!cJSON_IsString(ssid) || !cJSON_IsString(password)) {
|
||||
cJSON_Delete(root);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "SSID and password are required");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
size_t ssid_len = strlen(ssid->valuestring);
|
||||
size_t password_len = strlen(password->valuestring);
|
||||
if (ssid_len == 0 || ssid_len > WIFI_STORE_SSID_MAX_LEN ||
|
||||
password_len > WIFI_STORE_PASSWORD_MAX_LEN) {
|
||||
cJSON_Delete(root);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Credential length is invalid");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
wifi_store_credentials_t credentials = {0};
|
||||
strlcpy(credentials.ssid, ssid->valuestring, sizeof(credentials.ssid));
|
||||
strlcpy(credentials.password, password->valuestring, sizeof(credentials.password));
|
||||
cJSON_Delete(root);
|
||||
|
||||
ESP_LOGI(TAG, "Received browser Wi-Fi configuration for SSID '%s' (password not logged)",
|
||||
credentials.ssid);
|
||||
set_state(WEB_PROV_CONNECTING, "");
|
||||
esp_err_t err = s_config.apply_credentials(&credentials);
|
||||
memset(&credentials, 0, sizeof(credentials));
|
||||
if (err != ESP_OK) {
|
||||
set_state(WEB_PROV_FAILED, "연결 요청을 시작하지 못했습니다.");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Could not start connection");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
cJSON *response = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(response, "state", "connecting");
|
||||
send_json(req, response, "202 Accepted");
|
||||
cJSON_Delete(response);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t not_found_handler(httpd_req_t *req, httpd_err_code_t error)
|
||||
{
|
||||
(void)error;
|
||||
httpd_resp_set_status(req, "303 See Other");
|
||||
httpd_resp_set_hdr(req, "Location", "/");
|
||||
return httpd_resp_send(req, "Open SESE Controller setup", HTTPD_RESP_USE_STRLEN);
|
||||
}
|
||||
|
||||
static void generate_nonce(void)
|
||||
{
|
||||
for (size_t i = 0; i < NONCE_HEX_LENGTH / 8; ++i) {
|
||||
snprintf(s_nonce + (i * 8), 9, "%08" PRIx32, esp_random());
|
||||
}
|
||||
s_nonce[NONCE_HEX_LENGTH] = '\0';
|
||||
}
|
||||
|
||||
static esp_err_t configure_captive_portal_dhcp(void)
|
||||
{
|
||||
esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
|
||||
ESP_RETURN_ON_FALSE(ap, ESP_ERR_NOT_FOUND, TAG, "SoftAP network interface missing");
|
||||
|
||||
esp_netif_ip_info_t ip_info;
|
||||
ESP_RETURN_ON_ERROR(esp_netif_get_ip_info(ap, &ip_info), TAG, "Could not read SoftAP IP");
|
||||
snprintf(s_captive_uri, sizeof(s_captive_uri), "http://" IPSTR, IP2STR(&ip_info.ip));
|
||||
|
||||
ESP_ERROR_CHECK_WITHOUT_ABORT(esp_netif_dhcps_stop(ap));
|
||||
esp_err_t err = esp_netif_dhcps_option(ap, ESP_NETIF_OP_SET,
|
||||
ESP_NETIF_CAPTIVEPORTAL_URI,
|
||||
s_captive_uri, strlen(s_captive_uri));
|
||||
ESP_ERROR_CHECK_WITHOUT_ABORT(esp_netif_dhcps_start(ap));
|
||||
return err;
|
||||
}
|
||||
|
||||
static void timeout_task(void *argument)
|
||||
{
|
||||
(void)argument;
|
||||
vTaskDelay(pdMS_TO_TICKS(s_config.timeout_seconds * 1000U));
|
||||
if (web_provisioning_is_active()) {
|
||||
ESP_LOGW(TAG, "Browser provisioning window expired");
|
||||
if (s_config.expired != NULL) {
|
||||
s_config.expired();
|
||||
}
|
||||
}
|
||||
s_timeout_task = NULL;
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
esp_err_t web_provisioning_start(const web_provisioning_config_t *config)
|
||||
{
|
||||
if (config == NULL || config->service_name == NULL ||
|
||||
config->apply_credentials == NULL || config->timeout_seconds == 0 || s_active) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
s_config = *config;
|
||||
generate_nonce();
|
||||
memset(s_message, 0, sizeof(s_message));
|
||||
memset(s_ip_address, 0, sizeof(s_ip_address));
|
||||
set_state(WEB_PROV_IDLE, "");
|
||||
|
||||
httpd_config_t http_config = HTTPD_DEFAULT_CONFIG();
|
||||
http_config.max_open_sockets = 4;
|
||||
http_config.lru_purge_enable = true;
|
||||
http_config.max_uri_handlers = 4;
|
||||
ESP_RETURN_ON_ERROR(httpd_start(&s_http_server, &http_config), TAG, "HTTP server start failed");
|
||||
|
||||
const httpd_uri_t root = {.uri = "/", .method = HTTP_GET, .handler = root_get_handler};
|
||||
const httpd_uri_t status = {.uri = "/api/v1/status", .method = HTTP_GET, .handler = status_get_handler};
|
||||
const httpd_uri_t networks = {.uri = "/api/v1/networks", .method = HTTP_GET, .handler = networks_get_handler};
|
||||
const httpd_uri_t apply = {.uri = "/api/v1/config", .method = HTTP_POST, .handler = config_post_handler};
|
||||
ESP_ERROR_CHECK(httpd_register_uri_handler(s_http_server, &root));
|
||||
ESP_ERROR_CHECK(httpd_register_uri_handler(s_http_server, &status));
|
||||
ESP_ERROR_CHECK(httpd_register_uri_handler(s_http_server, &networks));
|
||||
ESP_ERROR_CHECK(httpd_register_uri_handler(s_http_server, &apply));
|
||||
ESP_ERROR_CHECK(httpd_register_err_handler(s_http_server, HTTPD_404_NOT_FOUND, not_found_handler));
|
||||
|
||||
ESP_RETURN_ON_ERROR(configure_captive_portal_dhcp(), TAG, "DHCP captive portal setup failed");
|
||||
dns_server_config_t dns_config = DNS_SERVER_CONFIG_SINGLE("*", "WIFI_AP_DEF");
|
||||
s_dns_server = start_dns_server(&dns_config);
|
||||
if (s_dns_server == NULL) {
|
||||
httpd_stop(s_http_server);
|
||||
s_http_server = NULL;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_active = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (xTaskCreate(timeout_task, "portal_timeout", 3072, NULL, 4, &s_timeout_task) != pdPASS) {
|
||||
web_provisioning_stop();
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Browser provisioning portal ready at %s", s_captive_uri);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void web_provisioning_stop(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool was_active = s_active;
|
||||
s_active = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!was_active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (s_timeout_task != NULL && s_timeout_task != xTaskGetCurrentTaskHandle()) {
|
||||
vTaskDelete(s_timeout_task);
|
||||
s_timeout_task = NULL;
|
||||
}
|
||||
if (s_dns_server != NULL) {
|
||||
stop_dns_server(s_dns_server);
|
||||
s_dns_server = NULL;
|
||||
}
|
||||
if (s_http_server != NULL) {
|
||||
httpd_stop(s_http_server);
|
||||
s_http_server = NULL;
|
||||
}
|
||||
ESP_LOGI(TAG, "Browser provisioning portal stopped");
|
||||
}
|
||||
|
||||
bool web_provisioning_is_active(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool active = s_active;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return active;
|
||||
}
|
||||
|
||||
bool web_provisioning_is_connecting(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool connecting = s_state == WEB_PROV_CONNECTING;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return connecting;
|
||||
}
|
||||
|
||||
void web_provisioning_report_connected(const char *ip_address)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
strlcpy(s_ip_address, ip_address == NULL ? "" : ip_address, sizeof(s_ip_address));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
set_state(WEB_PROV_CONNECTED, "설정이 완료되었습니다. 이 창은 닫아도 됩니다.");
|
||||
}
|
||||
|
||||
void web_provisioning_report_failure(const char *reason)
|
||||
{
|
||||
set_state(WEB_PROV_FAILED, reason == NULL ? "Wi-Fi에 연결하지 못했습니다." : reason);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#include "wifi_store.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "nvs.h"
|
||||
|
||||
#define WIFI_STORE_NAMESPACE "wifi_cfg"
|
||||
#define WIFI_STORE_KEY "record"
|
||||
#define WIFI_STORE_MAGIC 0x53455345U
|
||||
#define WIFI_STORE_SCHEMA_VERSION 1U
|
||||
|
||||
typedef struct {
|
||||
uint32_t magic;
|
||||
uint16_t schema_version;
|
||||
uint16_t reserved;
|
||||
wifi_store_credentials_t credentials;
|
||||
} wifi_store_record_t;
|
||||
|
||||
static bool credentials_valid(const wifi_store_credentials_t *credentials)
|
||||
{
|
||||
if (credentials == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t ssid_len = strnlen(credentials->ssid, sizeof(credentials->ssid));
|
||||
size_t password_len = strnlen(credentials->password, sizeof(credentials->password));
|
||||
return ssid_len > 0 && ssid_len <= WIFI_STORE_SSID_MAX_LEN &&
|
||||
password_len <= WIFI_STORE_PASSWORD_MAX_LEN;
|
||||
}
|
||||
|
||||
esp_err_t wifi_store_load(wifi_store_credentials_t *credentials, bool *found)
|
||||
{
|
||||
if (credentials == NULL || found == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
*found = false;
|
||||
memset(credentials, 0, sizeof(*credentials));
|
||||
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open(WIFI_STORE_NAMESPACE, NVS_READONLY, &handle);
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
wifi_store_record_t record = {0};
|
||||
size_t size = sizeof(record);
|
||||
err = nvs_get_blob(handle, WIFI_STORE_KEY, &record, &size);
|
||||
nvs_close(handle);
|
||||
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
if (size != sizeof(record) || record.magic != WIFI_STORE_MAGIC ||
|
||||
record.schema_version != WIFI_STORE_SCHEMA_VERSION ||
|
||||
!credentials_valid(&record.credentials)) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
*credentials = record.credentials;
|
||||
*found = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wifi_store_save(const wifi_store_credentials_t *credentials)
|
||||
{
|
||||
if (!credentials_valid(credentials)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
wifi_store_record_t record = {
|
||||
.magic = WIFI_STORE_MAGIC,
|
||||
.schema_version = WIFI_STORE_SCHEMA_VERSION,
|
||||
.credentials = *credentials,
|
||||
};
|
||||
|
||||
nvs_handle_t handle = 0;
|
||||
esp_err_t err = nvs_open(WIFI_STORE_NAMESPACE, NVS_READWRITE, &handle);
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_set_blob(handle, WIFI_STORE_KEY, &record, sizeof(record));
|
||||
}
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_commit(handle);
|
||||
}
|
||||
if (handle) {
|
||||
nvs_close(handle);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t wifi_store_erase(void)
|
||||
{
|
||||
nvs_handle_t handle = 0;
|
||||
esp_err_t err = nvs_open(WIFI_STORE_NAMESPACE, NVS_READWRITE, &handle);
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
err = nvs_erase_all(handle);
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
return err;
|
||||
}
|
||||
Reference in New Issue
Block a user