diff --git a/AGENTS.md b/AGENTS.md index 293e257..c5c11ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,24 +105,24 @@ Additional enforcement rules: ## Build, Flash, and Validation - Use the ESP-IDF/toolchain version declared by the project documentation or lockfiles. -- Activate ESP-IDF v5.5.3 before building. +- Activate ESP-IDF v5.5.4 before building. - Before running any `idf.py` command, first determine which activation style exists in the current environment: - Prefer path expressions that are portable across developer machines. Avoid hardcoded user-specific absolute paths such as `/home//...` or `/Users//...` in docs, scripts, and shell snippets when a variable-based form is sufficient. - For POSIX shells, prefer parameterized paths such as: ```bash export ESPRESSIF_ROOT="${ESPRESSIF_ROOT:-$HOME/.espressif}" - export IDF_VERSION_TAG="v5.5.3" + export IDF_VERSION_TAG="v5.5.4" ``` - If `eim` exists on the current machine, prefer running ESP-IDF commands through `eim run`, for example: ```bash eim run [IDF_VERSION] - eim run "idf.py build" v5.5.3 + eim run "idf.py build" v5.5.4 eim run "idf.py --version" ``` - In sandboxed or other write-restricted environments, `eim` logging initialization may fail if the default user state/log directories are not writable. In that case, pass `--log-file` with a writable path, for example: ```bash - eim --log-file ./eim-build.log run "idf.py build" v5.5.3 - eim --log-file /tmp/eim.log run "idf.py --version" v5.5.3 + eim --log-file ./eim-build.log run "idf.py build" v5.5.4 + eim --log-file /tmp/eim.log run "idf.py --version" v5.5.4 ``` - If `eim` does not exist, prefer the newer EIM-managed activation script when available: ```bash diff --git a/components/control_plane/CMakeLists.txt b/components/control_plane/CMakeLists.txt index 1dd17b7..315b3fb 100644 --- a/components/control_plane/CMakeLists.txt +++ b/components/control_plane/CMakeLists.txt @@ -13,6 +13,7 @@ idf_component_register( "src/runtime/control_plane_fortune_service.c" "src/runtime/control_plane_system_events.c" "src/runtime/control_plane_key_input.c" + "src/runtime/control_plane_remote_log_service.c" "src/runtime/control_plane_runtime_workers.c" "src/runtime/control_plane_factory_cli.c" INCLUDE_DIRS diff --git a/components/control_plane/internal/control_plane_internal.h b/components/control_plane/internal/control_plane_internal.h index 9d0c67c..8eb7628 100644 --- a/components/control_plane/internal/control_plane_internal.h +++ b/components/control_plane/internal/control_plane_internal.h @@ -142,3 +142,6 @@ esp_err_t control_plane_start_key_input_task(void); void control_plane_stop_key_input_task(void); esp_err_t control_plane_schedule_websocket_wait_task(void); void control_plane_set_ptt_active(bool active); + +esp_err_t control_plane_remote_log_start(void); +void control_plane_remote_log_stop(int timeout_ms); diff --git a/components/control_plane/src/runtime/control_plane_remote_log_service.c b/components/control_plane/src/runtime/control_plane_remote_log_service.c new file mode 100644 index 0000000..a7c62a6 --- /dev/null +++ b/components/control_plane/src/runtime/control_plane_remote_log_service.c @@ -0,0 +1,824 @@ +/* + * SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "control_plane_internal.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "domain.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sdkconfig.h" + +#ifndef CONFIG_TQ_REMOTE_LOG_ENABLE +#define CONFIG_TQ_REMOTE_LOG_ENABLE 0 +#endif + +#ifndef CONFIG_TQ_REMOTE_LOG_ENDPOINT +#define CONFIG_TQ_REMOTE_LOG_ENDPOINT "" +#endif + +#ifndef CONFIG_TQ_REMOTE_LOG_AUTH_TOKEN +#define CONFIG_TQ_REMOTE_LOG_AUTH_TOKEN "" +#endif + +#ifndef CONFIG_TQ_REMOTE_LOG_MIN_LEVEL +#define CONFIG_TQ_REMOTE_LOG_MIN_LEVEL 2 +#endif + +#ifndef CONFIG_TQ_REMOTE_LOG_PRESERVE_LOCAL_OUTPUT +#define CONFIG_TQ_REMOTE_LOG_PRESERVE_LOCAL_OUTPUT 1 +#endif + +#ifndef CONFIG_TQ_REMOTE_LOG_QUEUE_LEN +#define CONFIG_TQ_REMOTE_LOG_QUEUE_LEN 128 +#endif + +#ifndef CONFIG_TQ_REMOTE_LOG_ENTRY_MAX_BYTES +#define CONFIG_TQ_REMOTE_LOG_ENTRY_MAX_BYTES 256 +#endif + +#ifndef CONFIG_TQ_REMOTE_LOG_BATCH_MAX_ENTRIES +#define CONFIG_TQ_REMOTE_LOG_BATCH_MAX_ENTRIES 30 +#endif + +#ifndef CONFIG_TQ_REMOTE_LOG_FLUSH_INTERVAL_MS +#define CONFIG_TQ_REMOTE_LOG_FLUSH_INTERVAL_MS 5000 +#endif + +#ifndef CONFIG_TQ_REMOTE_LOG_UPLOAD_TIMEOUT_MS +#define CONFIG_TQ_REMOTE_LOG_UPLOAD_TIMEOUT_MS 8000 +#endif + +#ifndef CONFIG_TQ_REMOTE_LOG_MAX_FILES +#define CONFIG_TQ_REMOTE_LOG_MAX_FILES 32 +#endif + +#ifndef CONFIG_TQ_REMOTE_LOG_FILE_MAX_BYTES +#define CONFIG_TQ_REMOTE_LOG_FILE_MAX_BYTES 16384 +#endif + +#ifndef CONFIG_TQ_NETWORK_TASK_CORE +#define CONFIG_TQ_NETWORK_TASK_CORE 0 +#endif + +#define REMOTE_LOG_TASK_STACK_SIZE (1024 * 8) +#define REMOTE_LOG_TASK_PRIORITY 3 +#define REMOTE_LOG_DIR "/littlefs/remote_logs" +#define REMOTE_LOG_FILE_PREFIX "log_" +#define REMOTE_LOG_FILE_SUFFIX ".jsonl" +#define REMOTE_LOG_PATH_MAX 96 +#define REMOTE_LOG_RESPONSE_MAX 256 +#define REMOTE_LOG_UPLOAD_BODY_MAX (CONFIG_TQ_REMOTE_LOG_FILE_MAX_BYTES + 4096) +#define REMOTE_LOG_BACKOFF_MAX_MS 300000 +#define REMOTE_LOG_POLL_MS 200 + +#if CONFIG_TQ_REMOTE_LOG_ENABLE + +typedef struct { + int64_t ts_ms; + char level; + bool truncated; + char text[CONFIG_TQ_REMOTE_LOG_ENTRY_MAX_BYTES]; +} remote_log_entry_t; + +static remote_log_entry_t s_queue[CONFIG_TQ_REMOTE_LOG_QUEUE_LEN]; +static size_t s_queue_read; +static size_t s_queue_count; +static uint32_t s_dropped_count; +static uint32_t s_file_sequence; +static TaskHandle_t s_remote_log_task; +static volatile bool s_stop_requested; +static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; +static vprintf_like_t s_original_vprintf; +static bool s_hook_installed; + +static bool remote_log_path_active(void) +{ + return xTaskGetCurrentTaskHandle() == s_remote_log_task; +} + +static char log_level_from_line(const char *line) +{ + if (line == NULL || line[0] == '\0') { + return 'I'; + } + if ((line[0] == 'E' || line[0] == 'W' || line[0] == 'I' || + line[0] == 'D' || line[0] == 'V') && + line[1] == ' ') { + return line[0]; + } + return 'I'; +} + +static int log_level_rank(char level) +{ + switch (level) { + case 'E': + return 1; + case 'W': + return 2; + case 'I': + return 3; + case 'D': + case 'V': + return 4; + default: + return 3; + } +} + +static bool should_capture_level(char level) +{ + int min_level = CONFIG_TQ_REMOTE_LOG_MIN_LEVEL; + if (min_level <= 0) { + return false; + } + return log_level_rank(level) <= min_level; +} + +static void enqueue_line(const char *line, bool truncated) +{ + if (line == NULL || line[0] == '\0') { + return; + } + + char level = log_level_from_line(line); + if (!should_capture_level(level)) { + return; + } + + portENTER_CRITICAL(&s_lock); + size_t write_index = (s_queue_read + s_queue_count) % CONFIG_TQ_REMOTE_LOG_QUEUE_LEN; + if (s_queue_count == CONFIG_TQ_REMOTE_LOG_QUEUE_LEN) { + s_queue_read = (s_queue_read + 1U) % CONFIG_TQ_REMOTE_LOG_QUEUE_LEN; + s_queue_count--; + s_dropped_count++; + } + + remote_log_entry_t *entry = &s_queue[write_index]; + entry->ts_ms = esp_timer_get_time() / 1000; + entry->level = level; + entry->truncated = truncated; + strlcpy(entry->text, line, sizeof(entry->text)); + s_queue_count++; + portEXIT_CRITICAL(&s_lock); +} + +static int remote_log_vprintf(const char *fmt, va_list args) +{ + int ret = 0; + +#if CONFIG_TQ_REMOTE_LOG_PRESERVE_LOCAL_OUTPUT + if (s_original_vprintf != NULL) { + va_list local_args; + va_copy(local_args, args); + ret = s_original_vprintf(fmt, local_args); + va_end(local_args); + } +#endif + + if (remote_log_path_active()) { + return ret; + } + + char line[CONFIG_TQ_REMOTE_LOG_ENTRY_MAX_BYTES]; + va_list copy_args; + va_copy(copy_args, args); + int written = vsnprintf(line, sizeof(line), fmt, copy_args); + va_end(copy_args); + if (written < 0) { + return ret; + } + + bool truncated = written >= (int)sizeof(line); + size_t len = strlen(line); + while (len > 0U && (line[len - 1U] == '\n' || line[len - 1U] == '\r')) { + line[--len] = '\0'; + } + enqueue_line(line, truncated); + return ret; +} + +static bool dequeue_entry(remote_log_entry_t *out_entry) +{ + if (out_entry == NULL) { + return false; + } + + bool ok = false; + portENTER_CRITICAL(&s_lock); + if (s_queue_count > 0U) { + *out_entry = s_queue[s_queue_read]; + s_queue_read = (s_queue_read + 1U) % CONFIG_TQ_REMOTE_LOG_QUEUE_LEN; + s_queue_count--; + ok = true; + } + portEXIT_CRITICAL(&s_lock); + return ok; +} + +static uint32_t snapshot_and_clear_dropped_count(void) +{ + uint32_t dropped = 0; + portENTER_CRITICAL(&s_lock); + dropped = s_dropped_count; + s_dropped_count = 0; + portEXIT_CRITICAL(&s_lock); + return dropped; +} + +static void restore_dropped_count(uint32_t dropped) +{ + if (dropped == 0U) { + return; + } + portENTER_CRITICAL(&s_lock); + s_dropped_count += dropped; + portEXIT_CRITICAL(&s_lock); +} + +static void sanitize_value(char *text, const char *key) +{ + if (text == NULL || key == NULL || key[0] == '\0') { + return; + } + + size_t key_len = strlen(key); + char *cursor = text; + while ((cursor = strstr(cursor, key)) != NULL) { + char *value = cursor + key_len; + while (value[0] == ' ' || value[0] == ':' || value[0] == '=') { + value++; + } + if (value[0] == '\0') { + break; + } + char *end = value; + while (end[0] != '\0' && end[0] != ' ' && end[0] != ',' && + end[0] != '&' && end[0] != '"' && end[0] != '\'') { + end++; + } + const char *replacement = "***REDACTED***"; + size_t repl_len = strlen(replacement); + size_t tail_len = strlen(end); + if (repl_len <= (size_t)(end - value)) { + memcpy(value, replacement, repl_len); + memmove(value + repl_len, end, tail_len + 1U); + cursor = value + repl_len; + } else { + cursor = end; + } + } +} + +static void sanitize_log_line(char *text) +{ + sanitize_value(text, "password"); + sanitize_value(text, "Password"); + sanitize_value(text, "pwd"); + sanitize_value(text, "token"); + sanitize_value(text, "Token"); + sanitize_value(text, "Bearer"); + sanitize_value(text, "secret"); + sanitize_value(text, "api_key"); +} + +static void json_append_escaped(char *out, size_t out_len, size_t *offset, const char *text) +{ + if (out == NULL || out_len == 0U || offset == NULL || *offset >= out_len) { + return; + } + + for (const char *p = text != NULL ? text : ""; p[0] != '\0' && *offset < out_len - 1U; ++p) { + unsigned char ch = (unsigned char)p[0]; + const char *escape = NULL; + switch (ch) { + case '\\': + escape = "\\\\"; + break; + case '"': + escape = "\\\""; + break; + case '\b': + escape = "\\b"; + break; + case '\f': + escape = "\\f"; + break; + case '\n': + escape = "\\n"; + break; + case '\r': + escape = "\\r"; + break; + case '\t': + escape = "\\t"; + break; + default: + break; + } + if (escape != NULL) { + int written = snprintf(out + *offset, out_len - *offset, "%s", escape); + if (written < 0) { + return; + } + *offset += (size_t)written; + } else if (ch < 0x20U) { + int written = snprintf(out + *offset, out_len - *offset, "\\u%04x", ch); + if (written < 0) { + return; + } + *offset += (size_t)written; + } else { + out[(*offset)++] = (char)ch; + out[*offset] = '\0'; + } + } +} + +static bool make_log_dir(void) +{ + struct stat st = {0}; + if (stat(REMOTE_LOG_DIR, &st) == 0 && S_ISDIR(st.st_mode)) { + return true; + } + return mkdir(REMOTE_LOG_DIR, 0755) == 0 || errno == EEXIST; +} + +static void make_log_path(uint32_t sequence, char *out, size_t out_len) +{ + snprintf(out, + out_len, + "%s/%s%08lu%s", + REMOTE_LOG_DIR, + REMOTE_LOG_FILE_PREFIX, + (unsigned long)sequence, + REMOTE_LOG_FILE_SUFFIX); +} + +static bool parse_log_filename(const char *name, uint32_t *out_sequence) +{ + if (name == NULL || out_sequence == NULL) { + return false; + } + size_t prefix_len = strlen(REMOTE_LOG_FILE_PREFIX); + size_t suffix_len = strlen(REMOTE_LOG_FILE_SUFFIX); + size_t name_len = strlen(name); + if (name_len <= prefix_len + suffix_len || + strncmp(name, REMOTE_LOG_FILE_PREFIX, prefix_len) != 0 || + strcmp(name + name_len - suffix_len, REMOTE_LOG_FILE_SUFFIX) != 0) { + return false; + } + char number[12] = {0}; + size_t number_len = name_len - prefix_len - suffix_len; + if (number_len >= sizeof(number)) { + return false; + } + memcpy(number, name + prefix_len, number_len); + char *end = NULL; + unsigned long value = strtoul(number, &end, 10); + if (end == NULL || end[0] != '\0') { + return false; + } + *out_sequence = (uint32_t)value; + return true; +} + +static uint32_t find_next_file_sequence(void) +{ + DIR *dir = opendir(REMOTE_LOG_DIR); + if (dir == NULL) { + return 1U; + } + + uint32_t max_sequence = 0U; + struct dirent *entry = NULL; + while ((entry = readdir(dir)) != NULL) { + uint32_t sequence = 0U; + if (parse_log_filename(entry->d_name, &sequence) && sequence > max_sequence) { + max_sequence = sequence; + } + } + closedir(dir); + return max_sequence + 1U; +} + +static size_t collect_files(uint32_t *out_sequences, size_t capacity) +{ + if (out_sequences == NULL || capacity == 0U) { + return 0U; + } + + DIR *dir = opendir(REMOTE_LOG_DIR); + if (dir == NULL) { + return 0U; + } + + size_t count = 0U; + struct dirent *entry = NULL; + while ((entry = readdir(dir)) != NULL) { + uint32_t sequence = 0U; + if (parse_log_filename(entry->d_name, &sequence)) { + if (count < capacity) { + out_sequences[count++] = sequence; + } + } + } + closedir(dir); + + for (size_t i = 0; i < count; ++i) { + for (size_t j = i + 1U; j < count; ++j) { + if (out_sequences[j] < out_sequences[i]) { + uint32_t tmp = out_sequences[i]; + out_sequences[i] = out_sequences[j]; + out_sequences[j] = tmp; + } + } + } + return count; +} + +static void trim_old_files(void) +{ + uint32_t sequences[CONFIG_TQ_REMOTE_LOG_MAX_FILES + 8] = {0}; + size_t count = collect_files(sequences, sizeof(sequences) / sizeof(sequences[0])); + while (count > CONFIG_TQ_REMOTE_LOG_MAX_FILES) { + char path[REMOTE_LOG_PATH_MAX] = {0}; + make_log_path(sequences[0], path, sizeof(path)); + (void)unlink(path); + memmove(&sequences[0], &sequences[1], (count - 1U) * sizeof(sequences[0])); + count--; + } +} + +static esp_err_t append_entry_to_file(const remote_log_entry_t *entry) +{ + if (entry == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (!make_log_dir()) { + return ESP_FAIL; + } + + char path[REMOTE_LOG_PATH_MAX] = {0}; + make_log_path(s_file_sequence, path, sizeof(path)); + struct stat st = {0}; + if (stat(path, &st) == 0 && st.st_size >= CONFIG_TQ_REMOTE_LOG_FILE_MAX_BYTES) { + s_file_sequence++; + make_log_path(s_file_sequence, path, sizeof(path)); + } + + FILE *file = fopen(path, "a"); + if (file == NULL) { + return ESP_FAIL; + } + + char text[CONFIG_TQ_REMOTE_LOG_ENTRY_MAX_BYTES]; + strlcpy(text, entry->text, sizeof(text)); + sanitize_log_line(text); + + char escaped[CONFIG_TQ_REMOTE_LOG_ENTRY_MAX_BYTES * 2]; + escaped[0] = '\0'; + size_t escaped_len = 0U; + json_append_escaped(escaped, sizeof(escaped), &escaped_len, text); + + int written = fprintf(file, + "{\"ts_ms\":%lld,\"level\":\"%c\",\"truncated\":%s,\"message\":\"%s\"}\n", + (long long)entry->ts_ms, + entry->level, + entry->truncated ? "true" : "false", + escaped); + int flush_err = fflush(file); + int fsync_err = fsync(fileno(file)); + int close_err = fclose(file); + if (written <= 0 || flush_err != 0 || fsync_err != 0 || close_err != 0) { + return ESP_FAIL; + } + + trim_old_files(); + return ESP_OK; +} + +static void drain_memory_queue_to_flash(void) +{ + remote_log_entry_t entry = {0}; + while (dequeue_entry(&entry)) { + if (append_entry_to_file(&entry) != ESP_OK) { + portENTER_CRITICAL(&s_lock); + s_dropped_count++; + portEXIT_CRITICAL(&s_lock); + break; + } + } +} + +static bool realtime_path_busy(void) +{ +#if CONFIG_ENABLE_MULTIMODAL_INTERACTION + control_plane_multimodal_runtime_state_t state = {0}; + if (control_plane_multimodal_get_runtime_state(&state) != ESP_OK) { + return false; + } + return state.client_speech_active || + state.remote_responding || + state.request_to_speak_pending || + state.received_downstream_audio; +#else + return false; +#endif +} + +static esp_err_t build_upload_body(uint32_t sequence, + uint32_t dropped_count, + char *body, + size_t body_len, + size_t *out_entry_count) +{ + if (body == NULL || body_len == 0U || out_entry_count == NULL) { + return ESP_ERR_INVALID_ARG; + } + *out_entry_count = 0U; + + char device_id[64] = {0}; + char serial_number[64] = {0}; + domain_runtime_read_device_identity(device_id, sizeof(device_id), serial_number, sizeof(serial_number)); + + size_t offset = 0U; + int written = snprintf(body, + body_len, + "{\"schema\":\"tq.remote_log.v1\",\"device_id\":\""); + if (written < 0 || (size_t)written >= body_len) { + return ESP_ERR_INVALID_SIZE; + } + offset = (size_t)written; + json_append_escaped(body, body_len, &offset, device_id); + written = snprintf(body + offset, + body_len - offset, + "\",\"serial_number\":\""); + if (written < 0 || (size_t)written >= body_len - offset) { + return ESP_ERR_INVALID_SIZE; + } + offset += (size_t)written; + json_append_escaped(body, body_len, &offset, serial_number); + written = snprintf(body + offset, + body_len - offset, + "\",\"batch_id\":\"%lu\",\"sent_at_ms\":%lld,\"dropped_count\":%lu,\"entries\":[", + (unsigned long)sequence, + (long long)(esp_timer_get_time() / 1000), + (unsigned long)dropped_count); + if (written < 0 || (size_t)written >= body_len - offset) { + return ESP_ERR_INVALID_SIZE; + } + offset += (size_t)written; + + char path[REMOTE_LOG_PATH_MAX] = {0}; + make_log_path(sequence, path, sizeof(path)); + FILE *file = fopen(path, "r"); + if (file == NULL) { + return ESP_ERR_NOT_FOUND; + } + + char line[CONFIG_TQ_REMOTE_LOG_ENTRY_MAX_BYTES * 3]; + bool first = true; + while (fgets(line, sizeof(line), file) != NULL) { + size_t line_len = strlen(line); + while (line_len > 0U && (line[line_len - 1U] == '\n' || line[line_len - 1U] == '\r')) { + line[--line_len] = '\0'; + } + if (line_len == 0U) { + continue; + } + written = snprintf(body + offset, + body_len - offset, + "%s%s", + first ? "" : ",", + line); + if (written < 0 || (size_t)written >= body_len - offset) { + fclose(file); + return ESP_ERR_INVALID_SIZE; + } + offset += (size_t)written; + first = false; + (*out_entry_count)++; + } + fclose(file); + + written = snprintf(body + offset, body_len - offset, "]}"); + if (written < 0 || (size_t)written >= body_len - offset) { + return ESP_ERR_INVALID_SIZE; + } + return (*out_entry_count > 0U || dropped_count > 0U) ? ESP_OK : ESP_ERR_NOT_FOUND; +} + +static bool upload_one_file(uint32_t sequence) +{ + uint32_t dropped = snapshot_and_clear_dropped_count(); + char *body = (char *)malloc(REMOTE_LOG_UPLOAD_BODY_MAX); + char *response = (char *)malloc(REMOTE_LOG_RESPONSE_MAX); + if (body == NULL || response == NULL) { + free(body); + free(response); + restore_dropped_count(dropped); + return false; + } + + size_t entry_count = 0U; + esp_err_t err = build_upload_body(sequence, + dropped, + body, + REMOTE_LOG_UPLOAD_BODY_MAX, + &entry_count); + if (err != ESP_OK) { + free(body); + free(response); + restore_dropped_count(dropped); + if (err == ESP_ERR_NOT_FOUND) { + char path[REMOTE_LOG_PATH_MAX] = {0}; + make_log_path(sequence, path, sizeof(path)); + (void)unlink(path); + } + return false; + } + + int status = -1; + size_t response_len = 0U; + err = domain_runtime_post_json(CONFIG_TQ_REMOTE_LOG_ENDPOINT, + body, + NULL, + CONFIG_TQ_REMOTE_LOG_AUTH_TOKEN, + CONFIG_TQ_REMOTE_LOG_UPLOAD_TIMEOUT_MS, + 0, + 0, + response, + REMOTE_LOG_RESPONSE_MAX, + &response_len, + &status); + + free(body); + free(response); + + if (err == ESP_OK && (status == 200 || status == 202 || status == 204)) { + char path[REMOTE_LOG_PATH_MAX] = {0}; + make_log_path(sequence, path, sizeof(path)); + (void)unlink(path); + return true; + } + + if (err == ESP_OK && status == 400) { + char path[REMOTE_LOG_PATH_MAX] = {0}; + make_log_path(sequence, path, sizeof(path)); + (void)unlink(path); + restore_dropped_count(dropped); + return true; + } + + restore_dropped_count(dropped); + return false; +} + +static bool upload_pending_files(void) +{ + if (!control_plane_is_sta_connected() || realtime_path_busy()) { + return false; + } + + uint32_t sequences[CONFIG_TQ_REMOTE_LOG_MAX_FILES] = {0}; + size_t count = collect_files(sequences, CONFIG_TQ_REMOTE_LOG_MAX_FILES); + if (count == 0U) { + return true; + } + return upload_one_file(sequences[0]); +} + +static void install_hook(void) +{ + if (s_hook_installed) { + return; + } + s_original_vprintf = esp_log_set_vprintf(remote_log_vprintf); + s_hook_installed = true; +} + +static void uninstall_hook(void) +{ + if (!s_hook_installed) { + return; + } + (void)esp_log_set_vprintf(s_original_vprintf); + s_original_vprintf = NULL; + s_hook_installed = false; +} + +static void remote_log_task(void *arg) +{ + (void)arg; + + esp_err_t mount_err = domain_runtime_mount_littlefs(); + if (mount_err != ESP_OK) { + s_remote_log_task = NULL; + vTaskDelete(NULL); + return; + } + (void)make_log_dir(); + s_file_sequence = find_next_file_sequence(); + + install_hook(); + + TickType_t last_flush = xTaskGetTickCount(); + uint32_t backoff_ms = 0U; + TickType_t next_upload_tick = xTaskGetTickCount(); + + while (!s_stop_requested) { + TickType_t now = xTaskGetTickCount(); + if ((now - last_flush) >= pdMS_TO_TICKS(CONFIG_TQ_REMOTE_LOG_FLUSH_INTERVAL_MS)) { + drain_memory_queue_to_flash(); + last_flush = now; + } + + if (backoff_ms == 0U || now >= next_upload_tick) { + drain_memory_queue_to_flash(); + bool ok = upload_pending_files(); + if (ok) { + backoff_ms = 0U; + } else if (control_plane_is_sta_connected()) { + backoff_ms = backoff_ms == 0U ? 5000U : backoff_ms * 2U; + if (backoff_ms > REMOTE_LOG_BACKOFF_MAX_MS) { + backoff_ms = REMOTE_LOG_BACKOFF_MAX_MS; + } + next_upload_tick = xTaskGetTickCount() + pdMS_TO_TICKS(backoff_ms); + } + } + + vTaskDelay(pdMS_TO_TICKS(REMOTE_LOG_POLL_MS)); + } + + drain_memory_queue_to_flash(); + uninstall_hook(); + s_remote_log_task = NULL; + vTaskDelete(NULL); +} + +esp_err_t control_plane_remote_log_start(void) +{ + if (CONFIG_TQ_REMOTE_LOG_ENDPOINT[0] == '\0') { + return ESP_ERR_INVALID_ARG; + } + if (s_remote_log_task != NULL) { + return ESP_OK; + } + + s_stop_requested = false; + BaseType_t created = xTaskCreatePinnedToCore(remote_log_task, + "cp_remote_log", + REMOTE_LOG_TASK_STACK_SIZE, + NULL, + REMOTE_LOG_TASK_PRIORITY, + &s_remote_log_task, + CONFIG_TQ_NETWORK_TASK_CORE < 0 ? tskNO_AFFINITY : + (BaseType_t)CONFIG_TQ_NETWORK_TASK_CORE); + return created == pdPASS ? ESP_OK : ESP_ERR_NO_MEM; +} + +void control_plane_remote_log_stop(int timeout_ms) +{ + if (s_remote_log_task == NULL) { + return; + } + + s_stop_requested = true; + TickType_t start = xTaskGetTickCount(); + TickType_t timeout_ticks = pdMS_TO_TICKS(timeout_ms > 0 ? timeout_ms : 1); + while (s_remote_log_task != NULL && + (xTaskGetTickCount() - start) < timeout_ticks) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + if (s_remote_log_task != NULL) { + uninstall_hook(); + vTaskDelete(s_remote_log_task); + s_remote_log_task = NULL; + } +} +#else +esp_err_t control_plane_remote_log_start(void) +{ + return ESP_ERR_NOT_SUPPORTED; +} + +void control_plane_remote_log_stop(int timeout_ms) +{ + (void)timeout_ms; +} +#endif diff --git a/components/control_plane/src/runtime/control_plane_runtime_workers.c b/components/control_plane/src/runtime/control_plane_runtime_workers.c index 499fd67..98bb3de 100644 --- a/components/control_plane/src/runtime/control_plane_runtime_workers.c +++ b/components/control_plane/src/runtime/control_plane_runtime_workers.c @@ -24,6 +24,7 @@ #define CONTROL_PLANE_LCD_RENDER_LOOP_TASK_STACK_SIZE (1024 * 12) #define CONTROL_PLANE_WIFI_STATE_TASK_STACK_SIZE (1024 * 12) #define CONTROL_PLANE_TASK_MONITOR_TASK_STACK_SIZE (1024 * 6) +#define CONTROL_PLANE_REMOTE_LOG_STOP_WAIT_MS 1500 #define CONTROL_PLANE_MODEL_CMD_QUEUE_LEN 4 #define CONTROL_PLANE_MODEL_CMD_RECV_TIMEOUT_MS 200 #define CONTROL_PLANE_TASK_STOP_WAIT_MS 1500 @@ -1812,6 +1813,11 @@ esp_err_t control_plane_start_runtime_workers(void) return key_input_err; } + esp_err_t remote_log_err = control_plane_remote_log_start(); + if (remote_log_err != ESP_OK && remote_log_err != ESP_ERR_NOT_SUPPORTED) { + ESP_LOGW(TAG, "Remote log start failed: %s", esp_err_to_name(remote_log_err)); + } + #if CONFIG_FORTUNE_ENABLE if (control_plane_get_task_handle(&s_daily_fortune_task) == NULL) { control_plane_request_fortune_refresh(); @@ -1856,6 +1862,7 @@ void control_plane_stop_event_observer_runtime(void) control_plane_set_runtime_stop_requested(true); control_plane_stop_key_input_task(); control_plane_stop_system_event_task(); + control_plane_remote_log_stop(CONTROL_PLANE_REMOTE_LOG_STOP_WAIT_MS); control_plane_set_runtime_stop_requested(false); } diff --git a/components/domain/include/domain.h b/components/domain/include/domain.h index 0f56b54..9a44b89 100644 --- a/components/domain/include/domain.h +++ b/components/domain/include/domain.h @@ -187,6 +187,7 @@ esp_err_t domain_runtime_slot_feature_switch_init(void); bool domain_runtime_slot_feature_switch_is_active(void); esp_err_t domain_runtime_prepare_storage(void); +esp_err_t domain_runtime_mount_littlefs(void); void domain_runtime_read_device_identity(char *device_id, size_t device_id_len, char *serial_number, diff --git a/components/domain/src/runtime/domain_runtime_adapters.c b/components/domain/src/runtime/domain_runtime_adapters.c index 43afcaf..944acec 100644 --- a/components/domain/src/runtime/domain_runtime_adapters.c +++ b/components/domain/src/runtime/domain_runtime_adapters.c @@ -753,6 +753,11 @@ esp_err_t domain_runtime_prepare_storage(void) return platform_prepare_storage(); } +esp_err_t domain_runtime_mount_littlefs(void) +{ + return platform_mount_littlefs(); +} + void domain_runtime_read_device_identity(char *device_id, size_t device_id_len, char *serial_number, diff --git a/components/platform/include/platform.h b/components/platform/include/platform.h index 137efad..6449ff6 100644 --- a/components/platform/include/platform.h +++ b/components/platform/include/platform.h @@ -132,6 +132,7 @@ typedef struct { esp_err_t platform_init(void); esp_err_t platform_prepare_storage(void); +esp_err_t platform_mount_littlefs(void); void platform_read_device_identity(char *device_id, size_t device_id_len, char *serial_number, diff --git a/components/platform/src/platform_behavior_stubs.c b/components/platform/src/platform_behavior_stubs.c index b518640..4b7c7a3 100644 --- a/components/platform/src/platform_behavior_stubs.c +++ b/components/platform/src/platform_behavior_stubs.c @@ -111,7 +111,7 @@ void platform_power_hold_init(void) power_manager_set_keep_awake(true); } -static esp_err_t voice_prompt_mount_littlefs(void) +esp_err_t platform_mount_littlefs(void) { if (s_voice_prompt_littlefs_mount_attempted) { return s_voice_prompt_littlefs_mount_result; @@ -160,7 +160,7 @@ esp_err_t voice_prompt_play_file(const char *path) return ESP_ERR_INVALID_ARG; } - esp_err_t mount_err = voice_prompt_mount_littlefs(); + esp_err_t mount_err = platform_mount_littlefs(); if (mount_err != ESP_OK) { return mount_err; } diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index a06391c..b046a20 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -132,6 +132,88 @@ menu "Example Configuration" help Stack size in bytes for the control-plane system event task. + menu "Remote Logging" + + config TQ_REMOTE_LOG_ENABLE + bool "Enable remote log upload" + default n + help + Capture ESP-IDF logs, persist them to the LittleFS-backed + queue, and upload batches to the configured HTTPS server. + + config TQ_REMOTE_LOG_ENDPOINT + string "Remote log endpoint URL" + default "https://gz.api.talkingq.com/log/batch" + depends on TQ_REMOTE_LOG_ENABLE + + config TQ_REMOTE_LOG_AUTH_TOKEN + string "Remote log bearer token" + default "" + depends on TQ_REMOTE_LOG_ENABLE + help + Optional bearer token sent as Authorization: Bearer + . Leave empty for unauthenticated test receivers. + + config TQ_REMOTE_LOG_MIN_LEVEL + int "Minimum remote log level" + default 2 + range 0 4 + depends on TQ_REMOTE_LOG_ENABLE + help + 0=NONE, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG. + + config TQ_REMOTE_LOG_PRESERVE_LOCAL_OUTPUT + bool "Preserve existing local log output" + default y + depends on TQ_REMOTE_LOG_ENABLE + help + Keep the original ESP-IDF vprintf output while also + enqueueing logs for server upload. + + config TQ_REMOTE_LOG_QUEUE_LEN + int "Remote log memory queue length" + default 128 + range 16 512 + depends on TQ_REMOTE_LOG_ENABLE + + config TQ_REMOTE_LOG_ENTRY_MAX_BYTES + int "Remote log entry max bytes" + default 256 + range 128 1024 + depends on TQ_REMOTE_LOG_ENABLE + + config TQ_REMOTE_LOG_BATCH_MAX_ENTRIES + int "Remote log batch max entries" + default 30 + range 5 100 + depends on TQ_REMOTE_LOG_ENABLE + + config TQ_REMOTE_LOG_FLUSH_INTERVAL_MS + int "Remote log flush interval" + default 5000 + range 1000 60000 + depends on TQ_REMOTE_LOG_ENABLE + + config TQ_REMOTE_LOG_UPLOAD_TIMEOUT_MS + int "Remote log upload timeout" + default 8000 + range 3000 30000 + depends on TQ_REMOTE_LOG_ENABLE + + config TQ_REMOTE_LOG_MAX_FILES + int "Remote log persisted file count" + default 32 + range 4 128 + depends on TQ_REMOTE_LOG_ENABLE + + config TQ_REMOTE_LOG_FILE_MAX_BYTES + int "Remote log persisted file max bytes" + default 16384 + range 4096 65536 + depends on TQ_REMOTE_LOG_ENABLE + + endmenu + config TQ_KEY_INPUT_ENABLE bool "Enable interrupt-driven key input task" default y diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 028d170..d15ab16 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -24,6 +24,18 @@ CONFIG_TQ_TASK_MONITOR_MAX_TASKS=64 CONFIG_TQ_SYSTEM_EVENT_ENABLE=y CONFIG_TQ_SYSTEM_EVENT_QUEUE_LEN=32 CONFIG_TQ_SYSTEM_EVENT_TASK_STACK=4096 +CONFIG_TQ_REMOTE_LOG_ENABLE=y +CONFIG_TQ_REMOTE_LOG_ENDPOINT="http://47.98.175.183:8090/log/batch" +CONFIG_TQ_REMOTE_LOG_AUTH_TOKEN="" +CONFIG_TQ_REMOTE_LOG_MIN_LEVEL=2 +CONFIG_TQ_REMOTE_LOG_PRESERVE_LOCAL_OUTPUT=y +CONFIG_TQ_REMOTE_LOG_QUEUE_LEN=128 +CONFIG_TQ_REMOTE_LOG_ENTRY_MAX_BYTES=256 +CONFIG_TQ_REMOTE_LOG_BATCH_MAX_ENTRIES=30 +CONFIG_TQ_REMOTE_LOG_FLUSH_INTERVAL_MS=5000 +CONFIG_TQ_REMOTE_LOG_UPLOAD_TIMEOUT_MS=8000 +CONFIG_TQ_REMOTE_LOG_MAX_FILES=32 +CONFIG_TQ_REMOTE_LOG_FILE_MAX_BYTES=16384 CONFIG_TQ_KEY_INPUT_ENABLE=y CONFIG_TQ_KEY_INPUT_TASK_STACK=4096 CONFIG_TQ_KEY_IRQ_QUEUE_LEN=16 diff --git a/tools/remote_log_receiver.py b/tools/remote_log_receiver.py new file mode 100644 index 0000000..4e2c566 --- /dev/null +++ b/tools/remote_log_receiver.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Minimal HTTP receiver for TQ remote log batches. + +Usage: + python tools/remote_log_receiver.py --host 0.0.0.0 --port 8080 --dir ./remote_logs + +Configure the device endpoint as: + http://:8080/log/batch + +For production, put this behind HTTPS or a reverse proxy that terminates TLS. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +class LogReceiver(BaseHTTPRequestHandler): + server_version = "TQRemoteLogReceiver/1.0" + + def do_POST(self) -> None: + if self.path != "/log/batch": + self._send_json(404, {"code": 404, "message": "not found"}) + return + + length = int(self.headers.get("Content-Length", "0")) + if length <= 0 or length > self.server.max_body_bytes: + self._send_json(413, {"code": 413, "message": "payload too large"}) + return + + raw = self.rfile.read(length) + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + self._send_json(400, {"code": 400, "message": f"invalid json: {exc}"}) + return + + now = dt.datetime.now(dt.timezone.utc) + day_dir = self.server.output_dir / now.strftime("%Y%m%d") + day_dir.mkdir(parents=True, exist_ok=True) + + device_id = str(payload.get("device_id") or "unknown").replace("/", "_") + batch_id = str(payload.get("batch_id") or now.strftime("%H%M%S%f")).replace("/", "_") + json_path = day_dir / f"{device_id}_{batch_id}.json" + jsonl_path = day_dir / f"{device_id}.jsonl" + + with json_path.open("w", encoding="utf-8") as file: + json.dump(payload, file, ensure_ascii=False, indent=2) + file.write("\n") + + entries = payload.get("entries") or [] + with jsonl_path.open("a", encoding="utf-8") as file: + for entry in entries: + row = { + "received_at": now.isoformat(), + "device_id": payload.get("device_id"), + "serial_number": payload.get("serial_number"), + "batch_id": payload.get("batch_id"), + **entry, + } + file.write(json.dumps(row, ensure_ascii=False, separators=(",", ":"))) + file.write("\n") + + self._send_json( + 200, + { + "code": 200, + "message": "OK", + "data": { + "accepted": len(entries), + "stored": str(json_path), + }, + }, + ) + + def log_message(self, fmt: str, *args: object) -> None: + print(f"{self.address_string()} - {fmt % args}") + + def _send_json(self, status: int, payload: dict) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +class LogServer(ThreadingHTTPServer): + def __init__(self, server_address: tuple[str, int], handler_class, output_dir: Path, max_body_bytes: int): + super().__init__(server_address, handler_class) + self.output_dir = output_dir + self.max_body_bytes = max_body_bytes + + +def main() -> None: + parser = argparse.ArgumentParser(description="Receive TQ device remote log batches.") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8080) + parser.add_argument("--dir", type=Path, default=Path("remote_logs")) + parser.add_argument("--max-body-bytes", type=int, default=256 * 1024) + args = parser.parse_args() + + args.dir.mkdir(parents=True, exist_ok=True) + server = LogServer((args.host, args.port), LogReceiver, args.dir, args.max_body_bytes) + print(f"listening on http://{args.host}:{args.port}/log/batch") + print(f"writing logs under {args.dir.resolve()}") + server.serve_forever() + + +if __name__ == "__main__": + main()