From e4e4d78b26c0e231fc2fe295c4671502eafc005a Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sun, 4 Oct 2026 11:42:27 +0900 Subject: [PATCH] Initial kesp32_app_base component --- .gitignore | 6 + CMakeLists.txt | 16 + Kconfig | 82 +++++ README.md | 9 + idf_component.yml | 5 + include/esp32_app_base.h | 34 +++ include/mqtt_transport.h | 25 ++ include/ota_update.h | 11 + include/ota_version.h | 6 + include/portal_self_test.h | 6 + include/time_sync.h | 10 + include/web_provisioning.h | 31 ++ include/wifi_store.h | 19 ++ src/dns_server/CMakeLists.txt | 3 + src/dns_server/dns_server.c | 290 ++++++++++++++++++ src/dns_server/include/dns_server.h | 80 +++++ src/esp32_app_base.c | 451 ++++++++++++++++++++++++++++ src/mqtt_transport.c | 65 ++++ src/ota_update.c | 190 ++++++++++++ src/ota_version.c | 23 ++ src/portal_self_test.c | 68 +++++ src/provisioning.html | 39 +++ src/time_sync.c | 74 +++++ src/web_provisioning.c | 416 +++++++++++++++++++++++++ src/wifi_store.c | 114 +++++++ 25 files changed, 2073 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 Kconfig create mode 100644 README.md create mode 100644 idf_component.yml create mode 100644 include/esp32_app_base.h create mode 100644 include/mqtt_transport.h create mode 100644 include/ota_update.h create mode 100644 include/ota_version.h create mode 100644 include/portal_self_test.h create mode 100644 include/time_sync.h create mode 100644 include/web_provisioning.h create mode 100644 include/wifi_store.h create mode 100644 src/dns_server/CMakeLists.txt create mode 100644 src/dns_server/dns_server.c create mode 100644 src/dns_server/include/dns_server.h create mode 100644 src/esp32_app_base.c create mode 100644 src/mqtt_transport.c create mode 100644 src/ota_update.c create mode 100644 src/ota_version.c create mode 100644 src/portal_self_test.c create mode 100644 src/provisioning.html create mode 100644 src/time_sync.c create mode 100644 src/web_provisioning.c create mode 100644 src/wifi_store.c diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..713dbda --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# ESP-IDF generated output and local configuration +build/ +managed_components/ +sdkconfig +sdkconfig.old +*.log diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..c5a8a8b --- /dev/null +++ b/CMakeLists.txt @@ -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 +) diff --git a/Kconfig b/Kconfig new file mode 100644 index 0000000..f29bdae --- /dev/null +++ b/Kconfig @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e38690c --- /dev/null +++ b/README.md @@ -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. diff --git a/idf_component.yml b/idf_component.yml new file mode 100644 index 0000000..d0585e4 --- /dev/null +++ b/idf_component.yml @@ -0,0 +1,5 @@ +dependencies: + idf: + version: '>=6.0.2' + espressif/mqtt: ^1.1.0 + espressif/cjson: ^1.7.19 \ No newline at end of file diff --git a/include/esp32_app_base.h b/include/esp32_app_base.h new file mode 100644 index 0000000..3306dc3 --- /dev/null +++ b/include/esp32_app_base.h @@ -0,0 +1,34 @@ +#pragma once + +#include "esp_err.h" +#include +#include +#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); diff --git a/include/mqtt_transport.h b/include/mqtt_transport.h new file mode 100644 index 0000000..da0b072 --- /dev/null +++ b/include/mqtt_transport.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esp_err.h" +#include +#include +#include + +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); diff --git a/include/ota_update.h b/include/ota_update.h new file mode 100644 index 0000000..10d1c94 --- /dev/null +++ b/include/ota_update.h @@ -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); diff --git a/include/ota_version.h b/include/ota_version.h new file mode 100644 index 0000000..5a62cc2 --- /dev/null +++ b/include/ota_version.h @@ -0,0 +1,6 @@ +#pragma once + +#include + +/** Compare strict major.minor.patch versions. Returns false for invalid input. */ +bool ota_version_compare(const char *left, const char *right, int *order); diff --git a/include/portal_self_test.h b/include/portal_self_test.h new file mode 100644 index 0000000..1c8cd3b --- /dev/null +++ b/include/portal_self_test.h @@ -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); diff --git a/include/time_sync.h b/include/time_sync.h new file mode 100644 index 0000000..ca0ea75 --- /dev/null +++ b/include/time_sync.h @@ -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); diff --git a/include/web_provisioning.h b/include/web_provisioning.h new file mode 100644 index 0000000..7de6ee7 --- /dev/null +++ b/include/web_provisioning.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#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); diff --git a/include/wifi_store.h b/include/wifi_store.h new file mode 100644 index 0000000..69d1afa --- /dev/null +++ b/include/wifi_store.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include + +#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); diff --git a/src/dns_server/CMakeLists.txt b/src/dns_server/CMakeLists.txt new file mode 100644 index 0000000..4230585 --- /dev/null +++ b/src/dns_server/CMakeLists.txt @@ -0,0 +1,3 @@ +idf_component_register(SRCS dns_server.c + INCLUDE_DIRS include + PRIV_REQUIRES esp_netif) diff --git a/src/dns_server/dns_server.c b/src/dns_server/dns_server.c new file mode 100644 index 0000000..13d4098 --- /dev/null +++ b/src/dns_server/dns_server.c @@ -0,0 +1,290 @@ +/* + * SPDX-FileCopyrightText: 2021-2023 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + */ + +#include +#include + +#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); + } +} diff --git a/src/dns_server/include/dns_server.h b/src/dns_server/include/dns_server.h new file mode 100644 index 0000000..cb3a028 --- /dev/null +++ b/src/dns_server/include/dns_server.h @@ -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; /** +#include +#include +#include +#include + +#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)); + } +} diff --git a/src/mqtt_transport.c b/src/mqtt_transport.c new file mode 100644 index 0000000..37772c6 --- /dev/null +++ b/src/mqtt_transport.c @@ -0,0 +1,65 @@ +#include "mqtt_transport.h" +#include +#include +#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; +} diff --git a/src/ota_update.c b/src/ota_update.c new file mode 100644 index 0000000..966f9a5 --- /dev/null +++ b/src/ota_update.c @@ -0,0 +1,190 @@ +#include "ota_update.h" + +#include +#include +#include +#include + +#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 +} diff --git a/src/ota_version.c b/src/ota_version.c new file mode 100644 index 0000000..ddcf0a1 --- /dev/null +++ b/src/ota_version.c @@ -0,0 +1,23 @@ +#include "ota_version.h" + +#include +#include + +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; +} diff --git a/src/portal_self_test.c b/src/portal_self_test.c new file mode 100644 index 0000000..5d1448f --- /dev/null +++ b/src/portal_self_test.c @@ -0,0 +1,68 @@ +#include "portal_self_test.h" + +#include + +#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; +} diff --git a/src/provisioning.html b/src/provisioning.html new file mode 100644 index 0000000..d72ef11 --- /dev/null +++ b/src/provisioning.html @@ -0,0 +1,39 @@ + + + + + + +SESE Controller Wi-Fi 설정 + + +
+

SESE Controller Wi-Fi 설정

+

장치 정보를 확인하고 있습니다.

+ +
+ + + + + + +
Wi-Fi를 선택해 주세요.
+

이 페이지는 인터넷이 아닌 SESE Controller에서 직접 제공됩니다.

+
+ diff --git a/src/time_sync.c b/src/time_sync.c new file mode 100644 index 0000000..ee99e6b --- /dev/null +++ b/src/time_sync.c @@ -0,0 +1,74 @@ +#include "time_sync.h" + +#include +#include +#include + +#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; +} diff --git a/src/web_provisioning.c b/src/web_provisioning.c new file mode 100644 index 0000000..6fdbc2e --- /dev/null +++ b/src/web_provisioning.c @@ -0,0 +1,416 @@ +#include "web_provisioning.h" + +#include +#include +#include +#include +#include + +#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); +} diff --git a/src/wifi_store.c b/src/wifi_store.c new file mode 100644 index 0000000..b153ab8 --- /dev/null +++ b/src/wifi_store.c @@ -0,0 +1,114 @@ +#include "wifi_store.h" + +#include + +#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; +}