Compare commits

...
5 Commits
26 changed files with 1583 additions and 342 deletions
+1 -1
View File
@@ -15,5 +15,5 @@
"*.c": "c"
},
"idf.currentSetup": "D:\\SDK\\.espressif\\v6.1\\esp-idf",
"idf.portWin": "COM4"
"idf.port": "COM4"
}
+20 -2
View File
@@ -9,6 +9,7 @@ A modular embedded micro-OS for the ABrobot ESP32-C3 board with a 0.42" monochro
- OLED-based app launcher and system UI
- Single-button input handling with click, double-click, hold, and home gestures
- USB serial/JTAG CLI for runtime system control
- Hardware UART Serial Console bridge (TX: GPIO 21, RX: GPIO 20) with Web Terminal UI at `/serial`
- Wi‑Fi station mode with automatic fallback to SoftAP provisioning
- Wi‑Fi network scanning from the setup page
- Persistent NVS Wi‑Fi credentials
@@ -24,7 +25,7 @@ d:/Projects/ESP32C3/OS/
├── components/
│ ├── sys_app/ # App lifecycle and app registry
│ ├── sys_cli/ # USB serial / JTAG shell commands
│ ├── sys_core/ # Global status and event plumbing
│ ├── sys_core/ # Global status, event plumbing & sys_uart driver
│ ├── sys_gfx/ # OLED display, fonts, UI compositor, widgets
│ ├── sys_input/ # Button input handling and gestures
│ ├── sys_wifi/ # Wi‑Fi initialization, station/AP logic, NVS credentials
@@ -34,7 +35,8 @@ d:/Projects/ESP32C3/OS/
│ │ └── web_server_pages.h
│ ├── pages/
│ │ ├── wifi.c # Root configuration and /scan /configure handlers
│ │ └── ota.c # /ota firmware upload page and OTA logic
│ │ ├── ota.c # /ota firmware upload page and OTA logic
│ │ └── serial.c # /serial Web Serial Terminal and /serial/data REST API
│ └── web_server.c # Starts the HTTP server and registers pages
├── main/
│ ├── apps/
@@ -42,6 +44,7 @@ d:/Projects/ESP32C3/OS/
│ │ ├── app_game.c
│ │ ├── app_settings.c
│ │ ├── app_sysmon.c
│ │ ├── app_serial.c
│ │ └── apps.h
│ ├── CMakeLists.txt
│ ├── font_5x7.h
@@ -98,6 +101,20 @@ The web server is also started while the board is connected to Wi‑Fi so the sa
- `/scan` — JSON network scan results
- `/configure` — save station credentials and connect
- `/ota` — OTA upload page and firmware update endpoint
- `/serial` — Web Serial Terminal interface
- `/serial/data` — Real-time streaming JSON endpoint for RX/TX delta bytes
- `/serial/tx` — POST endpoint to transmit text or bytes over UART TX (GPIO 21)
- `/serial/baud` — POST endpoint to dynamically change UART baud rate
- `/serial/clear` — POST endpoint to clear the RX ring buffer
---
## Web Serial Terminal
Connect over Wi-Fi and open `http://192.168.4.1/serial` (in SoftAP mode) or `http://<board-ip>/serial` (in station mode) to access the interactive web-based serial console:
- **Pins**: TX on `GPIO 21`, RX on `GPIO 20`
- **Features**: Live streaming terminal, color-coded RX/TX logs, auto-scroll toggle, command history (Up/Down arrow navigation), dynamic baud rate switching (9600 to 921600), macro shortcuts (`PING`, `AT`, `HELP`, etc.), and log file exporting.
---
@@ -169,6 +186,7 @@ esp32c3-os> help
free Display system heap and RAM status
app App management (list, launch <id>, home)
input Simulate button input (click, double, long, home)
uart Hardware UART control (send, baud, stats, clear)
contrast Set OLED contrast (0-255)
reboot Reboot the MCU
```
+4 -9
View File
@@ -21,13 +21,11 @@ static void launcher_update(uint32_t delta_ms)
sys_status_get(&status);
sys_gfx_draw_status_bar(&status);
sys_gfx_draw_str_3x5(2, 9, "--- APPS ---", COLOR_WHITE);
const char *names[MAX_APPS];
for (size_t i = 0; i < s_app_count; i++) {
names[i] = s_registry[i]->name;
}
sys_gfx_draw_menu(15, names, s_app_count, s_launcher_selected);
sys_gfx_draw_menu(10, names, s_app_count, s_launcher_selected);
}
static void launcher_handle_input(input_action_t action)
@@ -37,11 +35,8 @@ static void launcher_handle_input(input_action_t action)
if (action == INPUT_ACTION_CLICK) {
s_launcher_selected = (s_launcher_selected + 1) % s_app_count;
} else if (action == INPUT_ACTION_DOUBLE_CLICK) {
if (s_launcher_selected == 0) {
s_launcher_selected = s_app_count - 1;
} else {
s_launcher_selected--;
}
/* Tap 1 already stepped +1; stepping -2 results in a net -1 (previous entry) */
s_launcher_selected = (s_launcher_selected + s_app_count - 2) % s_app_count;
} else if (action == INPUT_ACTION_LONG_PRESS) {
sys_app_launch_by_index(s_launcher_selected);
}
@@ -133,7 +128,7 @@ void sys_app_update(uint32_t delta_ms)
void sys_app_dispatch_input(input_action_t action)
{
if (action == INPUT_ACTION_HOLD_HOME) {
if (action == INPUT_ACTION_HOLD_HOME || action == INPUT_ACTION_TRIPLE_CLICK) {
sys_app_launch_launcher();
return;
}
+120 -1
View File
@@ -4,9 +4,12 @@
#include "os_gfx.h"
#include "os_oled.h"
#include "os_status.h"
#include "sys_uart.h"
#include "sys_time.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>
#include "esp_console.h"
#include "esp_system.h"
#include "esp_log.h"
@@ -15,6 +18,96 @@
static const char *TAG = "SYS_CLI";
static int cmd_time(int argc, char **argv)
{
if (argc >= 2 && strcmp(argv[1], "sync") == 0) {
printf("Triggering SNTP network time sync...\n");
sys_time_start_sntp();
return 0;
} else if (argc >= 3 && strcmp(argv[1], "set") == 0) {
time_t epoch = (time_t)strtoull(argv[2], NULL, 10);
if (sys_time_set_epoch(epoch) == ESP_OK) {
printf("System time successfully set to epoch: %lld\n", (long long)epoch);
return 0;
} else {
printf("Failed to set time. Usage: time set <epoch_seconds>\n");
return 1;
}
}
struct tm tm_info;
bool synced = sys_time_get_local(&tm_info);
time_t now = sys_time_get_epoch();
char strftime_buf[64];
strftime(strftime_buf, sizeof(strftime_buf), "%Y-%m-%d %H:%M:%S (%A)", &tm_info);
printf("\n=== System Wall Clock ===\n");
printf("Status: %s\n", synced ? "SYNCHRONIZED" : "UNSYNCHRONIZED (Uptime only)");
printf("Local: %s\n", strftime_buf);
printf("Epoch: %lld\n", (long long)now);
printf("Timezone: %s\n", sys_time_get_timezone());
printf("\nCommands: time sync | time set <epoch>\n");
return 0;
}
static int cmd_tz(int argc, char **argv)
{
if (argc >= 2) {
if (sys_time_set_timezone(argv[1]) == ESP_OK) {
printf("Timezone set to '%s'\n", argv[1]);
return 0;
} else {
printf("Failed to set timezone\n");
return 1;
}
}
printf("Current Timezone: %s\n", sys_time_get_timezone());
printf("Examples: tz \"GMT0BST,M3.5.0/1,M10.5.0\" (UK)\n");
printf(" tz \"EST5EDT,M3.2.0,M11.1.0\" (US Eastern)\n");
printf(" tz \"CST-8\" (China / Asia)\n");
printf(" tz \"UTC0\" (UTC)\n");
return 0;
}
static int cmd_uart(int argc, char **argv)
{
if (argc < 2) {
printf("Usage: uart <send <msg> | baud <rate> | stats | clear>\n");
return 1;
}
if (strcmp(argv[1], "send") == 0 && argc >= 3) {
sys_uart_write_str(argv[2]);
sys_uart_write_str("\r\n");
printf("Sent over UART TX: %s\n", argv[2]);
return 0;
} else if (strcmp(argv[1], "baud") == 0 && argc >= 3) {
uint32_t baud = (uint32_t)strtoul(argv[2], NULL, 10);
if (baud > 0 && sys_uart_set_baud_rate(baud) == ESP_OK) {
printf("UART baud rate changed to %lu\n", (unsigned long)baud);
return 0;
} else {
printf("Failed to set baud rate\n");
return 1;
}
} else if (strcmp(argv[1], "stats") == 0) {
uint32_t rx = 0, tx = 0;
sys_uart_get_stats(&rx, &tx);
printf("\n=== UART Status (TX: GPIO 21, RX: GPIO 20) ===\n");
printf("Active Baud Rate: %lu\n", (unsigned long)sys_uart_get_baud_rate());
printf("Total RX Bytes: %lu\n", (unsigned long)rx);
printf("Total TX Bytes: %lu\n", (unsigned long)tx);
return 0;
} else if (strcmp(argv[1], "clear") == 0) {
sys_uart_clear_rx_buffer();
printf("UART RX buffer cleared\n");
return 0;
}
printf("Unknown uart sub-command\n");
return 1;
}
static int cmd_ps(int argc, char **argv)
{
printf("\n=== Running FreeRTOS Tasks ===\n");
@@ -69,7 +162,7 @@ static int cmd_app(int argc, char **argv)
static int cmd_input(int argc, char **argv)
{
if (argc < 2) {
printf("Usage: input <click | double | long | home>\n");
printf("Usage: input <click | double | triple | long | home>\n");
return 1;
}
@@ -77,6 +170,8 @@ static int cmd_input(int argc, char **argv)
sys_input_inject(INPUT_ACTION_CLICK);
} else if (strcmp(argv[1], "double") == 0) {
sys_input_inject(INPUT_ACTION_DOUBLE_CLICK);
} else if (strcmp(argv[1], "triple") == 0) {
sys_input_inject(INPUT_ACTION_TRIPLE_CLICK);
} else if (strcmp(argv[1], "long") == 0) {
sys_input_inject(INPUT_ACTION_LONG_PRESS);
} else if (strcmp(argv[1], "home") == 0) {
@@ -130,6 +225,22 @@ esp_err_t sys_cli_init(void)
esp_console_register_help_command();
const esp_console_cmd_t time_cmd = {
.command = "time",
.help = "View or configure real-time clock (time | time sync | time set <epoch>)",
.hint = "<sync|set <epoch>>",
.func = &cmd_time,
};
esp_console_cmd_register(&time_cmd);
const esp_console_cmd_t tz_cmd = {
.command = "tz",
.help = "View or configure timezone string",
.hint = "<tz_string>",
.func = &cmd_tz,
};
esp_console_cmd_register(&tz_cmd);
const esp_console_cmd_t ps_cmd = {
.command = "ps",
.help = "List active FreeRTOS tasks and memory",
@@ -178,6 +289,14 @@ esp_err_t sys_cli_init(void)
};
esp_console_cmd_register(&reboot_cmd);
const esp_console_cmd_t uart_cmd = {
.command = "uart",
.help = "Hardware UART control (send, baud, stats, clear)",
.hint = "<send|baud|stats|clear>",
.func = &cmd_uart,
};
esp_console_cmd_register(&uart_cmd);
err = esp_console_start_repl(repl);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to start USB Serial/JTAG REPL: %s", esp_err_to_name(err));
+9 -1
View File
@@ -1,7 +1,9 @@
idf_component_register(
idf_component_register(
SRCS
"sys_event.c"
"sys_status.c"
"sys_uart.c"
"sys_time.c"
INCLUDE_DIRS
"include"
REQUIRES
@@ -9,4 +11,10 @@ idf_component_register(
esp_timer
esp_system
log
driver
esp_driver_gpio
esp_driver_uart
nvs_flash
lwip
esp_netif
)
+61
View File
@@ -0,0 +1,61 @@
#ifndef SYS_TIME_H
#define SYS_TIME_H
#include <time.h>
#include <stdbool.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Default Timezone: UK (GMT / BST with Daylight Saving) */
#define SYS_TIME_DEFAULT_TZ "GMT0BST,M3.5.0/1,M10.5.0"
/**
* @brief Initialize system time service and load timezone from NVS.
*/
esp_err_t sys_time_init(void);
/**
* @brief Start SNTP background synchronization over active network.
*/
void sys_time_start_sntp(void);
/**
* @brief Check whether real-world wall clock has been synchronized.
*/
bool sys_time_is_synced(void);
/**
* @brief Get current UTC epoch seconds.
*/
time_t sys_time_get_epoch(void);
/**
* @brief Get breakdown of current local time.
* @param[out] out_tm Struct tm filled with current local time.
* @return true if time is synchronized, false otherwise.
*/
bool sys_time_get_local(struct tm *out_tm);
/**
* @brief Set epoch time manually.
*/
esp_err_t sys_time_set_epoch(time_t epoch_sec);
/**
* @brief Set and persist active timezone string in POSIX format.
*/
esp_err_t sys_time_set_timezone(const char *tz);
/**
* @brief Get active timezone string.
*/
const char *sys_time_get_timezone(void);
#ifdef __cplusplus
}
#endif
#endif // SYS_TIME_H
+79
View File
@@ -0,0 +1,79 @@
#ifndef SYS_UART_H
#define SYS_UART_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#include "esp_err.h"
#include "driver/gpio.h"
#include "driver/uart.h"
#ifdef __cplusplus
extern "C" {
#endif
#define SYS_UART_NUM UART_NUM_1
#define SYS_UART_PIN_TX GPIO_NUM_21
#define SYS_UART_PIN_RX GPIO_NUM_20
#define SYS_UART_DEFAULT_BAUD 115200
#define SYS_UART_BUFFER_SIZE 4096
/**
* @brief Initialize the hardware UART subsystem and RX background task.
* @param baud_rate Initial baud rate (e.g., 115200).
* @return ESP_OK on success.
*/
esp_err_t sys_uart_init(uint32_t baud_rate);
/**
* @brief Change the UART baud rate dynamically.
* @param baud_rate New baud rate.
* @return ESP_OK on success.
*/
esp_err_t sys_uart_set_baud_rate(uint32_t baud_rate);
/**
* @brief Get the currently active baud rate.
*/
uint32_t sys_uart_get_baud_rate(void);
/**
* @brief Transmit data over UART TX pin.
* @param data Pointer to buffer.
* @param len Number of bytes to send.
* @return ESP_OK on success.
*/
esp_err_t sys_uart_write(const uint8_t *data, size_t len);
/**
* @brief Transmit a null-terminated string over UART TX.
* @param str Null-terminated string.
* @return ESP_OK on success.
*/
esp_err_t sys_uart_write_str(const char *str);
/**
* @brief Read new incoming data since a given monotonic sequence index.
* @param from_seq Sequence number previously returned to the caller.
* @param out_buf Buffer to store newly received bytes.
* @param max_len Size of out_buf.
* @param[out] new_seq Pointer to store the updated sequence counter.
* @return Number of bytes copied to out_buf.
*/
size_t sys_uart_read_delta(uint32_t from_seq, uint8_t *out_buf, size_t max_len, uint32_t *new_seq);
/**
* @brief Get total transmitted and received byte counts.
*/
void sys_uart_get_stats(uint32_t *rx_count, uint32_t *tx_count);
/**
* @brief Clear the RX circular buffer and reset sequence.
*/
void sys_uart_clear_rx_buffer(void);
#ifdef __cplusplus
}
#endif
#endif // SYS_UART_H
+184
View File
@@ -0,0 +1,184 @@
#include "sys_time.h"
#include "os_event.h"
#include "os_status.h"
#include "esp_log.h"
#include "esp_netif_sntp.h"
#include "esp_sntp.h"
#include "nvs.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <sys/time.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
static const char *TAG = "SYS_TIME";
static char s_tz_str[64] = SYS_TIME_DEFAULT_TZ;
static TaskHandle_t s_sntp_task_handle = NULL;
static const char *const s_ntp_servers[] = {
"time.google.com",
"time.cloudflare.com",
"pool.ntp.org",
"time.windows.com",
"216.239.35.0" /* Fallback direct IP for time.google.com */
};
static void time_sync_notification_cb(struct timeval *tv)
{
time_t now = tv ? tv->tv_sec : time(NULL);
struct tm timeinfo;
localtime_r(&now, &timeinfo);
char strftime_buf[64];
strftime(strftime_buf, sizeof(strftime_buf), "%Y-%m-%d %H:%M:%S %Z", &timeinfo);
ESP_LOGI(TAG, "SNTP packet received & verified: %s", strftime_buf);
sys_event_post(SYS_EVENT_TICK_1S, 0, NULL);
}
esp_err_t sys_time_init(void)
{
nvs_handle_t nvs;
size_t tz_len = sizeof(s_tz_str);
if (nvs_open("system", NVS_READONLY, &nvs) == ESP_OK) {
if (nvs_get_str(nvs, "tz", s_tz_str, &tz_len) != ESP_OK) {
strncpy(s_tz_str, SYS_TIME_DEFAULT_TZ, sizeof(s_tz_str) - 1);
}
nvs_close(nvs);
} else {
strncpy(s_tz_str, SYS_TIME_DEFAULT_TZ, sizeof(s_tz_str) - 1);
}
s_tz_str[sizeof(s_tz_str) - 1] = '\0';
setenv("TZ", s_tz_str, 1);
tzset();
ESP_LOGI(TAG, "System time service initialized (Timezone: %s)", s_tz_str);
return ESP_OK;
}
static void sntp_sync_task(void *pvParameters)
{
const size_t num_servers = sizeof(s_ntp_servers) / sizeof(s_ntp_servers[0]);
for (size_t i = 0; i < num_servers && !sys_time_is_synced(); i++) {
const char *server = s_ntp_servers[i];
ESP_LOGI(TAG, "Attempting SNTP sync with server [%u/%u]: '%s'...", (unsigned int)(i + 1), (unsigned int)num_servers, server);
esp_netif_sntp_deinit();
esp_sntp_config_t config = ESP_NETIF_SNTP_DEFAULT_CONFIG(server);
config.sync_cb = time_sync_notification_cb;
config.smooth_sync = false;
config.server_from_dhcp = false;
config.wait_for_sync = true;
config.start = true;
esp_err_t err = esp_netif_sntp_init(&config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to initialize SNTP for '%s': %s", server, esp_err_to_name(err));
continue;
}
// Poll for sync completion up to 5 seconds per server
for (int r = 0; r < 5; r++) {
vTaskDelay(pdMS_TO_TICKS(1000));
if (sys_time_is_synced() || sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) {
break;
}
}
if (sys_time_is_synced()) {
break;
}
}
if (sys_time_is_synced()) {
time_t now = time(NULL);
struct tm timeinfo;
localtime_r(&now, &timeinfo);
char strftime_buf[64];
strftime(strftime_buf, sizeof(strftime_buf), "%Y-%m-%d %H:%M:%S %Z", &timeinfo);
ESP_LOGI(TAG, "SNTP Sync Complete! Current Local Time: %s", strftime_buf);
} else {
ESP_LOGW(TAG, "SNTP sync could not reach any configured NTP server. Device will retry on reconnect.");
}
s_sntp_task_handle = NULL;
vTaskDelete(NULL);
}
void sys_time_start_sntp(void)
{
if (s_sntp_task_handle != NULL) {
ESP_LOGI(TAG, "SNTP sync already running in background");
return;
}
xTaskCreate(sntp_sync_task, "sntp_task", 4096, NULL, 5, &s_sntp_task_handle);
}
bool sys_time_is_synced(void)
{
time_t now = time(NULL);
// Synced if timestamp is after Jan 1, 2024 (1704067200)
return (now >= 1704067200ULL);
}
time_t sys_time_get_epoch(void)
{
return time(NULL);
}
bool sys_time_get_local(struct tm *out_tm)
{
if (!out_tm) return false;
time_t now = time(NULL);
localtime_r(&now, out_tm);
return sys_time_is_synced();
}
esp_err_t sys_time_set_epoch(time_t epoch_sec)
{
if (epoch_sec < 1704067200ULL) {
ESP_LOGE(TAG, "Invalid epoch timestamp: %lld", (long long)epoch_sec);
return ESP_ERR_INVALID_ARG;
}
struct timeval tv = {
.tv_sec = epoch_sec,
.tv_usec = 0
};
int ret = settimeofday(&tv, NULL);
if (ret != 0) {
ESP_LOGE(TAG, "Failed to set time of day");
return ESP_FAIL;
}
struct tm timeinfo;
localtime_r(&epoch_sec, &timeinfo);
char strftime_buf[64];
strftime(strftime_buf, sizeof(strftime_buf), "%Y-%m-%d %H:%M:%S %Z", &timeinfo);
ESP_LOGI(TAG, "Manual time set: %s", strftime_buf);
sys_event_post(SYS_EVENT_TICK_1S, 0, NULL);
return ESP_OK;
}
esp_err_t sys_time_set_timezone(const char *tz)
{
if (!tz || strlen(tz) == 0) return ESP_ERR_INVALID_ARG;
strncpy(s_tz_str, tz, sizeof(s_tz_str) - 1);
s_tz_str[sizeof(s_tz_str) - 1] = '\0';
setenv("TZ", s_tz_str, 1);
tzset();
nvs_handle_t nvs;
if (nvs_open("system", NVS_READWRITE, &nvs) == ESP_OK) {
nvs_set_str(nvs, "tz", s_tz_str);
nvs_commit(nvs);
nvs_close(nvs);
}
ESP_LOGI(TAG, "Timezone updated to: %s", s_tz_str);
return ESP_OK;
}
const char *sys_time_get_timezone(void)
{
return s_tz_str;
}
+191
View File
@@ -0,0 +1,191 @@
#include "sys_uart.h"
#include <string.h>
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
static const char *TAG = "SYS_UART";
static uint8_t s_rx_ring[SYS_UART_BUFFER_SIZE];
static uint32_t s_rx_head = 0; /* Total lifetime received bytes (monotonic) */
static uint32_t s_tx_total = 0;
static uint32_t s_baud_rate = SYS_UART_DEFAULT_BAUD;
static SemaphoreHandle_t s_uart_mutex = NULL;
static bool s_initialized = false;
static void uart_rx_task(void *pvParameters)
{
uint8_t tmp_buf[256];
while (1) {
int len = uart_read_bytes(SYS_UART_NUM, tmp_buf, sizeof(tmp_buf), pdMS_TO_TICKS(20));
if (len > 0) {
if (xSemaphoreTake(s_uart_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
for (int i = 0; i < len; i++) {
s_rx_ring[s_rx_head % SYS_UART_BUFFER_SIZE] = tmp_buf[i];
s_rx_head++;
}
xSemaphoreGive(s_uart_mutex);
}
}
}
}
esp_err_t sys_uart_init(uint32_t baud_rate)
{
if (s_initialized) {
return ESP_OK;
}
if (baud_rate == 0) {
baud_rate = SYS_UART_DEFAULT_BAUD;
}
s_baud_rate = baud_rate;
s_uart_mutex = xSemaphoreCreateMutex();
if (!s_uart_mutex) {
ESP_LOGE(TAG, "Failed to create UART mutex");
return ESP_ERR_NO_MEM;
}
uart_config_t uart_config = {
.baud_rate = (int)s_baud_rate,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
esp_err_t err = uart_driver_install(SYS_UART_NUM, 1024, 1024, 0, NULL, 0);
if (err != ESP_OK) {
ESP_LOGE(TAG, "uart_driver_install failed: %s", esp_err_to_name(err));
return err;
}
err = uart_param_config(SYS_UART_NUM, &uart_config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "uart_param_config failed: %s", esp_err_to_name(err));
return err;
}
err = uart_set_pin(SYS_UART_NUM, SYS_UART_PIN_TX, SYS_UART_PIN_RX, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
if (err != ESP_OK) {
ESP_LOGE(TAG, "uart_set_pin failed: %s", esp_err_to_name(err));
return err;
}
/* Enable internal pull-up on RX to prevent floating line noise / framing errors */
gpio_set_pull_mode(SYS_UART_PIN_RX, GPIO_PULLUP_ONLY);
gpio_pullup_en(SYS_UART_PIN_RX);
memset(s_rx_ring, 0, sizeof(s_rx_ring));
s_rx_head = 0;
s_tx_total = 0;
s_initialized = true;
xTaskCreate(uart_rx_task, "uart_rx_task", 3072, NULL, 5, NULL);
ESP_LOGI(TAG, "Hardware UART initialized on TX:GPIO%d, RX:GPIO%d @ %lu baud",
SYS_UART_PIN_TX, SYS_UART_PIN_RX, (unsigned long)s_baud_rate);
return ESP_OK;
}
esp_err_t sys_uart_set_baud_rate(uint32_t baud_rate)
{
if (!s_initialized || baud_rate == 0) return ESP_ERR_INVALID_STATE;
esp_err_t err = uart_set_baudrate(SYS_UART_NUM, baud_rate);
if (err == ESP_OK) {
s_baud_rate = baud_rate;
uart_flush_input(SYS_UART_NUM);
ESP_LOGI(TAG, "Baud rate changed to %lu", (unsigned long)baud_rate);
}
return err;
}
uint32_t sys_uart_get_baud_rate(void)
{
return s_baud_rate;
}
esp_err_t sys_uart_write(const uint8_t *data, size_t len)
{
if (!s_initialized || !data || len == 0) return ESP_ERR_INVALID_ARG;
int sent = uart_write_bytes(SYS_UART_NUM, data, len);
if (sent > 0) {
if (xSemaphoreTake(s_uart_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_tx_total += (uint32_t)sent;
xSemaphoreGive(s_uart_mutex);
}
return ESP_OK;
}
return ESP_FAIL;
}
esp_err_t sys_uart_write_str(const char *str)
{
if (!str) return ESP_ERR_INVALID_ARG;
return sys_uart_write((const uint8_t *)str, strlen(str));
}
size_t sys_uart_read_delta(uint32_t from_seq, uint8_t *out_buf, size_t max_len, uint32_t *new_seq)
{
if (!s_initialized || !out_buf || max_len == 0) {
if (new_seq) *new_seq = from_seq;
return 0;
}
size_t bytes_copied = 0;
if (xSemaphoreTake(s_uart_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
uint32_t current_head = s_rx_head;
if (from_seq > current_head) {
from_seq = current_head;
}
uint32_t available = current_head - from_seq;
if (available > SYS_UART_BUFFER_SIZE) {
/* Client is too far behind, catch up to buffer start */
from_seq = current_head - SYS_UART_BUFFER_SIZE;
available = SYS_UART_BUFFER_SIZE;
}
if (available > max_len) {
available = max_len;
}
for (size_t i = 0; i < available; i++) {
out_buf[i] = s_rx_ring[(from_seq + i) % SYS_UART_BUFFER_SIZE];
}
bytes_copied = available;
if (new_seq) {
*new_seq = from_seq + available;
}
xSemaphoreGive(s_uart_mutex);
} else {
if (new_seq) *new_seq = from_seq;
}
return bytes_copied;
}
void sys_uart_get_stats(uint32_t *rx_count, uint32_t *tx_count)
{
if (xSemaphoreTake(s_uart_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
if (rx_count) *rx_count = s_rx_head;
if (tx_count) *tx_count = s_tx_total;
xSemaphoreGive(s_uart_mutex);
} else {
if (rx_count) *rx_count = s_rx_head;
if (tx_count) *tx_count = s_tx_total;
}
}
void sys_uart_clear_rx_buffer(void)
{
if (xSemaphoreTake(s_uart_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
memset(s_rx_ring, 0, sizeof(s_rx_ring));
xSemaphoreGive(s_uart_mutex);
}
}
+24 -19
View File
@@ -215,36 +215,41 @@ void sys_gfx_draw_action_bar(const char *left_hint, const char *right_hint)
}
}
/* UI Framework: Scrollable List Menu */
/* UI Framework: Scrollable List Menu (Always centers highlighted entry) */
void sys_gfx_draw_menu(int16_t y_start, const char *items[], size_t count, size_t selected_idx)
{
if (!items || count == 0) return;
size_t visible_count = 3;
size_t top_idx = 0;
int16_t y_center = y_start + 9;
int16_t y_top = y_start;
int16_t y_bot = y_start + 18;
if (selected_idx >= visible_count) {
top_idx = selected_idx - visible_count + 1;
if (count == 1) {
sys_gfx_fill_rect(0, y_center - 1, OLED_WIDTH - 4, 9, COLOR_WHITE);
sys_gfx_draw_char_3x5(2, y_center + 1, '>', COLOR_BLACK);
sys_gfx_draw_str_5x7(8, y_center, items[0], COLOR_BLACK);
return;
}
for (size_t i = 0; i < visible_count && (top_idx + i) < count; i++) {
size_t item_idx = top_idx + i;
int16_t y = y_start + (i * 9);
// Top item: previous item in list (cyclically)
size_t prev_idx = (selected_idx + count - 1) % count;
sys_gfx_draw_str_5x7(8, y_top, items[prev_idx], COLOR_WHITE);
if (item_idx == selected_idx) {
sys_gfx_fill_rect(0, y - 1, OLED_WIDTH - 3, 9, COLOR_WHITE);
sys_gfx_draw_char_3x5(2, y + 1, '>', COLOR_BLACK);
sys_gfx_draw_str_5x7(8, y, items[item_idx], COLOR_BLACK);
} else {
sys_gfx_draw_str_5x7(8, y, items[item_idx], COLOR_WHITE);
}
}
// Center item: currently highlighted entry
sys_gfx_fill_rect(0, y_center - 1, OLED_WIDTH - 4, 9, COLOR_WHITE);
sys_gfx_draw_char_3x5(2, y_center + 1, '>', COLOR_BLACK);
sys_gfx_draw_str_5x7(8, y_center, items[selected_idx], COLOR_BLACK);
if (count > visible_count) {
// Bottom item: next item in list (cyclically)
size_t next_idx = (selected_idx + 1) % count;
sys_gfx_draw_str_5x7(8, y_bot, items[next_idx], COLOR_WHITE);
// Right-hand vertical scroll indicator
if (count > 1) {
sys_gfx_draw_line(OLED_WIDTH - 1, y_start, OLED_WIDTH - 1, y_start + 26, COLOR_WHITE);
int16_t thumb_h = 27 / count;
int16_t thumb_h = 27 / (int16_t)count;
if (thumb_h < 3) thumb_h = 3;
int16_t thumb_y = y_start + (selected_idx * (27 - thumb_h)) / (count - 1);
int16_t thumb_y = y_start + (int16_t)((selected_idx * (27 - thumb_h)) / (count - 1));
sys_gfx_fill_rect(OLED_WIDTH - 2, thumb_y, 2, thumb_h, COLOR_WHITE);
}
}
+3 -2
View File
@@ -14,8 +14,9 @@ typedef enum {
INPUT_ACTION_NONE = 0,
INPUT_ACTION_CLICK, /* Short press (Next / Step / Advance) */
INPUT_ACTION_DOUBLE_CLICK, /* Double click (Back / Cancel) */
INPUT_ACTION_LONG_PRESS, /* Hold > 400ms (Select / Confirm / Open) */
INPUT_ACTION_HOLD_HOME, /* Hold > 1500ms (Return to App Launcher) */
INPUT_ACTION_TRIPLE_CLICK, /* Triple click (Global Home / Launcher) */
INPUT_ACTION_LONG_PRESS, /* Hold > 380ms (Select / Confirm / Open) */
INPUT_ACTION_HOLD_HOME, /* Hold > 1400ms (Return to App Launcher) */
} input_action_t;
typedef void (*sys_input_handler_t)(input_action_t action, void *user_arg);
+31 -25
View File
@@ -7,11 +7,10 @@
static const char *TAG = "SYS_INPUT";
#define DEBOUNCE_TIME_MS 25
#define CLICK_TIME_MAX_MS 350
#define DOUBLE_CLICK_GAP_MS 280
#define LONG_PRESS_TIME_MS 450
#define HOME_PRESS_TIME_MS 1500
#define DEBOUNCE_TIME_MS 12
#define DOUBLE_CLICK_GAP_MS 260
#define LONG_PRESS_TIME_MS 380
#define HOME_PRESS_TIME_MS 1400
typedef struct {
sys_input_handler_t handler;
@@ -33,12 +32,13 @@ static void dispatch_action(input_action_t action)
case INPUT_ACTION_DOUBLE_CLICK:
evt_id = SYS_EVENT_INPUT_DOUBLE_CLICK;
break;
case INPUT_ACTION_LONG_PRESS:
evt_id = SYS_EVENT_INPUT_LONG_PRESS;
break;
case INPUT_ACTION_TRIPLE_CLICK:
case INPUT_ACTION_HOLD_HOME:
evt_id = SYS_EVENT_INPUT_EXTRA_LONG_PRESS;
break;
case INPUT_ACTION_LONG_PRESS:
evt_id = SYS_EVENT_INPUT_LONG_PRESS;
break;
default:
return;
}
@@ -69,48 +69,54 @@ esp_err_t sys_input_register_handler(sys_input_handler_t handler, void *user_arg
static void button_scanner_task(void *pvParameters)
{
bool last_state = true;
bool last_state = false;
uint32_t press_start_ms = 0;
uint32_t release_time_ms = 0;
uint8_t click_count = 0;
uint32_t last_release_time_ms = 0;
uint8_t tap_count = 0;
bool long_press_fired = false;
bool home_press_fired = false;
while (1) {
vTaskDelay(pdMS_TO_TICKS(10));
vTaskDelay(pdMS_TO_TICKS(5)); // High-rate 200 Hz scan loop for zero input lag
uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000ULL);
bool current_state = (gpio_get_level(s_button_pin) == 0);
if (current_state && !last_state) {
// Button pressed down
press_start_ms = now_ms;
long_press_fired = false;
home_press_fired = false;
} else if (current_state && last_state) {
// Button held down
uint32_t held_duration = now_ms - press_start_ms;
if (!home_press_fired && held_duration >= HOME_PRESS_TIME_MS) {
home_press_fired = true;
click_count = 0;
tap_count = 0;
dispatch_action(INPUT_ACTION_HOLD_HOME);
} else if (!long_press_fired && !home_press_fired && held_duration >= LONG_PRESS_TIME_MS) {
long_press_fired = true;
click_count = 0;
tap_count = 0;
dispatch_action(INPUT_ACTION_LONG_PRESS);
}
} else if (!current_state && last_state) {
// Button released
uint32_t press_duration = now_ms - press_start_ms;
if (press_duration >= DEBOUNCE_TIME_MS && !long_press_fired && !home_press_fired) {
click_count++;
release_time_ms = now_ms;
if ((now_ms - last_release_time_ms) <= DOUBLE_CLICK_GAP_MS) {
tap_count++;
} else {
tap_count = 1;
}
} else if (!current_state && !last_state) {
if (click_count > 0) {
uint32_t idle_time = now_ms - release_time_ms;
if (click_count >= 2) {
dispatch_action(INPUT_ACTION_DOUBLE_CLICK);
click_count = 0;
} else if (idle_time >= DOUBLE_CLICK_GAP_MS) {
last_release_time_ms = now_ms;
if (tap_count == 1) {
dispatch_action(INPUT_ACTION_CLICK);
click_count = 0;
} else if (tap_count == 2) {
dispatch_action(INPUT_ACTION_DOUBLE_CLICK);
} else if (tap_count >= 3) {
dispatch_action(INPUT_ACTION_TRIPLE_CLICK);
tap_count = 0;
last_release_time_ms = 0;
}
}
}
@@ -142,6 +148,6 @@ esp_err_t sys_input_init(gpio_num_t button_gpio)
return ESP_FAIL;
}
ESP_LOGI(TAG, "Input gesture engine started on GPIO %d", (int)s_button_pin);
ESP_LOGI(TAG, "Input gesture engine started on GPIO %d (Instant-Response Mode)", (int)s_button_pin);
return ESP_OK;
}
+3 -228
View File
@@ -1,5 +1,6 @@
#include "sys_wifi.h"
#include "sys_wifi.h"
#include "web_server.h"
#include "sys_time.h"
#include "os_event.h"
#include "os_status.h"
#include "sdkconfig.h"
@@ -45,233 +46,6 @@ static esp_err_t load_credentials(void)
return err;
}
#if 0
static esp_err_t provisioning_scan(httpd_req_t *request)
{
wifi_scan_config_t scan_config = {
.show_hidden = true,
.scan_type = WIFI_SCAN_TYPE_ACTIVE,
};
esp_err_t err = esp_wifi_scan_start(&scan_config, true);
if (err != ESP_OK) {
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Wi-Fi scan failed");
return err;
}
uint16_t count = 0;
esp_wifi_scan_get_ap_num(&count);
if (count > 32) count = 32;
wifi_ap_record_t *records = calloc(count, sizeof(wifi_ap_record_t));
if (count > 0 && !records) {
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Not enough memory for scan");
return ESP_ERR_NO_MEM;
}
if (count > 0) {
err = esp_wifi_scan_get_ap_records(&count, records);
if (err != ESP_OK) {
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Could not read scan results");
free(records);
return err;
}
}
httpd_resp_set_type(request, "application/json");
httpd_resp_send_chunk(request, "[", 1);
uint16_t visible_count = 0;
for (uint16_t i = 0; i < count; i++) {
if (records[i].ssid[0] == '\0') continue;
char prefix[32];
int prefix_length = snprintf(prefix, sizeof(prefix), "%s{\"ssid\":\"",
visible_count == 0 ? "" : ",");
httpd_resp_send_chunk(request, prefix, prefix_length);
append_json_string(request, records[i].ssid, sizeof(records[i].ssid));
char suffix[48];
int suffix_length = snprintf(suffix, sizeof(suffix), "\",\"rssi\":%d}", records[i].rssi);
httpd_resp_send_chunk(request, suffix, suffix_length);
visible_count++;
}
httpd_resp_send_chunk(request, "]", 1);
free(records);
return httpd_resp_send_chunk(request, NULL, 0);
}
static esp_err_t provisioning_get(httpd_req_t *request)
{
const char *page =
"<!doctype html><html><head><meta name=viewport content=\"width=device-width\">"
"<title>ABrobot Wi-Fi</title><style>body{font:16px sans-serif;max-width:480px;margin:2em auto;padding:0 1em}"
"input,select,button{font:inherit;padding:.5em;width:100%;box-sizing:border-box}button{margin-top:1em}"
"#message{margin-top:1em}</style></head><body><h1>ABrobot Wi-Fi setup</h1>"
"<form id=form method=post action=/configure><label>Network<br><select id=networks>"
"<option value=\"\">Scanning...</option></select></label>"
"<p><label><input type=checkbox id=hidden onchange=toggleManual()> Hidden network</label></p>"
"<div id=manualBox style=display:none><label>Network name<br>"
"<input id=manual name=ssid maxlength=32></label></div>"
"<br><br><label>Password<br><input name=password type=password maxlength=64 required></label>"
"<br><br><button type=button onclick=scan()>Refresh networks</button>"
"<button type=submit>Connect</button></form><p id=message></p>"
"<script>const list=document.getElementById('networks'),manual=document.getElementById('manual');"
"function toggleManual(){let on=document.getElementById('hidden').checked;"
"document.getElementById('manualBox').style.display=on?'block':'none';manual.required=on;"
"if(on){manual.value='';manual.focus()}else if(list.value)manual.value=list.value}"
"document.getElementById('form').onsubmit=()=>{if(!manual.value){message.textContent='Select a network or enable Hidden network';return false}return true};"
"function scan(){fetch('/scan').then(r=>r.json()).then(a=>{list.innerHTML='';"
"a.forEach(n=>{let o=document.createElement('option');o.value=n.ssid;o.textContent=n.ssid+' ('+n.rssi+' dBm)';list.appendChild(o)});"
"if(!a.length)list.innerHTML='<option value=\"\">No networks found</option>';"
"if(a.length&&!document.getElementById('hidden').checked)manual.value=list.value}).catch(()=>message.textContent='Scan failed')}"
"list.onchange=()=>{if(!document.getElementById('hidden').checked)manual.value=list.value};scan();</script></body></html>";
httpd_resp_set_type(request, "text/html");
return httpd_resp_send(request, page, HTTPD_RESP_USE_STRLEN);
}
static esp_err_t ota_get(httpd_req_t *request)
{
const char *page =
"<!doctype html><html><head><meta name=viewport content=\"width=device-width\">"
"<title>ABrobot OTA</title><style>body{font:16px sans-serif;max-width:480px;margin:2em auto;padding:0 1em}"
"input,button{font:inherit;padding:.6em;width:100%;box-sizing:border-box}button{margin-top:1em}"
"progress{width:100%;margin-top:1em}#message{margin-top:1em}</style></head><body>"
"<h1>Firmware update</h1><form id=form><input id=file type=file accept=\".bin,application/octet-stream\" required>"
"<button type=submit>Upload firmware</button></form><progress id=progress value=0 max=100 hidden></progress>"
"<p id=message></p><script>const form=document.getElementById('form'),file=document.getElementById('file'),"
"progress=document.getElementById('progress'),message=document.getElementById('message');"
"form.onsubmit=async event=>{event.preventDefault();if(!file.files.length)return;"
"let selected=file.files[0];progress.hidden=false;progress.value=0;message.textContent='Uploading...';"
"try{let response=await fetch('/ota',{method:'POST',headers:{'Content-Type':'application/octet-stream'},body:selected});"
"if(!response.ok)throw new Error(await response.text());progress.value=100;message.textContent='Update installed. Rebooting...';"
"}catch(error){message.textContent='Update failed: '+error.message;progress.hidden=true}};</script></body></html>";
httpd_resp_set_type(request, "text/html");
return httpd_resp_send(request, page, HTTPD_RESP_USE_STRLEN);
}
static esp_err_t ota_post(httpd_req_t *request)
{
const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL);
if (!update_partition || request->content_len == 0 || request->content_len > update_partition->size) {
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "Firmware image is too large or unavailable");
return ESP_ERR_INVALID_SIZE;
}
esp_ota_handle_t ota_handle;
esp_err_t err = esp_ota_begin(update_partition, request->content_len, &ota_handle);
if (err != ESP_OK) {
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Could not begin firmware update");
return err;
}
uint8_t *buffer = malloc(4096);
if (!buffer) {
esp_ota_abort(ota_handle);
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Not enough memory for firmware update");
return ESP_ERR_NO_MEM;
}
size_t remaining = request->content_len;
while (remaining > 0) {
size_t chunk_size = remaining < 4096 ? remaining : 4096;
int received = httpd_req_recv(request, (char *)buffer, chunk_size);
if (received <= 0) {
free(buffer);
esp_ota_abort(ota_handle);
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "Firmware upload interrupted");
return ESP_FAIL;
}
err = esp_ota_write(ota_handle, buffer, received);
if (err != ESP_OK) {
free(buffer);
esp_ota_abort(ota_handle);
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Could not write firmware image");
return err;
}
remaining -= (size_t)received;
}
free(buffer);
err = esp_ota_end(ota_handle);
if (err != ESP_OK) {
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "Invalid firmware image");
return err;
}
err = esp_ota_set_boot_partition(update_partition);
if (err != ESP_OK) {
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Could not select new firmware");
return err;
}
httpd_resp_sendstr(request, "Firmware update installed. Rebooting.");
ESP_LOGI(TAG, "OTA image installed to %s; rebooting", update_partition->label);
xTaskCreate(ota_reboot_task, "ota_reboot", 2048, NULL, 5, NULL);
return ESP_OK;
}
static esp_err_t provisioning_post(httpd_req_t *request)
{
char body[160] = {0};
char ssid[sizeof(s_sta_ssid)] = {0};
char password[sizeof(s_sta_password)] = {0};
int received = httpd_req_recv(request, body, sizeof(body) - 1);
if (received <= 0 || !form_value(body, "ssid", ssid, sizeof(ssid)) ||
!form_value(body, "password", password, sizeof(password))) {
httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "SSID and password are required");
return ESP_FAIL;
}
esp_err_t err = save_credentials(ssid, password);
if (err != ESP_OK) {
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Could not save credentials");
return err;
}
snprintf(s_sta_ssid, sizeof(s_sta_ssid), "%s", ssid);
snprintf(s_sta_password, sizeof(s_sta_password), "%s", password);
httpd_resp_sendstr(request, "Credentials saved. The device is connecting; you can close this page.");
set_status(WIFI_STATE_CONNECTING, 0);
err = esp_wifi_set_mode(WIFI_MODE_STA);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to switch to station mode: %s", esp_err_to_name(err));
return err;
}
wifi_config_t config = {0};
copy_wifi_string(config.sta.ssid, sizeof(config.sta.ssid), s_sta_ssid);
copy_wifi_string(config.sta.password, sizeof(config.sta.password), s_sta_password);
err = esp_wifi_set_config(WIFI_IF_STA, &config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to apply station credentials: %s", esp_err_to_name(err));
return err;
}
err = esp_wifi_connect();
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to start station connection: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "Station connection started for '%s'", s_sta_ssid);
return ESP_OK;
}
static void start_provisioning_server(void)
{
if (s_http_server != NULL) return;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
if (httpd_start(&s_http_server, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start provisioning web server");
return;
}
const httpd_uri_t get_uri = {.uri = "/", .method = HTTP_GET, .handler = provisioning_get};
const httpd_uri_t ota_get_uri = {.uri = "/ota", .method = HTTP_GET, .handler = ota_get};
const httpd_uri_t ota_post_uri = {.uri = "/ota", .method = HTTP_POST, .handler = ota_post};
const httpd_uri_t scan_uri = {.uri = "/scan", .method = HTTP_GET, .handler = provisioning_scan};
const httpd_uri_t post_uri = {.uri = "/configure", .method = HTTP_POST, .handler = provisioning_post};
httpd_register_uri_handler(s_http_server, &get_uri);
httpd_register_uri_handler(s_http_server, &ota_get_uri);
httpd_register_uri_handler(s_http_server, &ota_post_uri);
httpd_register_uri_handler(s_http_server, &scan_uri);
httpd_register_uri_handler(s_http_server, &post_uri);
}
#endif
static void start_softap(void)
{
wifi_config_t ap_config = {0};
@@ -311,6 +85,7 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base, int32_t e
if (esp_wifi_sta_get_ap_info(&record) == ESP_OK) rssi = record.rssi;
set_status(WIFI_STATE_CONNECTED, rssi);
web_server_start();
sys_time_start_sntp();
const ip_event_got_ip_t *got_ip = (const ip_event_got_ip_t *)event_data;
ESP_LOGI(TAG, "Wi-Fi connected, web server at http://" IPSTR, IP2STR(&got_ip->ip_info.ip));
}
+1
View File
@@ -3,6 +3,7 @@ idf_component_register(
"web_server.c"
"pages/wifi.c"
"pages/ota.c"
"pages/serial.c"
INCLUDE_DIRS
"include"
REQUIRES
@@ -5,5 +5,6 @@
void web_server_register_wifi_pages(httpd_handle_t server);
void web_server_register_ota_page(httpd_handle_t server);
void web_server_register_serial_pages(httpd_handle_t server);
#endif
+115 -2
View File
@@ -14,11 +14,124 @@ static void reboot_task(void *arg)
esp_restart();
}
static const char OTA_HTML[] =
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\">"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">"
"<title>ABrobot OTA Firmware Update</title>"
"<style>"
":root{--bg:#0b0f17;--card:#141b26;--border:#243042;--text:#e6edf3;--muted:#8b949e;--accent:#00d2ff;--accent2:#3a7bd5;--green:#00e676;--amber:#ffb300;--red:#ff5252;--term-bg:#080b10;}"
"*{box-sizing:border-box;margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;}"
"body{background:var(--bg);color:var(--text);padding:14px;display:flex;justify-content:center;min-height:100vh;}"
".container{width:100%;max-width:920px;display:flex;flex-direction:column;gap:14px;}"
".header{background:linear-gradient(135deg,rgba(58,123,213,0.18),rgba(0,210,255,0.08));border:1px solid var(--border);border-radius:12px;padding:12px 18px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;}"
".title-group{display:flex;align-items:center;gap:10px;}"
".status-dot{width:10px;height:10px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);}"
".title{font-size:1.15rem;font-weight:700;letter-spacing:0.5px;background:linear-gradient(90deg,#00d2ff,#3a7bd5);-webkit-background-clip:text;-webkit-text-fill-color:transparent;}"
".nav-links{display:flex;gap:8px;align-items:center;}"
".nav-links a{color:var(--muted);text-decoration:none;font-size:0.85rem;padding:6px 12px;border-radius:6px;transition:all 0.2s;}"
".nav-links a:hover{color:var(--text);background:rgba(255,255,255,0.05);}"
".nav-links a.active{color:var(--accent);font-weight:600;background:rgba(0,210,255,0.1);border:1px solid rgba(0,210,255,0.25);}"
".card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px;display:flex;flex-direction:column;gap:16px;box-shadow:0 4px 16px rgba(0,0,0,0.3);}"
".card-title{font-size:1rem;font-weight:600;color:var(--text);display:flex;align-items:center;justify-content:space-between;}"
".badge{background:#1a2332;border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:0.75rem;font-family:monospace;color:#58a6ff;}"
".dropzone{background:#0a0e14;border:2px dashed var(--border);border-radius:10px;padding:32px 20px;text-align:center;cursor:pointer;transition:all 0.2s;display:flex;flex-direction:column;align-items:center;gap:10px;}"
".dropzone:hover,.dropzone.dragover{border-color:var(--accent);background:rgba(0,210,255,0.04);box-shadow:0 0 12px rgba(0,210,255,0.15);}"
".drop-icon{font-size:2.2rem;line-height:1;}"
".drop-text{font-size:0.92rem;font-weight:600;color:var(--text);}"
".drop-subtext{font-size:0.8rem;color:var(--muted);}"
".file-info{display:none;background:#0a0e14;border:1px solid var(--border);border-radius:8px;padding:12px 16px;align-items:center;justify-content:space-between;}"
".file-meta{display:flex;flex-direction:column;gap:3px;}"
".file-name{font-size:0.9rem;font-weight:600;color:var(--accent);font-family:monospace;}"
".file-size{font-size:0.78rem;color:var(--muted);}"
"button{cursor:pointer;background:linear-gradient(180deg,#21262d,#161b22);border:1px solid #30363d;border-radius:8px;color:var(--text);padding:9px 16px;font-size:0.88rem;font-weight:600;transition:all 0.2s;display:inline-flex;align-items:center;justify-content:center;gap:6px;}"
"button:hover{background:#30363d;border-color:var(--muted);}"
"button.primary{background:linear-gradient(135deg,var(--accent2),var(--accent));border:none;color:#0a0d14;font-weight:700;padding:12px 20px;font-size:0.95rem;}"
"button.primary:hover{opacity:0.92;box-shadow:0 0 14px rgba(0,210,255,0.4);}"
"button:disabled{opacity:0.45;cursor:not-allowed;box-shadow:none;}"
".progress-wrap{display:none;flex-direction:column;gap:8px;}"
".progress-header{display:flex;justify-content:space-between;font-size:0.84rem;color:var(--muted);}"
".progress-bar{background:var(--term-bg);border:1px solid var(--border);border-radius:8px;height:24px;overflow:hidden;position:relative;}"
".progress-fill{background:linear-gradient(90deg,var(--accent2),var(--accent));height:100%;width:0%;transition:width 0.15s ease;box-shadow:0 0 10px rgba(0,210,255,0.4);}"
".progress-pct{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:0.75rem;font-weight:700;font-family:monospace;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,0.8);}"
".msg-box{padding:12px 14px;border-radius:8px;font-size:0.86rem;line-height:1.45;display:none;}"
".msg-info{background:rgba(58,123,213,0.12);border:1px solid rgba(58,123,213,0.35);color:#79c0ff;}"
".msg-success{background:rgba(0,230,118,0.12);border:1px solid rgba(0,230,118,0.35);color:#7ee787;}"
".msg-error{background:rgba(255,82,82,0.12);border:1px solid rgba(255,82,82,0.35);color:#ff7b72;}"
".info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px;}"
".info-item{background:#0a0e14;border:1px solid var(--border);border-radius:8px;padding:12px;display:flex;flex-direction:column;gap:4px;}"
".info-label{font-size:0.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px;}"
".info-val{font-size:0.85rem;color:var(--text);font-weight:500;}"
"</style></head><body>"
"<div class=\"container\">"
"<div class=\"header\">"
" <div class=\"title-group\">"
" <div class=\"status-dot\" id=\"statusDot\"></div>"
" <div class=\"title\">ESP32-C3 Firmware Update</div>"
" </div>"
" <div class=\"nav-links\">"
" <a href=\"/\">Wi-Fi Setup</a>"
" <a href=\"/serial\">Serial Terminal</a>"
" <a href=\"/ota\" class=\"active\">OTA Update</a>"
" </div>"
"</div>"
"<div class=\"card\">"
" <div class=\"card-title\"><span>Over-The-Air Flasher</span><span class=\"badge\">OTA 2.0 Engine</span></div>"
" <form id=\"otaForm\" onsubmit=\"startUpload(event)\" style=\"display:flex;flex-direction:column;gap:16px;\">"
" <input type=\"file\" id=\"fileInput\" accept=\".bin,application/octet-stream\" style=\"display:none;\" onchange=\"fileChosen()\">"
" <div id=\"dropArea\" class=\"dropzone\" onclick=\"document.getElementById('fileInput').click()\">"
" <div class=\"drop-icon\">⚡</div>"
" <div class=\"drop-text\">Click or Drag & Drop Firmware Image (.bin)</div>"
" <div class=\"drop-subtext\">Select the compiled application binary file to flash to ESP32-C3</div>"
" </div>"
" <div id=\"fileCard\" class=\"file-info\">"
" <div class=\"file-meta\">"
" <span class=\"file-name\" id=\"fileNameText\">firmware.bin</span>"
" <span class=\"file-size\" id=\"fileSizeText\">0 KB</span>"
" </div>"
" <button type=\"button\" onclick=\"document.getElementById('fileInput').click()\">Change</button>"
" </div>"
" <div id=\"progressSection\" class=\"progress-wrap\">"
" <div class=\"progress-header\">"
" <span id=\"progressStatus\">Uploading binary...</span>"
" <span id=\"bytesTransferred\">0 KB / 0 KB</span>"
" </div>"
" <div class=\"progress-bar\">"
" <div class=\"progress-fill\" id=\"progressFill\"></div>"
" <div class=\"progress-pct\" id=\"progressPct\">0%</div>"
" </div>"
" </div>"
" <button type=\"submit\" class=\"primary\" id=\"btnUpload\" disabled>Start Firmware Update</button>"
" </form>"
" <div id=\"statusMsg\" class=\"msg-box\"></div>"
"</div>"
"<div class=\"card\">"
" <div class=\"card-title\"><span>OTA Safety Checklist</span></div>"
" <div class=\"info-grid\">"
" <div class=\"info-item\"><span class=\"info-label\">Dual Partition</span><span class=\"info-val\">Safe rollback on boot failure</span></div>"
" <div class=\"info-item\"><span class=\"info-label\">Flash Verification</span><span class=\"info-val\">SHA-256 & image headers verified</span></div>"
" <div class=\"info-item\"><span class=\"info-label\">Power Notice</span><span class=\"info-val\">Do not remove power during flash</span></div>"
" <div class=\"info-item\"><span class=\"info-label\">Auto Restart</span><span class=\"info-val\">Reboots automatically after install</span></div>"
" </div>"
"</div>"
"</div>"
"<script>"
"const fileIn=document.getElementById('fileInput'),dropArea=document.getElementById('dropArea'),fileCard=document.getElementById('fileCard'),fileName=document.getElementById('fileNameText'),fileSize=document.getElementById('fileSizeText'),progSec=document.getElementById('progressSection'),progFill=document.getElementById('progressFill'),progPct=document.getElementById('progressPct'),progStatus=document.getElementById('progressStatus'),bytesText=document.getElementById('bytesTransferred'),btnUp=document.getElementById('btnUpload'),msg=document.getElementById('statusMsg'),statusDot=document.getElementById('statusDot');"
"function showMsg(text,type){msg.className='msg-box msg-'+(type||'info');msg.textContent=text;msg.style.display='block';}"
"function formatBytes(bytes){if(bytes<1024)return bytes+' B';if(bytes<1048576)return(bytes/1024).toFixed(1)+' KB';return(bytes/1048576).toFixed(2)+' MB';}"
"function fileChosen(){if(fileIn.files.length>0){const f=fileIn.files[0];fileName.textContent=f.name;fileSize.textContent=formatBytes(f.size);dropArea.style.display='none';fileCard.style.display='flex';btnUp.disabled=false;showMsg('Ready to flash \"'+f.name+'\" ('+formatBytes(f.size)+'). Click \"Start Firmware Update\" to proceed.','info');}}"
"['dragenter','dragover'].forEach(ev=>{dropArea.addEventListener(ev,e=>{e.preventDefault();dropArea.classList.add('dragover');});});"
"['dragleave','drop'].forEach(ev=>{dropArea.addEventListener(ev,e=>{e.preventDefault();dropArea.classList.remove('dragover');});});"
"dropArea.addEventListener('drop',e=>{if(e.dataTransfer.files.length>0){fileIn.files=e.dataTransfer.files;fileChosen();}});"
"function startUpload(e){e.preventDefault();if(!fileIn.files.length)return;const file=fileIn.files[0];btnUp.disabled=true;btnUp.textContent='Flashing in Progress...';progSec.style.display='flex';progFill.style.width='0%';progPct.textContent='0%';showMsg('Uploading firmware payload... Please keep device powered on.','info');const xhr=new XMLHttpRequest();xhr.open('POST','/ota',true);xhr.setRequestHeader('Content-Type','application/octet-stream');xhr.upload.onprogress=ev=>{if(ev.lengthComputable){const pct=Math.round((ev.loaded/ev.total)*100);progFill.style.width=pct+'%';progPct.textContent=pct+'%';progStatus.textContent=pct<100?'Uploading firmware binary...':'Writing flash & verifying integrity...';bytesText.textContent=formatBytes(ev.loaded)+' / '+formatBytes(ev.total);}};"
"xhr.onload=()=>{if(xhr.status===200){progFill.style.width='100%';progPct.textContent='100%';progStatus.textContent='OTA Flash Complete!';statusDot.style.background='#00e676';showMsg('Firmware updated successfully! Device is now rebooting... Please wait 5 seconds and refresh.','success');let countdown=5;setInterval(()=>{if(countdown>0){progStatus.textContent='Rebooting in '+countdown+'s...';countdown--;}else{window.location.href='/';}},1000);}else{statusDot.style.background='#ff5252';showMsg('OTA Flash Failed: '+(xhr.responseText||'Server Error '+xhr.status),'error');btnUp.disabled=false;btnUp.textContent='Retry Firmware Update';}};"
"xhr.onerror=()=>{statusDot.style.background='#ff5252';showMsg('Upload network error occurred. Please verify Wi-Fi connection and retry.','error');btnUp.disabled=false;btnUp.textContent='Retry Firmware Update';};"
"xhr.send(file);}"
"</script></body></html>";
static esp_err_t ota_get(httpd_req_t *request)
{
const char *page = "<!doctype html><html><head><meta name=viewport content=\"width=device-width\"><title>ABrobot OTA</title><style>body{font:16px sans-serif;max-width:480px;margin:2em auto;padding:0 1em}input,button{font:inherit;padding:.6em;width:100%;box-sizing:border-box}button{margin-top:1em}progress{width:100%;margin-top:1em}</style></head><body><h1>Firmware update</h1><form id=form><input id=file type=file accept=\".bin,application/octet-stream\" required><button type=submit>Upload firmware</button></form><progress id=progress value=0 max=100 hidden></progress><p id=message></p><script>form.onsubmit=async e=>{e.preventDefault();if(!file.files.length)return;progress.hidden=false;message.textContent='Uploading...';try{let r=await fetch('/ota',{method:'POST',headers:{'Content-Type':'application/octet-stream'},body:file.files[0]});if(!r.ok)throw Error(await r.text());progress.value=100;message.textContent='Update installed. Rebooting...'}catch(e){message.textContent='Update failed: '+e.message;progress.hidden=true}}</script></body></html>";
httpd_resp_set_type(request, "text/html");
return httpd_resp_send(request, page, HTTPD_RESP_USE_STRLEN);
return httpd_resp_send(request, OTA_HTML, HTTPD_RESP_USE_STRLEN);
}
static esp_err_t ota_post(httpd_req_t *request)
+360
View File
@@ -0,0 +1,360 @@
#include "web_server_pages.h"
#include "sys_uart.h"
#include "esp_log.h"
#include "esp_http_server.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
static const char *TAG = "SYS_WEB_SERIAL";
static const char SERIAL_HTML[] =
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\">"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">"
"<title>ABrobot Web Serial Terminal</title>"
"<style>"
":root{--bg:#0b0f17;--card:#141b26;--border:#243042;--text:#e6edf3;--accent:#00d2ff;--accent2:#3a7bd5;--green:#00e676;--amber:#ffb300;--term-bg:#080b10;}"
"*{box-sizing:border-box;margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;}"
"body{background:var(--bg);color:var(--text);padding:14px;display:flex;justify-content:center;min-height:100vh;}"
".container{width:100%;max-width:920px;display:flex;flex-direction:column;gap:12px;}"
".header{background:linear-gradient(135deg,rgba(58,123,213,0.18),rgba(0,210,255,0.08));border:1px solid var(--border);border-radius:12px;padding:12px 18px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;}"
".title-group{display:flex;align-items:center;gap:10px;}"
".status-dot{width:10px;height:10px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);transition:background 0.3s;}"
".title{font-size:1.15rem;font-weight:700;letter-spacing:0.5px;background:linear-gradient(90deg,#00d2ff,#3a7bd5);-webkit-background-clip:text;-webkit-text-fill-color:transparent;}"
".nav-links{display:flex;gap:8px;align-items:center;}"
".nav-links a{color:#8b949e;text-decoration:none;font-size:0.85rem;padding:6px 12px;border-radius:6px;transition:all 0.2s;}"
".nav-links a:hover{color:var(--text);background:rgba(255,255,255,0.05);}"
".nav-links a.active{color:var(--accent);font-weight:600;background:rgba(0,210,255,0.1);border:1px solid rgba(0,210,255,0.25);}"
".toolbar{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;}"
".tool-group{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}"
"label{font-size:0.82rem;color:#8b949e;}"
"select,button,input{background:#0a0e14;color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px;font-size:0.86rem;outline:none;transition:border-color 0.2s;}"
"select:focus,input:focus{border-color:var(--accent);}"
"button{cursor:pointer;background:linear-gradient(180deg,#21262d,#161b22);border-color:#30363d;font-weight:500;}"
"button:hover{background:#30363d;border-color:#8b949e;}"
"button.primary{background:linear-gradient(135deg,var(--accent2),var(--accent));border:none;color:#0a0d14;font-weight:700;}"
"button.primary:hover{opacity:0.9;}"
".badge{background:#1a2332;border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:0.75rem;font-family:monospace;color:#58a6ff;}"
"#terminal{background:var(--term-bg);border:1px solid var(--border);border-radius:10px;height:420px;overflow-y:auto;padding:12px;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;font-size:0.86rem;line-height:1.45;white-space:pre-wrap;word-break:break-all;box-shadow:inset 0 2px 8px rgba(0,0,0,0.6);}"
".log-rx{color:#7ee787;}"
".log-tx{color:#f6d365;}"
".log-sys{color:#79c0ff;font-style:italic;}"
".log-hex{color:#d2a8ff;}"
".input-bar{display:flex;gap:8px;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:10px;flex-wrap:wrap;}"
"#cmdInput{flex:1;min-width:200px;font-family:monospace;}"
".macros{display:flex;gap:6px;flex-wrap:wrap;align-items:center;}"
".macros button{font-size:0.75rem;padding:4px 8px;}"
"@media(max-width:600px){#terminal{height:300px;}}"
"</style></head><body>"
"<div class=\"container\">"
"<div class=\"header\">"
" <div class=\"title-group\">"
" <div class=\"status-dot\" id=\"statusDot\"></div>"
" <div class=\"title\">ESP32-C3 Web Serial Terminal</div>"
" </div>"
" <div class=\"nav-links\">"
" <a href=\"/\">Wi-Fi Setup</a>"
" <a href=\"/serial\" class=\"active\">Serial Terminal</a>"
" <a href=\"/ota\">OTA Update</a>"
" </div>"
"</div>"
"<div class=\"toolbar\">"
" <div class=\"tool-group\">"
" <label for=\"baudSelect\">Baud:</label>"
" <select id=\"baudSelect\" onchange=\"changeBaud()\">"
" <option value=\"9600\">9600</option>"
" <option value=\"19200\">19200</option>"
" <option value=\"38400\">38400</option>"
" <option value=\"57600\">57600</option>"
" <option value=\"115200\" selected>115200</option>"
" <option value=\"230400\">230400</option>"
" <option value=\"460800\">460800</option>"
" <option value=\"921600\">921600</option>"
" </select>"
" <label for=\"viewMode\">View:</label>"
" <select id=\"viewMode\" onchange=\"clearTerminal()\">"
" <option value=\"ascii\" selected>ASCII Text</option>"
" <option value=\"hex\">HEX Dump</option>"
" </select>"
" <button onclick=\"clearTerminal()\">Clear</button>"
" <button id=\"scrollBtn\" onclick=\"toggleAutoScroll()\">Scroll: ON</button>"
" </div>"
" <div class=\"tool-group\">"
" <span class=\"badge\" id=\"rxStat\">RX: 0 B</span>"
" <span class=\"badge\" id=\"txStat\">TX: 0 B</span>"
" <button onclick=\"exportLog()\">Save Log</button>"
" </div>"
"</div>"
"<div id=\"terminal\"></div>"
"<div class=\"input-bar\">"
" <select id=\"sendFormat\">"
" <option value=\"text\" selected>Text</option>"
" <option value=\"hex\">HEX (e.g. 41 42 FF)</option>"
" </select>"
" <select id=\"lineEnding\">"
" <option value=\"\\r\\n\" selected>CRLF (\\r\\n)</option>"
" <option value=\"\\n\">LF (\\n)</option>"
" <option value=\"\\r\">CR (\\r)</option>"
" <option value=\"\">None</option>"
" </select>"
" <input type=\"text\" id=\"cmdInput\" placeholder=\"Type command to send over TX (GPIO 21)...\" autocomplete=\"off\" autofocus>"
" <button class=\"primary\" onclick=\"sendCmd()\">Send</button>"
"</div>"
"<div class=\"macros\">"
" <span style=\"font-size:0.75rem;color:#8b949e;\">Quick:</span>"
" <button onclick=\"sendMacro('PING')\">PING</button>"
" <button onclick=\"sendMacro('AT')\">AT</button>"
" <button onclick=\"sendMacro('HELP')\">HELP</button>"
" <button onclick=\"sendMacro('STATUS')\">STATUS</button>"
" <button onclick=\"sendMacro('RESET')\">RESET</button>"
"</div>"
"</div>"
"<script>"
"let seq = 0, autoScroll = true, history = [], histIdx = -1;"
"const term = document.getElementById('terminal');"
"const cmdIn = document.getElementById('cmdInput');"
"const rxStat = document.getElementById('rxStat');"
"const txStat = document.getElementById('txStat');"
"const baudSel = document.getElementById('baudSelect');"
"const viewMode = document.getElementById('viewMode');"
"const sendFmt = document.getElementById('sendFormat');"
"const statusDot = document.getElementById('statusDot');"
"function log(text, cls) {"
" const span = document.createElement('span');"
" span.className = cls || '';"
" span.textContent = text;"
" term.appendChild(span);"
" if (autoScroll) term.scrollTop = term.scrollHeight;"
"}"
"function toggleAutoScroll() {"
" autoScroll = !autoScroll;"
" document.getElementById('scrollBtn').textContent = 'Scroll: ' + (autoScroll ? 'ON' : 'OFF');"
"}"
"async function clearTerminal() {"
" term.innerHTML = '';"
" try { await fetch('/serial/clear', {method: 'POST'}); } catch(e){}"
" log('[Terminal buffer cleared]\\n', 'log-sys');"
"}"
"function exportLog() {"
" const blob = new Blob([term.textContent], {type: 'text/plain'});"
" const a = document.createElement('a');"
" a.href = URL.createObjectURL(blob);"
" a.download = 'serial_log_' + new Date().toISOString().replace(/[:.]/g,'-') + '.txt';"
" a.click();"
"}"
"async function pollData() {"
" try {"
" const res = await fetch('/serial/data?seq=' + seq);"
" if (res.ok) {"
" statusDot.style.background = '#00e676';"
" statusDot.style.boxShadow = '0 0 8px #00e676';"
" const data = await res.json();"
" if (data.seq !== undefined) seq = data.seq;"
" if (data.rx !== undefined) rxStat.textContent = 'RX: ' + formatBytes(data.rx);"
" if (data.tx !== undefined) txStat.textContent = 'TX: ' + formatBytes(data.tx);"
" if (data.baud !== undefined && baudSel.value != data.baud) baudSel.value = data.baud;"
" if (data.hex && data.hex.length > 0) {"
" if (viewMode.value === 'hex') {"
" let hexStr = '';"
" for (let i = 0; i < data.hex.length; i++) {"
" hexStr += data.hex[i].toString(16).padStart(2, '0').toUpperCase() + ' ';"
" if ((i + 1) % 16 === 0) hexStr += '\\n';"
" }"
" log(hexStr, 'log-hex');"
" } else {"
" if (data.text) log(data.text, 'log-rx');"
" }"
" }"
" } else {"
" statusDot.style.background = '#ff5252';"
" statusDot.style.boxShadow = '0 0 8px #ff5252';"
" }"
" } catch (e) {"
" statusDot.style.background = '#ff5252';"
" statusDot.style.boxShadow = '0 0 8px #ff5252';"
" }"
" setTimeout(pollData, 120);"
"}"
"function formatBytes(bytes) {"
" if (bytes < 1024) return bytes + ' B';"
" if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';"
" return (bytes / 1048576).toFixed(2) + ' MB';"
"}"
"async function sendCmd() {"
" let val = cmdIn.value;"
" if (val === '') return;"
" history.push(val);"
" histIdx = history.length;"
" cmdIn.value = '';"
" let payload = '';"
" if (sendFmt.value === 'hex') {"
" let bytes = val.trim().split(/[\\s,]+/).map(h => parseInt(h, 16)).filter(n => !isNaN(n));"
" let binStr = String.fromCharCode(...bytes);"
" payload = binStr;"
" log('-> [HEX: ' + val + ']\\n', 'log-tx');"
" } else {"
" const le = document.getElementById('lineEnding').value;"
" payload = val + le.replace(/\\\\r/g, '\\r').replace(/\\\\n/g, '\\n');"
" log('-> ' + val + '\\n', 'log-tx');"
" }"
" try {"
" await fetch('/serial/tx', {method: 'POST', body: payload});"
" } catch (e) {"
" log('[TX Error: ' + e.message + ']\\n', 'log-sys');"
" }"
"}"
"function sendMacro(cmd) {"
" cmdIn.value = cmd;"
" sendCmd();"
"}"
"async function changeBaud() {"
" const b = baudSel.value;"
" log('[Switching Baud to ' + b + '...]\\n', 'log-sys');"
" try {"
" await fetch('/serial/baud', {method: 'POST', body: b});"
" await clearTerminal();"
" } catch (e) {"
" log('[Failed to set baud: ' + e.message + ']\\n', 'log-sys');"
" }"
"}"
"cmdIn.addEventListener('keydown', e => {"
" if (e.key === 'Enter') { sendCmd(); }"
" else if (e.key === 'ArrowUp') {"
" if (histIdx > 0) { histIdx--; cmdIn.value = history[histIdx]; }"
" } else if (e.key === 'ArrowDown') {"
" if (histIdx < history.length - 1) { histIdx++; cmdIn.value = history[histIdx]; }"
" else { histIdx = history.length; cmdIn.value = ''; }"
" }"
"});"
"log('[ABrobot Web Serial Connected on TX:GPIO21 / RX:GPIO20 (Internal Pull-Up Active)]\\n', 'log-sys');"
"try{fetch('/time/sync?epoch='+Math.floor(Date.now()/1000));}catch(e){}pollData();"
"</script></body></html>";
static esp_err_t serial_page_get(httpd_req_t *request)
{
httpd_resp_set_type(request, "text/html");
return httpd_resp_send(request, SERIAL_HTML, HTTPD_RESP_USE_STRLEN);
}
static esp_err_t serial_data_get(httpd_req_t *request)
{
char query[64] = {0};
uint32_t from_seq = 0;
if (httpd_req_get_url_query_str(request, query, sizeof(query)) == ESP_OK) {
char val[16] = {0};
if (httpd_query_key_value(query, "seq", val, sizeof(val)) == ESP_OK) {
from_seq = (uint32_t)strtoul(val, NULL, 10);
}
}
uint8_t rx_delta[1024];
uint32_t new_seq = from_seq;
size_t len = sys_uart_read_delta(from_seq, rx_delta, sizeof(rx_delta) - 1, &new_seq);
rx_delta[len] = '\0';
uint32_t total_rx = 0, total_tx = 0;
sys_uart_get_stats(&total_rx, &total_tx);
uint32_t baud = sys_uart_get_baud_rate();
httpd_resp_set_type(request, "application/json");
/* Begin JSON response */
char prefix[128];
int p_len = snprintf(prefix, sizeof(prefix), "{\"seq\":%lu,\"rx\":%lu,\"tx\":%lu,\"baud\":%lu,\"text\":\"",
(unsigned long)new_seq, (unsigned long)total_rx, (unsigned long)total_tx, (unsigned long)baud);
httpd_resp_send_chunk(request, prefix, p_len);
/* Stream escaped JSON text */
for (size_t i = 0; i < len; i++) {
uint8_t c = rx_delta[i];
if (c == '"') {
httpd_resp_send_chunk(request, "\\\"", 2);
} else if (c == '\\') {
httpd_resp_send_chunk(request, "\\\\", 2);
} else if (c == '\n') {
httpd_resp_send_chunk(request, "\\n", 2);
} else if (c == '\r') {
httpd_resp_send_chunk(request, "\\r", 2);
} else if (c == '\t') {
httpd_resp_send_chunk(request, "\\t", 2);
} else if (c < 32 || c > 126) {
/* Replace non-printable ASCII in text view with dot or hex escape */
char hex_esc[8];
int h_len = snprintf(hex_esc, sizeof(hex_esc), "\\u%04x", c);
httpd_resp_send_chunk(request, hex_esc, h_len);
} else {
char ch[2] = {(char)c, '\0'};
httpd_resp_send_chunk(request, ch, 1);
}
}
/* Stream raw byte array for HEX dump view */
httpd_resp_send_chunk(request, "\",\"hex\":[", 9);
for (size_t i = 0; i < len; i++) {
char num[8];
int n_len = snprintf(num, sizeof(num), "%s%u", (i > 0 ? "," : ""), (unsigned int)rx_delta[i]);
httpd_resp_send_chunk(request, num, n_len);
}
httpd_resp_send_chunk(request, "]}", 2);
return httpd_resp_send_chunk(request, NULL, 0);
}
static esp_err_t serial_tx_post(httpd_req_t *request)
{
char body[512] = {0};
int total_len = request->content_len;
if (total_len >= sizeof(body)) {
total_len = sizeof(body) - 1;
}
int received = httpd_req_recv(request, body, total_len);
if (received > 0) {
sys_uart_write((const uint8_t *)body, (size_t)received);
httpd_resp_sendstr(request, "OK");
return ESP_OK;
}
return httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "No TX data received");
}
static esp_err_t serial_baud_post(httpd_req_t *request)
{
char body[32] = {0};
int received = httpd_req_recv(request, body, sizeof(body) - 1);
if (received > 0) {
body[received] = '\0';
uint32_t baud = (uint32_t)strtoul(body, NULL, 10);
if (baud > 0) {
esp_err_t err = sys_uart_set_baud_rate(baud);
if (err == ESP_OK) {
httpd_resp_sendstr(request, "OK");
return ESP_OK;
}
}
}
return httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "Invalid baud rate");
}
static esp_err_t serial_clear_post(httpd_req_t *request)
{
sys_uart_clear_rx_buffer();
httpd_resp_sendstr(request, "OK");
return ESP_OK;
}
void web_server_register_serial_pages(httpd_handle_t server)
{
const httpd_uri_t page = {.uri = "/serial", .method = HTTP_GET, .handler = serial_page_get};
const httpd_uri_t data = {.uri = "/serial/data", .method = HTTP_GET, .handler = serial_data_get};
const httpd_uri_t tx = {.uri = "/serial/tx", .method = HTTP_POST, .handler = serial_tx_post};
const httpd_uri_t baud = {.uri = "/serial/baud", .method = HTTP_POST, .handler = serial_baud_post};
const httpd_uri_t clear = {.uri = "/serial/clear", .method = HTTP_POST, .handler = serial_clear_post};
httpd_register_uri_handler(server, &page);
httpd_register_uri_handler(server, &data);
httpd_register_uri_handler(server, &tx);
httpd_register_uri_handler(server, &baud);
httpd_register_uri_handler(server, &clear);
ESP_LOGI(TAG, "Web Serial Terminal endpoints registered at /serial");
}
+111 -3
View File
@@ -1,4 +1,4 @@
#include "web_server_pages.h"
#include "web_server_pages.h"
#include "sdkconfig.h"
#include "esp_log.h"
#include "esp_wifi.h"
@@ -11,6 +11,115 @@ static const char *TAG = "SYS_WEB_WIFI";
static char s_ssid[33];
static char s_password[65];
static const char WIFI_HTML[] =
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\">"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">"
"<title>ABrobot Wi-Fi Manager</title>"
"<style>"
":root{--bg:#0b0f17;--card:#141b26;--border:#243042;--text:#e6edf3;--muted:#8b949e;--accent:#00d2ff;--accent2:#3a7bd5;--green:#00e676;--amber:#ffb300;--red:#ff5252;}"
"*{box-sizing:border-box;margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;}"
"body{background:var(--bg);color:var(--text);padding:14px;display:flex;justify-content:center;min-height:100vh;}"
".container{width:100%;max-width:920px;display:flex;flex-direction:column;gap:14px;}"
".header{background:linear-gradient(135deg,rgba(58,123,213,0.18),rgba(0,210,255,0.08));border:1px solid var(--border);border-radius:12px;padding:12px 18px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;}"
".title-group{display:flex;align-items:center;gap:10px;}"
".status-dot{width:10px;height:10px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);}"
".title{font-size:1.15rem;font-weight:700;letter-spacing:0.5px;background:linear-gradient(90deg,#00d2ff,#3a7bd5);-webkit-background-clip:text;-webkit-text-fill-color:transparent;}"
".nav-links{display:flex;gap:8px;align-items:center;}"
".nav-links a{color:var(--muted);text-decoration:none;font-size:0.85rem;padding:6px 12px;border-radius:6px;transition:all 0.2s;}"
".nav-links a:hover{color:var(--text);background:rgba(255,255,255,0.05);}"
".nav-links a.active{color:var(--accent);font-weight:600;background:rgba(0,210,255,0.1);border:1px solid rgba(0,210,255,0.25);}"
".card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px;display:flex;flex-direction:column;gap:16px;box-shadow:0 4px 16px rgba(0,0,0,0.3);}"
".card-title{font-size:1rem;font-weight:600;color:var(--text);display:flex;align-items:center;gap:8px;}"
".form-group{display:flex;flex-direction:column;gap:6px;}"
"label{font-size:0.84rem;color:var(--muted);font-weight:500;display:flex;justify-content:space-between;align-items:center;}"
"select,input[type='text'],input[type='password']{background:#0a0e14;color:var(--text);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:0.9rem;outline:none;width:100%;transition:border-color 0.2s,box-shadow 0.2s;}"
"select:focus,input[type='text']:focus,input[type='password']:focus{border-color:var(--accent);box-shadow:0 0 8px rgba(0,210,255,0.25);}"
"button{cursor:pointer;background:linear-gradient(180deg,#21262d,#161b22);border:1px solid #30363d;border-radius:8px;color:var(--text);padding:9px 16px;font-size:0.88rem;font-weight:600;transition:all 0.2s;display:inline-flex;align-items:center;justify-content:center;gap:6px;}"
"button:hover{background:#30363d;border-color:var(--muted);}"
"button.primary{background:linear-gradient(135deg,var(--accent2),var(--accent));border:none;color:#0a0d14;font-weight:700;padding:11px 20px;font-size:0.92rem;}"
"button.primary:hover{opacity:0.92;box-shadow:0 0 12px rgba(0,210,255,0.35);}"
"button:disabled{opacity:0.5;cursor:not-allowed;}"
".input-row{display:flex;gap:10px;align-items:center;}"
".pass-wrap{position:relative;display:flex;width:100%;}"
".pass-wrap input{padding-right:42px;}"
".pass-toggle{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;padding:4px 6px;color:var(--muted);font-size:0.9rem;cursor:pointer;border-radius:4px;}"
".pass-toggle:hover{color:var(--text);background:rgba(255,255,255,0.1);}"
".checkbox-wrap{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:0.85rem;color:var(--muted);user-select:none;}"
".checkbox-wrap input{cursor:pointer;accent-color:var(--accent);width:16px;height:16px;}"
".msg-box{padding:12px 14px;border-radius:8px;font-size:0.86rem;line-height:1.45;display:none;}"
".msg-info{background:rgba(58,123,213,0.12);border:1px solid rgba(58,123,213,0.35);color:#79c0ff;}"
".msg-success{background:rgba(0,230,118,0.12);border:1px solid rgba(0,230,118,0.35);color:#7ee787;}"
".msg-error{background:rgba(255,82,82,0.12);border:1px solid rgba(255,82,82,0.35);color:#ff7b72;}"
".badges{display:flex;gap:8px;flex-wrap:wrap;}"
".badge{background:#1a2332;border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:0.75rem;font-family:monospace;color:#58a6ff;}"
".info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px;}"
".info-item{background:#0a0e14;border:1px solid var(--border);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:4px;}"
".info-label{font-size:0.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px;}"
".info-val{font-size:0.88rem;color:var(--text);font-family:monospace;font-weight:600;}"
"</style></head><body>"
"<div class=\"container\">"
"<div class=\"header\">"
" <div class=\"title-group\">"
" <div class=\"status-dot\" id=\"statusDot\"></div>"
" <div class=\"title\">ESP32-C3 Wi-Fi Setup</div>"
" </div>"
" <div class=\"nav-links\">"
" <a href=\"/\" class=\"active\">Wi-Fi Setup</a>"
" <a href=\"/serial\">Serial Terminal</a>"
" <a href=\"/ota\">OTA Update</a>"
" </div>"
"</div>"
"<div class=\"card\">"
" <div class=\"card-title\"><span>Wireless Network Configuration</span></div>"
" <form id=\"wifiForm\" onsubmit=\"submitWifi(event)\" style=\"display:flex;flex-direction:column;gap:14px;\">"
" <div class=\"form-group\">"
" <label for=\"networks\"><span>Available Wi-Fi Networks</span><span id=\"scanCount\" class=\"badge\">Ready</span></label>"
" <div class=\"input-row\">"
" <select id=\"networks\"><option value=\"\">Scanning nearby networks...</option></select>"
" <button type=\"button\" id=\"btnScan\" onclick=\"scan()\">Refresh</button>"
" </div>"
" </div>"
" <label class=\"checkbox-wrap\">"
" <input type=\"checkbox\" id=\"hidden\" onchange=\"toggleManual()\">"
" <span>Manual / Hidden Network SSID</span>"
" </label>"
" <div class=\"form-group\" id=\"manualBox\" style=\"display:none;\">"
" <label for=\"manual\">Custom SSID</label>"
" <input type=\"text\" id=\"manual\" name=\"ssid\" maxlength=\"32\" placeholder=\"Enter Wi-Fi network name...\">"
" </div>"
" <div class=\"form-group\">"
" <label for=\"password\">Wi-Fi Password</label>"
" <div class=\"pass-wrap\">"
" <input type=\"password\" id=\"password\" name=\"password\" maxlength=\"64\" placeholder=\"Enter Wi-Fi password (WPA/WPA2)...\" required>"
" <button type=\"button\" class=\"pass-toggle\" id=\"eyeBtn\" onclick=\"togglePassword()\" title=\"Show/Hide Password\">👁️</button>"
" </div>"
" </div>"
" <button type=\"submit\" class=\"primary\" id=\"btnConnect\">Connect to Network</button>"
" </form>"
" <div id=\"statusMsg\" class=\"msg-box\"></div>"
"</div>"
"<div class=\"card\">"
" <div class=\"card-title\"><span>Device Information</span></div>"
" <div class=\"info-grid\">"
" <div class=\"info-item\"><span class=\"info-label\">Provisioning AP</span><span class=\"info-val\">ABrobot-OS</span></div>"
" <div class=\"info-item\"><span class=\"info-label\">Default AP IP</span><span class=\"info-val\">192.168.4.1</span></div>"
" <div class=\"info-item\"><span class=\"info-label\">Hardware Port</span><span class=\"info-val\">TX:GPIO21 / RX:GPIO20</span></div>"
" <div class=\"info-item\"><span class=\"info-label\">Status</span><span class=\"info-val\" style=\"color:var(--green);\">Operational</span></div>"
" </div>"
"</div>"
"</div>"
"<script>"
"const list=document.getElementById('networks'),manual=document.getElementById('manual'),manualBox=document.getElementById('manualBox'),hidden=document.getElementById('hidden'),passInput=document.getElementById('password'),msg=document.getElementById('statusMsg'),btnConnect=document.getElementById('btnConnect'),btnScan=document.getElementById('btnScan'),scanCount=document.getElementById('scanCount');"
"function showMsg(text,type){msg.className='msg-box msg-'+(type||'info');msg.textContent=text;msg.style.display='block';}"
"function toggleManual(){let on=hidden.checked;manualBox.style.display=on?'block':'none';manual.required=on;if(on){manual.value='';manual.focus();}else if(list.value)manual.value=list.value;}"
"function togglePassword(){let isPass=passInput.type==='password';passInput.type=isPass?'text':'password';document.getElementById('eyeBtn').textContent=isPass?'🔒':'👁️';}"
"async function scan(){btnScan.disabled=true;btnScan.textContent='Scanning...';scanCount.textContent='Scanning...';showMsg('Scanning for 2.4 GHz Wi-Fi access points...','info');try{const res=await fetch('/scan');if(!res.ok)throw new Error('HTTP '+res.status);const data=await res.json();list.innerHTML='';if(!data.length){list.innerHTML='<option value=\"\">No networks found</option>';scanCount.textContent='0 APs';showMsg('No networks found. Check router or use manual SSID entry.','error');}else{data.forEach(n=>{const opt=document.createElement('option');opt.value=n.ssid;const qual=n.rssi>-60?'Strong':(n.rssi>-75?'Good':'Weak');opt.textContent=n.ssid+' ('+n.rssi+' dBm - '+qual+')';list.appendChild(opt);});scanCount.textContent=data.length+' APs';if(!hidden.checked)manual.value=list.value;showMsg('Found '+data.length+' Wi-Fi network(s). Select your network and enter credentials.','success');}}catch(e){scanCount.textContent='Error';showMsg('Wi-Fi scan failed: '+e.message,'error');}finally{btnScan.disabled=false;btnScan.textContent='Refresh';}}"
"list.onchange=()=>{if(!hidden.checked)manual.value=list.value;};"
"async function submitWifi(e){e.preventDefault();const ssidVal=manual.value.trim();const passVal=passInput.value;if(!ssidVal){showMsg('Please select or enter an SSID network name.','error');return;}btnConnect.disabled=true;btnConnect.textContent='Connecting...';showMsg('Saving credentials and connecting to \"'+ssidVal+'\"...','info');try{const body='ssid='+encodeURIComponent(ssidVal)+'&password='+encodeURIComponent(passVal);const res=await fetch('/configure',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body});if(res.ok){showMsg('Credentials saved for \"'+ssidVal+'\". ESP32 is switching to Station mode. Please connect your device to \"'+ssidVal+'\".','success');}else{const err=await res.text();showMsg('Connection failed: '+err,'error');btnConnect.disabled=false;btnConnect.textContent='Connect to Network';}}catch(e){showMsg('Credentials saved for \"'+ssidVal+'\". ESP32 is connecting to Wi-Fi. Please connect to \"'+ssidVal+'\".','success');}}"
"if(!hidden.checked&&list.value)manual.value=list.value;"
"try{fetch('/time/sync?epoch='+Math.floor(Date.now()/1000));}catch(e){}scan();"
"</script></body></html>";
static void copy_string(uint8_t *destination, size_t size, const char *source)
{
size_t length = strlen(source);
@@ -64,9 +173,8 @@ static void append_json_string(httpd_req_t *request, const uint8_t *value, size_
static esp_err_t root_get(httpd_req_t *request)
{
const char *page = "<!doctype html><html><head><meta name=viewport content=\"width=device-width\"><title>ABrobot Wi-Fi</title><style>body{font:16px sans-serif;max-width:480px;margin:2em auto;padding:0 1em}input,select,button{font:inherit;padding:.5em;width:100%;box-sizing:border-box}button{margin-top:1em}#message{margin-top:1em}</style></head><body><h1>ABrobot Wi-Fi setup</h1><form id=form method=post action=/configure><label>Network<br><select id=networks><option value=\"\">Scanning...</option></select></label><p><label><input type=checkbox id=hidden onchange=toggleManual()> Hidden network</label></p><div id=manualBox style=display:none><label>Network name<br><input id=manual name=ssid maxlength=32></label></div><br><br><label>Password<br><input name=password type=password maxlength=64 required></label><br><br><button type=button onclick=scan()>Refresh networks</button><button type=submit>Connect</button></form><p id=message></p><script>const list=document.getElementById('networks'),manual=document.getElementById('manual'),message=document.getElementById('message');function toggleManual(){let on=hidden.checked;manualBox.style.display=on?'block':'none';manual.required=on;if(on){manual.value='';manual.focus()}else if(list.value)manual.value=list.value}form.onsubmit=()=>{if(!manual.value){message.textContent='Select a network or enable Hidden network';return false}return true};function scan(){fetch('/scan').then(r=>r.json()).then(a=>{list.innerHTML='';a.forEach(n=>{let o=document.createElement('option');o.value=n.ssid;o.textContent=n.ssid+' ('+n.rssi+' dBm)';list.appendChild(o)});if(!a.length)list.innerHTML='<option value=\"\">No networks found</option>';if(a.length&&!hidden.checked)manual.value=list.value}).catch(()=>message.textContent='Scan failed')}list.onchange=()=>{if(!hidden.checked)manual.value=list.value};scan();</script></body></html>";
httpd_resp_set_type(request, "text/html");
return httpd_resp_send(request, page, HTTPD_RESP_USE_STRLEN);
return httpd_resp_send(request, WIFI_HTML, HTTPD_RESP_USE_STRLEN);
}
static esp_err_t scan_get(httpd_req_t *request)
+44 -2
View File
@@ -1,21 +1,63 @@
#include "web_server.h"
#include "web_server.h"
#include "web_server_pages.h"
#include "sys_time.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
static const char *TAG = "SYS_WEB";
static httpd_handle_t s_server;
static esp_err_t time_sync_handler(httpd_req_t *req)
{
char query[128] = {0};
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
char val[32] = {0};
if (httpd_query_key_value(query, "epoch", val, sizeof(val)) == ESP_OK) {
time_t epoch = (time_t)strtoull(val, NULL, 10);
if (epoch >= 1704067200ULL) {
sys_time_set_epoch(epoch);
}
}
}
struct tm tm_info;
bool synced = sys_time_get_local(&tm_info);
time_t now = sys_time_get_epoch();
char strftime_buf[64];
strftime(strftime_buf, sizeof(strftime_buf), "%Y-%m-%d %H:%M:%S", &tm_info);
char resp[160];
snprintf(resp, sizeof(resp), "{\"synced\":%s,\"epoch\":%lld,\"time\":\"%s\",\"tz\":\"%s\"}",
synced ? "true" : "false", (long long)now, strftime_buf, sys_time_get_timezone());
httpd_resp_set_type(req, "application/json");
return httpd_resp_sendstr(req, resp);
}
esp_err_t web_server_start(void)
{
if (s_server) return ESP_OK;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.max_uri_handlers = 16;
config.stack_size = 8192;
esp_err_t err = httpd_start(&s_server, &config);
if (err != ESP_OK) return err;
web_server_register_wifi_pages(s_server);
web_server_register_ota_page(s_server);
ESP_LOGI(TAG, "Web server started");
web_server_register_serial_pages(s_server);
const httpd_uri_t time_uri = {
.uri = "/time/sync",
.method = HTTP_GET,
.handler = time_sync_handler
};
httpd_register_uri_handler(s_server, &time_uri);
ESP_LOGI(TAG, "Web server started with real-time sync service");
return ESP_OK;
}
+1
View File
@@ -5,6 +5,7 @@ idf_component_register(
"apps/app_sysmon.c"
"apps/app_settings.c"
"apps/app_game.c"
"apps/app_serial.c"
INCLUDE_DIRS
"."
"apps"
+66 -18
View File
@@ -1,11 +1,14 @@
#include "apps.h"
#include "os_gfx.h"
#include "os_status.h"
#include "sys_time.h"
#include <stdio.h>
#include <time.h>
#include "esp_timer.h"
static uint8_t s_view_mode = 0;
static const char *const s_days[] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"};
static const char *const s_months[] = {"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"};
static void clock_init(void)
{
@@ -23,41 +26,86 @@ static void clock_update(uint32_t delta_ms)
sys_gfx_draw_status_bar(&status);
uint32_t sec = status.uptime_sec % 60;
uint32_t min = (status.uptime_sec / 60) % 60;
uint32_t hrs = (status.uptime_sec / 3600);
struct tm tm_info;
bool is_synced = sys_time_get_local(&tm_info);
char time_str[16];
char sub_str[20];
uint32_t sec, min, hrs;
if (is_synced) {
sec = tm_info.tm_sec;
min = tm_info.tm_min;
hrs = tm_info.tm_hour;
} else {
sec = status.uptime_sec % 60;
min = (status.uptime_sec / 60) % 60;
hrs = (status.uptime_sec / 3600);
}
char time_str[32];
char sub_str[48];
if (s_view_mode == 0) {
// Mode 0: Digital Clock (HH:MM:SS) + Date / Status
snprintf(time_str, sizeof(time_str), "%02lu:%02lu:%02lu", (unsigned long)hrs, (unsigned long)min, (unsigned long)sec);
sys_gfx_draw_str_5x7(12, 11, time_str, COLOR_WHITE);
snprintf(sub_str, sizeof(sub_str), "RAM:%luK", (unsigned long)(status.free_heap / 1024));
sys_gfx_draw_str_3x5(4, 21, sub_str, COLOR_WHITE);
snprintf(sub_str, sizeof(sub_str), "CPU:160M");
sys_gfx_draw_str_3x5(40, 21, sub_str, COLOR_WHITE);
if (is_synced) {
int wday = tm_info.tm_wday % 7;
int mon = tm_info.tm_mon % 12;
int mday = tm_info.tm_mday % 100;
int year = (tm_info.tm_year + 1900) % 100;
snprintf(sub_str, sizeof(sub_str), "%s %02d-%s-%02d", s_days[wday], mday, s_months[mon], year);
sys_gfx_draw_str_3x5(9, 21, sub_str, COLOR_WHITE);
} else {
snprintf(sub_str, sizeof(sub_str), "NTP: WAITING...");
sys_gfx_draw_str_3x5(8, 21, sub_str, COLOR_WHITE);
}
sys_gfx_draw_progress_bar(4, 27, 64, 4, (uint8_t)((sec * 100) / 59));
sys_gfx_draw_action_bar("C:MODE", "H:HOME");
} else if (s_view_mode == 1) {
snprintf(time_str, sizeof(time_str), "%02lu:%02lu", (unsigned long)min, (unsigned long)sec);
sys_gfx_draw_str_5x7(18, 14, time_str, COLOR_WHITE);
sys_gfx_draw_str_5x7(19, 14, time_str, COLOR_WHITE);
// Mode 1: Large Minimalist Time (HH:MM) + Seconds + Date
snprintf(time_str, sizeof(time_str), "%02lu:%02lu", (unsigned long)hrs, (unsigned long)min);
sys_gfx_draw_str_5x7(16, 13, time_str, COLOR_WHITE);
sys_gfx_draw_str_5x7(17, 13, time_str, COLOR_WHITE); // Bold effect
snprintf(sub_str, sizeof(sub_str), "UP:%luh %lum", (unsigned long)hrs, (unsigned long)min);
sys_gfx_draw_str_3x5(12, 24, sub_str, COLOR_WHITE);
char sec_str[16];
snprintf(sec_str, sizeof(sec_str), ":%02lu", (unsigned long)sec);
sys_gfx_draw_str_3x5(49, 14, sec_str, COLOR_WHITE);
if (is_synced) {
int wday = tm_info.tm_wday % 7;
int mon = tm_info.tm_mon % 12;
int mday = tm_info.tm_mday % 100;
snprintf(sub_str, sizeof(sub_str), "%s %02d %s", s_days[wday], mday, s_months[mon]);
sys_gfx_draw_str_3x5(14, 23, sub_str, COLOR_WHITE);
} else {
snprintf(sub_str, sizeof(sub_str), "UP:%luh %lum (SYNC..)", (unsigned long)hrs, (unsigned long)min);
sys_gfx_draw_str_3x5(4, 23, sub_str, COLOR_WHITE);
}
sys_gfx_draw_action_bar("C:MODE", "H:HOME");
} else {
snprintf(sub_str, sizeof(sub_str), "UP:%02lu:%02lu:%02lu", (unsigned long)hrs, (unsigned long)min, (unsigned long)sec);
// Mode 2: Detailed System Clock & Telemetry Diagnostics
if (is_synced) {
snprintf(sub_str, sizeof(sub_str), "TIME:%02lu:%02lu:%02lu", (unsigned long)hrs, (unsigned long)min, (unsigned long)sec);
sys_gfx_draw_str_3x5(3, 10, sub_str, COLOR_WHITE);
snprintf(sub_str, sizeof(sub_str), "HEAP:%lu B", (unsigned long)status.free_heap);
int year = (tm_info.tm_year + 1900) % 10000;
int mon = (tm_info.tm_mon + 1) % 100;
int mday = tm_info.tm_mday % 100;
snprintf(sub_str, sizeof(sub_str), "DATE:%04d-%02d-%02d", year, mon, mday);
sys_gfx_draw_str_3x5(3, 17, sub_str, COLOR_WHITE);
} else {
snprintf(sub_str, sizeof(sub_str), "NTP: NOT SYNCED");
sys_gfx_draw_str_3x5(3, 10, sub_str, COLOR_WHITE);
snprintf(sub_str, sizeof(sub_str), "MINH:%lu B", (unsigned long)status.min_free_heap);
snprintf(sub_str, sizeof(sub_str), "HEAP:%lu KB", (unsigned long)(status.free_heap / 1024));
sys_gfx_draw_str_3x5(3, 17, sub_str, COLOR_WHITE);
}
uint32_t up_sec = status.uptime_sec % 60;
uint32_t up_min = (status.uptime_sec / 60) % 60;
uint32_t up_hrs = (status.uptime_sec / 3600);
snprintf(sub_str, sizeof(sub_str), "UP: %02lu:%02lu:%02lu", (unsigned long)up_hrs, (unsigned long)up_min, (unsigned long)up_sec);
sys_gfx_draw_str_3x5(3, 24, sub_str, COLOR_WHITE);
sys_gfx_draw_action_bar("C:MODE", "H:HOME");
+35 -19
View File
@@ -13,21 +13,27 @@ typedef enum {
} game_state_t;
static game_state_t s_game_state = GAME_STATE_READY;
static int16_t s_player_y = GROUND_Y;
static int16_t s_player_vy = 0;
/* Fixed-point 8-bit physics for smooth 60 FPS jump arcs */
#define FP_SHIFT 8
#define TO_FP(val) ((int32_t)((val) << FP_SHIFT))
#define FROM_FP(val) ((int16_t)((val) >> FP_SHIFT))
static int32_t s_player_y_fp = TO_FP(GROUND_Y);
static int32_t s_player_vy_fp = 0;
static bool s_is_jumping = false;
static int16_t s_obs_x = 72;
static int32_t s_obs_x_fp = TO_FP(72);
static int16_t s_obs_h = 6;
static uint16_t s_score = 0;
static uint16_t s_high_score = 0;
static void game_reset(void)
{
s_player_y = GROUND_Y;
s_player_vy = 0;
s_player_y_fp = TO_FP(GROUND_Y);
s_player_vy_fp = 0;
s_is_jumping = false;
s_obs_x = 72;
s_obs_x_fp = TO_FP(72);
s_obs_h = 5 + (rand() % 4);
s_score = 0;
}
@@ -58,33 +64,43 @@ static void game_update(uint32_t delta_ms)
}
if (s_game_state == GAME_STATE_PLAYING) {
// 60 FPS Jump Physics
if (s_is_jumping) {
s_player_y += s_player_vy;
s_player_vy += 1;
if (s_player_y >= GROUND_Y) {
s_player_y = GROUND_Y;
s_player_vy = 0;
s_player_y_fp += s_player_vy_fp;
s_player_vy_fp += 34; // Gravity per frame
if (s_player_y_fp >= TO_FP(GROUND_Y)) {
s_player_y_fp = TO_FP(GROUND_Y);
s_player_vy_fp = 0;
s_is_jumping = false;
}
}
s_obs_x -= 3;
if (s_obs_x < -4) {
s_obs_x = 72 + (rand() % 16);
// 60 FPS Obstacle Movement (~70 px/sec)
s_obs_x_fp -= 300; // ~1.17 pixels per 60fps frame
int16_t obs_x = FROM_FP(s_obs_x_fp);
if (obs_x < -4) {
s_obs_x_fp = TO_FP(72 + (rand() % 16));
obs_x = FROM_FP(s_obs_x_fp);
s_obs_h = 5 + (rand() % 5);
s_score++;
if (s_score > s_high_score) s_high_score = s_score;
}
if (s_obs_x <= 13 && (s_obs_x + 3) >= 8) {
if ((s_player_y - 1) >= (GROUND_Y - s_obs_h + 1)) {
int16_t player_y = FROM_FP(s_player_y_fp);
// Collision Check (Hitbox: player is 5x5 at x=8..12, obs is 3xH at obs_x..obs_x+2)
if (obs_x <= 13 && (obs_x + 3) >= 8) {
if ((player_y - 1) >= (GROUND_Y - s_obs_h + 1)) {
s_game_state = GAME_STATE_OVER;
sys_gfx_show_toast("GAME OVER", 800);
}
}
sys_gfx_fill_rect(8, s_player_y - 5, 5, 5, COLOR_WHITE);
sys_gfx_fill_rect(s_obs_x, GROUND_Y - s_obs_h + 1, 3, s_obs_h, COLOR_WHITE);
// Render Player and Obstacle
sys_gfx_fill_rect(8, player_y - 5, 5, 5, COLOR_WHITE);
sys_gfx_fill_rect(obs_x, GROUND_Y - s_obs_h + 1, 3, s_obs_h, COLOR_WHITE);
char score_str[12];
snprintf(score_str, sizeof(score_str), "SC:%u", s_score);
@@ -113,7 +129,7 @@ static void game_handle_input(input_action_t action)
} else if (s_game_state == GAME_STATE_PLAYING) {
if (!s_is_jumping) {
s_is_jumping = true;
s_player_vy = -5;
s_player_vy_fp = -740; // Instant jump impulse (-2.9 px/frame)
}
} else if (s_game_state == GAME_STATE_OVER) {
s_game_state = GAME_STATE_PLAYING;
+97
View File
@@ -0,0 +1,97 @@
#include "apps.h"
#include "os_gfx.h"
#include "os_status.h"
#include "sys_uart.h"
#include <stdio.h>
#include <string.h>
#include "esp_netif.h"
#include "esp_log.h"
static const uint32_t s_baud_presets[] = {9600, 19200, 38400, 57600, 115200, 230400};
#define BAUD_PRESETS_COUNT (sizeof(s_baud_presets) / sizeof(s_baud_presets[0]))
static void serial_init(void)
{
sys_uart_init(SYS_UART_DEFAULT_BAUD);
}
static void serial_enter(void)
{
}
static void serial_update(uint32_t delta_ms)
{
(void)delta_ms;
sys_status_t status;
sys_status_get(&status);
sys_gfx_draw_status_bar(&status);
uint32_t rx_count = 0, tx_count = 0;
sys_uart_get_stats(&rx_count, &tx_count);
uint32_t baud = sys_uart_get_baud_rate();
/* Title */
sys_gfx_draw_str_3x5(3, 9, "SERIAL CONSOLE", COLOR_WHITE);
/* Web URL / IP Info */
char ip_str[24] = "192.168.4.1";
if (status.wifi_state == WIFI_STATE_CONNECTED) {
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (netif) {
esp_netif_ip_info_t ip_info;
if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) {
esp_ip4addr_ntoa(&ip_info.ip, ip_str, sizeof(ip_str));
}
}
}
char url_buf[32];
snprintf(url_buf, sizeof(url_buf), "IP:%s", ip_str);
sys_gfx_draw_str_3x5(3, 16, url_buf, COLOR_WHITE);
/* Stats Line: Baud, RX, TX */
char stats_buf[32];
snprintf(stats_buf, sizeof(stats_buf), "B:%lu R:%lu T:%lu",
(unsigned long)baud, (unsigned long)rx_count, (unsigned long)tx_count);
sys_gfx_draw_str_3x5(3, 23, stats_buf, COLOR_WHITE);
sys_gfx_draw_action_bar("C:PING D:BAUD", "H:HOME");
}
static void serial_handle_input(input_action_t action)
{
if (action == INPUT_ACTION_CLICK) {
sys_uart_write_str("ESP32C3-OS PING\r\n");
sys_gfx_show_toast("TX: PING SENT", 1000);
} else if (action == INPUT_ACTION_DOUBLE_CLICK) {
uint32_t current_baud = sys_uart_get_baud_rate();
size_t next_idx = 0;
for (size_t i = 0; i < BAUD_PRESETS_COUNT; i++) {
if (s_baud_presets[i] == current_baud) {
next_idx = (i + 1) % BAUD_PRESETS_COUNT;
break;
}
}
sys_uart_set_baud_rate(s_baud_presets[next_idx]);
char toast[24];
snprintf(toast, sizeof(toast), "BAUD:%lu", (unsigned long)s_baud_presets[next_idx]);
sys_gfx_show_toast(toast, 1200);
} else if (action == INPUT_ACTION_LONG_PRESS) {
sys_uart_clear_rx_buffer();
sys_gfx_show_toast("BUF CLEARED", 1000);
}
}
static void serial_exit(void)
{
}
const sys_app_t g_app_serial = {
.id = "serial",
.name = "Web Serial",
.init = serial_init,
.enter = serial_enter,
.update = serial_update,
.handle_input = serial_handle_input,
.exit = serial_exit,
};
+2 -2
View File
@@ -52,8 +52,8 @@ static void settings_handle_input(input_action_t action)
if (action == INPUT_ACTION_CLICK) {
s_selected_idx = (s_selected_idx + 1) % SETTINGS_COUNT;
} else if (action == INPUT_ACTION_DOUBLE_CLICK) {
if (s_selected_idx == 0) s_selected_idx = SETTINGS_COUNT - 1;
else s_selected_idx--;
/* Tap 1 already stepped +1; stepping -2 results in a net -1 (previous entry) */
s_selected_idx = (s_selected_idx + SETTINGS_COUNT - 2) % SETTINGS_COUNT;
} else if (action == INPUT_ACTION_LONG_PRESS) {
switch (s_selected_idx) {
case 0:
+1
View File
@@ -11,6 +11,7 @@ extern const sys_app_t g_app_clock;
extern const sys_app_t g_app_sysmon;
extern const sys_app_t g_app_settings;
extern const sys_app_t g_app_game;
extern const sys_app_t g_app_serial;
#ifdef __cplusplus
}
+13 -2
View File
@@ -12,18 +12,20 @@
/* OS Subsystems */
#include "os_event.h"
#include "os_status.h"
#include "sys_time.h"
#include "os_gfx.h"
#include "os_input.h"
#include "os_app.h"
#include "os_cli.h"
#include "sys_wifi.h"
#include "sys_uart.h"
#include "apps.h"
static const char *TAG = "MICRO_OS";
#define PIN_LED_USER GPIO_NUM_8
#define PIN_BUTTON_BOOT GPIO_NUM_9
#define UI_REFRESH_HZ 20
#define UI_REFRESH_HZ 60
#define UI_FRAME_MS (1000 / UI_REFRESH_HZ)
/* Task: Onboard LED Heartbeat */
@@ -60,6 +62,7 @@ static void on_input_event(input_action_t action, void *user_arg)
static void os_ui_task(void *pvParameters)
{
ESP_LOGI(TAG, "Starting UI compositor loop @ %d FPS", UI_REFRESH_HZ);
TickType_t last_wake_time = xTaskGetTickCount();
uint32_t last_time_ms = (uint32_t)(esp_timer_get_time() / 1000ULL);
while (1) {
@@ -73,7 +76,7 @@ static void os_ui_task(void *pvParameters)
sys_gfx_render_toast_if_active();
sys_gfx_flush();
vTaskDelay(pdMS_TO_TICKS(UI_FRAME_MS));
vTaskDelayUntil(&last_wake_time, pdMS_TO_TICKS(UI_FRAME_MS));
}
}
@@ -86,6 +89,7 @@ void app_main(void)
// 1. Core Services & Telemetry
sys_event_init();
sys_status_init();
sys_time_init();
esp_err_t err;
// 1b. Wi-Fi and provisioning services
@@ -94,6 +98,12 @@ void app_main(void)
ESP_LOGE(TAG, "Failed to initialize Wi-Fi subsystem: %s", esp_err_to_name(err));
}
// 1c. Hardware UART Subsystem (TX: GPIO 21, RX: GPIO 20)
err = sys_uart_init(115200);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to initialize UART subsystem: %s", esp_err_to_name(err));
}
// 2. Display & Graphics Subsystem
err = sys_gfx_init();
if (err != ESP_OK) {
@@ -108,6 +118,7 @@ void app_main(void)
sys_app_init();
sys_app_register(&g_app_clock);
sys_app_register(&g_app_sysmon);
sys_app_register(&g_app_serial);
sys_app_register(&g_app_game);
sys_app_register(&g_app_settings);