diff --git a/components/control_plane/src/control_plane_lifecycle.c b/components/control_plane/src/control_plane_lifecycle.c index 04fc93b..351e5ce 100644 --- a/components/control_plane/src/control_plane_lifecycle.c +++ b/components/control_plane/src/control_plane_lifecycle.c @@ -88,6 +88,18 @@ static esp_err_t lifecycle_start_voice(void) { return voice_interaction_init(); } +static esp_err_t lifecycle_start_product_test(void) { + return product_test_service_start(); +} + +static esp_err_t lifecycle_stop_product_test(void) { + esp_err_t rc = product_test_service_stop(domain_policy_printer_stop_timeout_ms()); + if (rc == ESP_OK || rc == ESP_ERR_NOT_SUPPORTED || rc == ESP_ERR_INVALID_STATE) { + return ESP_OK; + } + return rc; +} + static esp_err_t lifecycle_stop_printer_protocol(void) { esp_err_t rc = printer_protocol_stop(domain_policy_printer_stop_timeout_ms()); if (rc == ESP_OK || rc == ESP_ERR_INVALID_STATE) { @@ -248,6 +260,7 @@ esp_err_t control_plane_lifecycle_start(void) { bool system_started = false; bool printer_started = false; bool voice_started = false; + bool product_test_started = false; const char *failed_stage = "unknown"; failed_stage = "system_runtime"; @@ -277,6 +290,13 @@ esp_err_t control_plane_lifecycle_start(void) { } voice_started = true; + failed_stage = "product_test"; + rc = lifecycle_start_product_test(); + if (rc != ESP_OK && rc != ESP_ERR_NOT_SUPPORTED) { + goto start_failed; + } + product_test_started = (rc == ESP_OK); + image_generation_schedule_prewarm(); esp_err_t voice_prewarm_rc = voice_interaction_schedule_session_prewarm(); if (voice_prewarm_rc != ESP_OK && voice_prewarm_rc != ESP_ERR_INVALID_STATE) { @@ -294,6 +314,9 @@ esp_err_t control_plane_lifecycle_start(void) { return ESP_OK; start_failed: + if (product_test_started) { + (void)lifecycle_stop_product_test(); + } if (voice_started) { (void)lifecycle_stop_voice(); } @@ -340,7 +363,12 @@ esp_err_t control_plane_lifecycle_stop(void) { esp_err_t first_err = ESP_OK; - esp_err_t rc = lifecycle_stop_voice(); + esp_err_t rc = lifecycle_stop_product_test(); + if (rc != ESP_OK && first_err == ESP_OK) { + first_err = rc; + } + + rc = lifecycle_stop_voice(); if (rc != ESP_OK && first_err == ESP_OK) { first_err = rc; } diff --git a/components/domain/CMakeLists.txt b/components/domain/CMakeLists.txt index 17c94d8..1250e64 100644 --- a/components/domain/CMakeLists.txt +++ b/components/domain/CMakeLists.txt @@ -10,6 +10,7 @@ idf_component_register( "src/screen_preview.c" "src/system_runtime.c" "src/domain_runtime_contracts.c" + "src/product_test_service.c" "src/voice_interaction.c" "src/voice_interaction_common.c" "src/voice_interaction_ws.c" diff --git a/components/domain/include/domain.h b/components/domain/include/domain.h index 91f22bb..bed1628 100644 --- a/components/domain/include/domain.h +++ b/components/domain/include/domain.h @@ -177,6 +177,11 @@ typedef struct { bool has_paper; int8_t paper_gpio_level; uint8_t paper_present_level; + bool battery_adc_ready; + bool ntc_adc_ready; + int battery_adc_raw; + int ntc_adc_raw; + uint16_t battery_mv; uint8_t battery_percent; float temperature; uint32_t queue_depth; @@ -257,6 +262,10 @@ esp_err_t voice_interaction_schedule_session_prewarm(void); void voice_interaction_get_status(voice_interaction_status_t *out_status); const char *voice_interaction_dialog_state_str(voice_dialog_state_t state); +// ---------- product_test_service ---------- +esp_err_t product_test_service_start(void); +esp_err_t product_test_service_stop(uint32_t timeout_ms); + #ifdef __cplusplus } #endif diff --git a/components/domain/internal/printer_protocol_internal.h b/components/domain/internal/printer_protocol_internal.h index 984b775..d727d99 100644 --- a/components/domain/internal/printer_protocol_internal.h +++ b/components/domain/internal/printer_protocol_internal.h @@ -45,6 +45,11 @@ typedef struct { bool has_paper; int8_t paper_gpio_level; uint8_t paper_present_level; + bool battery_adc_ready; + bool ntc_adc_ready; + int battery_adc_raw; + int ntc_adc_raw; + uint16_t battery_mv; uint8_t battery; float temperature; int64_t updated_ms; diff --git a/components/domain/src/printer_protocol.c b/components/domain/src/printer_protocol.c index c38a139..68754f5 100644 --- a/components/domain/src/printer_protocol.c +++ b/components/domain/src/printer_protocol.c @@ -22,6 +22,11 @@ parsed_status_t s_status = { .has_paper = true, .paper_gpio_level = -1, .paper_present_level = 0, + .battery_adc_ready = false, + .ntc_adc_ready = false, + .battery_adc_raw = -1, + .ntc_adc_raw = -1, + .battery_mv = 0, .battery = 100, .temperature = 25.0f, .updated_ms = 0, @@ -96,6 +101,11 @@ static void refresh_printer_status_locked(void) { s_status.has_paper = sensors.has_paper; s_status.paper_gpio_level = sensors.paper_gpio_level; s_status.paper_present_level = sensors.paper_present_level; + s_status.battery_adc_ready = sensors.battery_adc_ready; + s_status.ntc_adc_ready = sensors.ntc_adc_ready; + s_status.battery_adc_raw = sensors.battery_adc_raw; + s_status.ntc_adc_raw = sensors.ntc_adc_raw; + s_status.battery_mv = sensors.battery_mv; s_status.battery = sensors.battery_percent; s_status.temperature = sensors.temperature_c; s_status.updated_ms = sensors.updated_ms; @@ -133,6 +143,11 @@ static void reset_runtime_state_locked(void) { s_status.has_paper = true; s_status.paper_gpio_level = -1; s_status.paper_present_level = 0; + s_status.battery_adc_ready = false; + s_status.ntc_adc_ready = false; + s_status.battery_adc_raw = -1; + s_status.ntc_adc_raw = -1; + s_status.battery_mv = 0; s_status.battery = 100; s_status.temperature = 25.0f; s_status.updated_ms = 0; @@ -453,6 +468,11 @@ void printer_protocol_get_runtime_status(printer_runtime_status_t *out_status) { s_status.has_paper = sensors.has_paper; s_status.paper_gpio_level = sensors.paper_gpio_level; s_status.paper_present_level = sensors.paper_present_level; + s_status.battery_adc_ready = sensors.battery_adc_ready; + s_status.ntc_adc_ready = sensors.ntc_adc_ready; + s_status.battery_adc_raw = sensors.battery_adc_raw; + s_status.ntc_adc_raw = sensors.ntc_adc_raw; + s_status.battery_mv = sensors.battery_mv; s_status.battery = sensors.battery_percent; s_status.temperature = sensors.temperature_c; s_status.updated_ms = sensors.updated_ms; @@ -464,6 +484,11 @@ void printer_protocol_get_runtime_status(printer_runtime_status_t *out_status) { out_status->has_paper = s_status.has_paper; out_status->paper_gpio_level = s_status.paper_gpio_level; out_status->paper_present_level = s_status.paper_present_level; + out_status->battery_adc_ready = s_status.battery_adc_ready; + out_status->ntc_adc_ready = s_status.ntc_adc_ready; + out_status->battery_adc_raw = s_status.battery_adc_raw; + out_status->ntc_adc_raw = s_status.ntc_adc_raw; + out_status->battery_mv = s_status.battery_mv; out_status->battery_percent = s_status.battery; out_status->temperature = s_status.temperature; out_status->last_status_ms = s_status.updated_ms; diff --git a/components/domain/src/printer_protocol_worker.c b/components/domain/src/printer_protocol_worker.c index 8d99906..3ec9fd3 100644 --- a/components/domain/src/printer_protocol_worker.c +++ b/components/domain/src/printer_protocol_worker.c @@ -48,6 +48,9 @@ static uint16_t density_to_hot_time(const char *density) { if (density == NULL) { return 2000; } + if (strcmp(density, "test") == 0) { + return 200; + } if (strcmp(density, "较淡") == 0) { return 1000; } diff --git a/components/domain/src/product_test_service.c b/components/domain/src/product_test_service.c new file mode 100644 index 0000000..1ac3492 --- /dev/null +++ b/components/domain/src/product_test_service.c @@ -0,0 +1,1283 @@ +#include "domain.h" +#include "platform.h" + +#include +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "sdkconfig.h" + +#ifndef CONFIG_TQ_PRODUCT_TEST_UART_ENABLE +#define CONFIG_TQ_PRODUCT_TEST_UART_ENABLE 1 +#endif + +#ifndef CONFIG_TQ_PRODUCT_TEST_UART_PORT +#define CONFIG_TQ_PRODUCT_TEST_UART_PORT 0 +#endif + +#ifndef CONFIG_TQ_PRODUCT_TEST_UART_BAUD +#define CONFIG_TQ_PRODUCT_TEST_UART_BAUD 115200 +#endif + +#ifndef CONFIG_TQ_PRODUCT_TEST_UART_TX_PIN +#define CONFIG_TQ_PRODUCT_TEST_UART_TX_PIN -1 +#endif + +#ifndef CONFIG_TQ_PRODUCT_TEST_UART_RX_PIN +#define CONFIG_TQ_PRODUCT_TEST_UART_RX_PIN -1 +#endif + +#define PRODUCT_TEST_PROTOCOL_VERSION 1 +#define PRODUCT_TEST_LINE_MAX 1024 +#define PRODUCT_TEST_TASK_STACK 12288 +#define PRODUCT_TEST_TASK_PRIO 4 +#define PRODUCT_TEST_UART_RX_BUFFER 4096 +#define PRODUCT_TEST_UART_TX_BUFFER 2048 +#define PRODUCT_TEST_UART_WRITE_TIMEOUT_MS 1000 +#define PRODUCT_TEST_UART_READ_TIMEOUT_MS 100 +#define PRODUCT_TEST_DEFAULT_TIMEOUT_MS 30000 +#define PRODUCT_TEST_WIFI_WAIT_MS 20000 +#define PRODUCT_TEST_WIFI_POLL_MS 250 +#define PRODUCT_TEST_JOB_POLL_MS 250 +#define PRODUCT_TEST_JOB_DEFAULT_TIMEOUT_MS 90000 +#define PRODUCT_TEST_VOICE_WAIT_POLL_MS 100 +#define PRODUCT_TEST_VOICE_START_TIMEOUT_MS 12000 +#define PRODUCT_TEST_VOICE_PROBE_SAMPLES 320 +#define PRODUCT_TEST_PREVIEW_TIMEOUT_MS 3000 +#define PRODUCT_TEST_PRINT_PATTERN_HEIGHT 96 +#define PRODUCT_TEST_IMAGE_PRINT_MAX_HEIGHT 256 +#define PRODUCT_TEST_IMAGE_DEFAULT_TIMEOUT_MS 90000 +#define PRODUCT_TEST_IMAGE_DOWNLOAD_TIMEOUT_MS 30000 + +static const char *TAG = "product_test"; + +static TaskHandle_t s_task; +static SemaphoreHandle_t s_write_lock; +static volatile bool s_stop_requested; + +static char *json_print(cJSON *obj) { + return cJSON_PrintUnformatted(obj); +} + +static void *test_malloc(size_t size) { + void *ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (ptr == NULL) { + ptr = malloc(size); + } + return ptr; +} + +static void test_free(void *ptr) { + free(ptr); +} + +static int64_t now_ms(void) { + return esp_timer_get_time() / 1000; +} + +static uint32_t clamp_u32(uint32_t value, uint32_t min_value, uint32_t max_value) { + if (value < min_value) { + return min_value; + } + if (value > max_value) { + return max_value; + } + return value; +} + +static void product_test_write_raw_line(const char *line) { + if (line == NULL || s_write_lock == NULL) { + return; + } + + if (xSemaphoreTake(s_write_lock, pdMS_TO_TICKS(PRODUCT_TEST_UART_WRITE_TIMEOUT_MS)) != pdTRUE) { + ESP_LOGW(TAG, "protocol write lock timeout"); + return; + } + esp_err_t rc = platform_test_uart_write_line(line, PRODUCT_TEST_UART_WRITE_TIMEOUT_MS); + xSemaphoreGive(s_write_lock); + if (rc != ESP_OK) { + ESP_LOGW(TAG, "protocol write failed: rc=0x%x", (unsigned)rc); + } +} + +static void product_test_emit_json(const char *token, cJSON *payload) { + if (token == NULL || payload == NULL) { + return; + } + + char *json = json_print(payload); + if (json == NULL) { + ESP_LOGW(TAG, "protocol json print failed: token=%s", token); + return; + } + + size_t token_len = strlen(token); + size_t json_len = strlen(json); + size_t line_len = token_len + 1 + json_len; + char *line = (char *)test_malloc(line_len + 1); + if (line == NULL) { + cJSON_free(json); + ESP_LOGW(TAG, "protocol line alloc failed: token=%s len=%u", token, (unsigned)line_len); + return; + } + + memcpy(line, token, token_len); + line[token_len] = ' '; + memcpy(line + token_len + 1, json, json_len); + line[line_len] = '\0'; + + product_test_write_raw_line(line); + test_free(line); + cJSON_free(json); +} + +static cJSON *make_base_payload(uint32_t seq, const char *name) { + cJSON *root = cJSON_CreateObject(); + if (root == NULL) { + return NULL; + } + cJSON_AddNumberToObject(root, "seq", (double)seq); + if (name != NULL && name[0] != '\0') { + cJSON_AddStringToObject(root, "name", name); + } + cJSON_AddNumberToObject(root, "ms", (double)now_ms()); + return root; +} + +static void emit_ready(void) { + cJSON *root = cJSON_CreateObject(); + if (root == NULL) { + return; + } + cJSON_AddNumberToObject(root, "version", PRODUCT_TEST_PROTOCOL_VERSION); + cJSON_AddStringToObject(root, "device", "TQ_printer"); + cJSON_AddNumberToObject(root, "idf_free_heap", (double)esp_get_free_heap_size()); + cJSON_AddNumberToObject(root, "uart_port", CONFIG_TQ_PRODUCT_TEST_UART_PORT); + cJSON_AddNumberToObject(root, "baud", CONFIG_TQ_PRODUCT_TEST_UART_BAUD); + product_test_emit_json("TQTEST_READY", root); + cJSON_Delete(root); +} + +static void emit_progress(uint32_t seq, const char *name, const char *stage, cJSON *extra) { + cJSON *root = make_base_payload(seq, name); + if (root == NULL) { + return; + } + if (stage != NULL) { + cJSON_AddStringToObject(root, "stage", stage); + } + if (extra != NULL) { + cJSON_AddItemToObject(root, "data", extra); + extra = NULL; + } + product_test_emit_json("TQTEST_PROGRESS", root); + cJSON_Delete(root); +} + +static void emit_result(uint32_t seq, + const char *name, + const char *status, + esp_err_t rc, + int64_t duration_ms, + cJSON *data, + const char *message) { + cJSON *root = make_base_payload(seq, name); + if (root == NULL) { + return; + } + cJSON_AddStringToObject(root, "status", status != NULL ? status : "error"); + cJSON_AddNumberToObject(root, "rc", (double)rc); + cJSON_AddStringToObject(root, "esp_err", esp_err_to_name(rc)); + if (duration_ms >= 0) { + cJSON_AddNumberToObject(root, "duration_ms", (double)duration_ms); + } + if (message != NULL && message[0] != '\0') { + cJSON_AddStringToObject(root, "message", message); + } + if (data != NULL) { + cJSON_AddItemToObject(root, "data", data); + data = NULL; + } + product_test_emit_json("TQTEST_RESULT", root); + cJSON_Delete(root); +} + +static void emit_error(uint32_t seq, const char *name, esp_err_t rc, const char *fmt, ...) { + char message[192] = {0}; + if (fmt != NULL) { + va_list ap; + va_start(ap, fmt); + vsnprintf(message, sizeof(message), fmt, ap); + va_end(ap); + } + emit_result(seq, name, "error", rc, -1, NULL, message); +} + +static const char *json_get_str(cJSON *obj, const char *key, const char *fallback) { + if (obj == NULL || key == NULL) { + return fallback; + } + cJSON *item = cJSON_GetObjectItemCaseSensitive(obj, key); + if (cJSON_IsString(item) && item->valuestring != NULL) { + return item->valuestring; + } + return fallback; +} + +static uint32_t json_get_u32(cJSON *obj, const char *key, uint32_t fallback) { + if (obj == NULL || key == NULL) { + return fallback; + } + cJSON *item = cJSON_GetObjectItemCaseSensitive(obj, key); + if (!cJSON_IsNumber(item) || item->valuedouble < 0.0) { + return fallback; + } + if (item->valuedouble > 4294967295.0) { + return fallback; + } + return (uint32_t)item->valuedouble; +} + +static bool json_get_bool(cJSON *obj, const char *key, bool fallback) { + if (obj == NULL || key == NULL) { + return fallback; + } + cJSON *item = cJSON_GetObjectItemCaseSensitive(obj, key); + if (cJSON_IsBool(item)) { + return cJSON_IsTrue(item); + } + return fallback; +} + +static void add_printer_status_fields(cJSON *obj, const printer_runtime_status_t *printer) { + if (obj == NULL || printer == NULL) { + return; + } + + cJSON_AddBoolToObject(obj, "busy", printer->busy); + cJSON_AddBoolToObject(obj, "has_paper", printer->has_paper); + cJSON_AddNumberToObject(obj, "paper_gpio_level", printer->paper_gpio_level); + cJSON_AddNumberToObject(obj, "paper_present_level", printer->paper_present_level); + cJSON_AddBoolToObject(obj, "battery_adc_ready", printer->battery_adc_ready); + cJSON_AddBoolToObject(obj, "ntc_adc_ready", printer->ntc_adc_ready); + cJSON_AddNumberToObject(obj, "battery_adc_raw", printer->battery_adc_raw); + cJSON_AddNumberToObject(obj, "ntc_adc_raw", printer->ntc_adc_raw); + cJSON_AddNumberToObject(obj, "battery_mv", printer->battery_mv); + cJSON_AddNumberToObject(obj, "battery_percent", printer->battery_percent); + cJSON_AddNumberToObject(obj, "temperature_c", printer->temperature); + cJSON_AddNumberToObject(obj, "temperature_min_c", runtime_policy_printer_temp_min_c()); + cJSON_AddNumberToObject(obj, "temperature_max_c", runtime_policy_printer_temp_max_c()); + cJSON_AddNumberToObject(obj, "battery_min_percent", runtime_policy_printer_battery_min_percent()); + cJSON_AddNumberToObject(obj, "ntc_pullup_ohms", runtime_policy_printer_ntc_pullup_ohms()); + cJSON_AddNumberToObject(obj, "ntc_r25_ohms", runtime_policy_printer_ntc_r25_ohms()); + cJSON_AddNumberToObject(obj, "ntc_beta", runtime_policy_printer_ntc_beta()); + cJSON_AddNumberToObject(obj, "queue_depth", printer->queue_depth); + cJSON_AddNumberToObject(obj, "last_status_ms", (double)printer->last_status_ms); +} + +static void fill_status_payload(cJSON *root) { + if (root == NULL) { + return; + } + + cJSON_AddBoolToObject(root, "wifi_ready", system_runtime_wifi_ready()); + char ip[20] = {0}; + system_runtime_get_ip(ip, sizeof(ip)); + cJSON_AddStringToObject(root, "ip", ip[0] != '\0' ? ip : "0.0.0.0"); + + printer_runtime_status_t printer = {0}; + printer_protocol_get_runtime_status(&printer); + cJSON *printer_obj = cJSON_AddObjectToObject(root, "printer"); + if (printer_obj != NULL) { + add_printer_status_fields(printer_obj, &printer); + } + + voice_interaction_status_t voice = {0}; + voice_interaction_get_status(&voice); + cJSON *voice_obj = cJSON_AddObjectToObject(root, "voice"); + if (voice_obj != NULL) { + cJSON_AddBoolToObject(voice_obj, "ready", voice.ready); + cJSON_AddBoolToObject(voice_obj, "session_active", voice.session_active); + cJSON_AddBoolToObject(voice_obj, "ws_connected", voice.ws_connected); + cJSON_AddBoolToObject(voice_obj, "started", voice.started); + cJSON_AddBoolToObject(voice_obj, "tap_active", voice.tap_active); + cJSON_AddBoolToObject(voice_obj, "marker_image_gen_running", voice.marker_image_gen_running); + cJSON_AddStringToObject(voice_obj, "dialog_state", voice_interaction_dialog_state_str(voice.dialog_state)); + cJSON_AddNumberToObject(voice_obj, "upstream_packets", voice.upstream_packets); + cJSON_AddNumberToObject(voice_obj, "downstream_packets", voice.downstream_packets); + cJSON_AddStringToObject(voice_obj, "task_id", voice.task_id); + cJSON_AddStringToObject(voice_obj, "dialog_id", voice.dialog_id); + cJSON_AddStringToObject(voice_obj, "last_error", voice.last_error); + cJSON_AddNumberToObject(voice_obj, "last_event_ms", (double)voice.last_event_ms); + } + + cJSON_AddNumberToObject(root, "free_heap", (double)esp_get_free_heap_size()); + cJSON_AddNumberToObject(root, "min_free_heap", (double)esp_get_minimum_free_heap_size()); +} + +static esp_err_t wait_wifi_ready(uint32_t timeout_ms) { + int64_t deadline = now_ms() + timeout_ms; + while (!s_stop_requested && now_ms() < deadline) { + if (system_runtime_wifi_ready()) { + return ESP_OK; + } + vTaskDelay(pdMS_TO_TICKS(PRODUCT_TEST_WIFI_POLL_MS)); + } + return system_runtime_wifi_ready() ? ESP_OK : ESP_ERR_TIMEOUT; +} + +static esp_err_t wait_voice_started(uint32_t timeout_ms) { + int64_t deadline = now_ms() + timeout_ms; + while (!s_stop_requested && now_ms() < deadline) { + voice_interaction_status_t status = {0}; + voice_interaction_get_status(&status); + if (status.session_active && status.ws_connected && status.started) { + return ESP_OK; + } + if (status.last_error[0] != '\0' && !status.session_active) { + return ESP_FAIL; + } + vTaskDelay(pdMS_TO_TICKS(PRODUCT_TEST_VOICE_WAIT_POLL_MS)); + } + return ESP_ERR_TIMEOUT; +} + +static esp_err_t wait_job_done(uint32_t job_id, uint32_t timeout_ms, print_job_info_t *out_job) { + int64_t deadline = now_ms() + timeout_ms; + print_job_info_t job = {0}; + while (!s_stop_requested && now_ms() < deadline) { + if (!printer_protocol_get_job(job_id, &job)) { + return ESP_ERR_NOT_FOUND; + } + if (job.state == PRINT_JOB_STATE_SUCCESS) { + if (out_job != NULL) { + *out_job = job; + } + return ESP_OK; + } + if (job.state == PRINT_JOB_STATE_FAILED || job.state == PRINT_JOB_STATE_CANCELED) { + if (out_job != NULL) { + *out_job = job; + } + return ESP_FAIL; + } + vTaskDelay(pdMS_TO_TICKS(PRODUCT_TEST_JOB_POLL_MS)); + } + + if (printer_protocol_get_job(job_id, &job) && out_job != NULL) { + *out_job = job; + } + return ESP_ERR_TIMEOUT; +} + +static esp_err_t build_print_pattern(uint16_t height, uint8_t **out_raster, size_t *out_len) { + if (out_raster == NULL || out_len == NULL || height == 0) { + return ESP_ERR_INVALID_ARG; + } + + const uint16_t width = 384; + const size_t line_bytes = width / 8u; + size_t len = line_bytes * height; + uint8_t *raster = (uint8_t *)test_malloc(len); + if (raster == NULL) { + return ESP_ERR_NO_MEM; + } + memset(raster, 0, len); + + for (uint16_t y = 0; y < height; ++y) { + uint16_t phase = (uint16_t)((y * 7u) & 0x1Fu); + for (uint16_t x = phase; x < width; x = (uint16_t)(x + 32u)) { + for (uint16_t dot = 0; dot < 3u && x + dot < width; ++dot) { + uint16_t px = (uint16_t)(x + dot); + raster[(size_t)y * line_bytes + (px / 8u)] |= (uint8_t)(0x80u >> (px & 0x07u)); + } + } + } + + *out_raster = raster; + *out_len = len; + return ESP_OK; +} + +static cJSON *job_payload(uint32_t job_id, const print_job_info_t *job) { + cJSON *data = cJSON_CreateObject(); + if (data == NULL) { + return NULL; + } + cJSON_AddNumberToObject(data, "job_id", job_id); + if (job != NULL) { + cJSON_AddStringToObject(data, "job_state", printer_protocol_job_state_str(job->state)); + cJSON_AddNumberToObject(data, "progress", job->progress); + cJSON_AddNumberToObject(data, "width", job->width); + cJSON_AddNumberToObject(data, "height", job->height); + cJSON_AddNumberToObject(data, "data_len", (double)job->data_len); + cJSON_AddStringToObject(data, "error", job->error); + } + return data; +} + +static void handle_status(uint32_t seq, const char *name) { + int64_t start = now_ms(); + cJSON *data = cJSON_CreateObject(); + if (data != NULL) { + fill_status_payload(data); + } + emit_result(seq, name, "ok", ESP_OK, now_ms() - start, data, NULL); +} + +static void handle_ping(uint32_t seq, const char *name) { + cJSON *data = cJSON_CreateObject(); + if (data != NULL) { + cJSON_AddNumberToObject(data, "version", PRODUCT_TEST_PROTOCOL_VERSION); + cJSON_AddNumberToObject(data, "free_heap", (double)esp_get_free_heap_size()); + } + emit_result(seq, name, "ok", ESP_OK, 0, data, NULL); +} + +static void handle_wait_wifi(uint32_t seq, const char *name, cJSON *payload) { + uint32_t timeout_ms = json_get_u32(payload, "timeout_ms", PRODUCT_TEST_WIFI_WAIT_MS); + timeout_ms = clamp_u32(timeout_ms, 1000, 120000); + + int64_t start = now_ms(); + emit_progress(seq, name, "wait_wifi", NULL); + esp_err_t rc = wait_wifi_ready(timeout_ms); + cJSON *data = cJSON_CreateObject(); + if (data != NULL) { + cJSON_AddBoolToObject(data, "wifi_ready", system_runtime_wifi_ready()); + char ip[20] = {0}; + system_runtime_get_ip(ip, sizeof(ip)); + cJSON_AddStringToObject(data, "ip", ip[0] != '\0' ? ip : "0.0.0.0"); + } + emit_result(seq, + name, + rc == ESP_OK ? "ok" : "fail", + rc, + now_ms() - start, + data, + rc == ESP_OK ? NULL : "wifi not ready"); +} + +static void handle_screen(uint32_t seq, const char *name, cJSON *payload) { + const char *text = json_get_str(payload, "text", "TQ Printer\nProduct Test\nScreen OK"); + uint32_t timeout_ms = json_get_u32(payload, "timeout_ms", PRODUCT_TEST_PREVIEW_TIMEOUT_MS); + timeout_ms = clamp_u32(timeout_ms, 500, 30000); + + int64_t start = now_ms(); + emit_progress(seq, name, "screen_preview", NULL); + char err[128] = {0}; + esp_err_t rc = screen_preview_show_text(text, timeout_ms, err, sizeof(err)); + cJSON *data = cJSON_CreateObject(); + if (data != NULL) { + cJSON_AddNumberToObject(data, "text_len", (double)strlen(text)); + } + emit_result(seq, + name, + rc == ESP_OK || rc == ESP_ERR_NOT_SUPPORTED ? "ok" : "fail", + rc, + now_ms() - start, + data, + err[0] != '\0' ? err : NULL); +} + +static void handle_printer_status(uint32_t seq, const char *name) { + int64_t start = now_ms(); + printer_runtime_status_t printer = {0}; + printer_protocol_get_runtime_status(&printer); + cJSON *data = cJSON_CreateObject(); + if (data != NULL) { + add_printer_status_fields(data, &printer); + } + emit_result(seq, name, "ok", ESP_OK, now_ms() - start, data, NULL); +} + +static void handle_gap(uint32_t seq, const char *name, cJSON *payload) { + uint32_t timeout_ms = json_get_u32(payload, "timeout_ms", PRODUCT_TEST_DEFAULT_TIMEOUT_MS); + timeout_ms = clamp_u32(timeout_ms, 1000, 120000); + + int64_t start = now_ms(); + emit_progress(seq, name, "gap_move", NULL); + char err[128] = {0}; + esp_err_t rc = printer_protocol_gap_move(timeout_ms, err, sizeof(err)); + emit_result(seq, + name, + rc == ESP_OK ? "ok" : "fail", + rc, + now_ms() - start, + NULL, + err[0] != '\0' ? err : NULL); +} + +static void handle_print_pattern(uint32_t seq, const char *name, cJSON *payload) { + uint32_t timeout_ms = json_get_u32(payload, "timeout_ms", PRODUCT_TEST_JOB_DEFAULT_TIMEOUT_MS); + timeout_ms = clamp_u32(timeout_ms, 3000, 300000); + uint32_t height_arg = json_get_u32(payload, "height", PRODUCT_TEST_PRINT_PATTERN_HEIGHT); + uint16_t height = (uint16_t)clamp_u32(height_arg, 16, 512); + bool wait_done = json_get_bool(payload, "wait_done", true); + bool ignore_precheck = json_get_bool(payload, "ignore_precheck", false); + + int64_t start = now_ms(); + emit_progress(seq, name, "build_pattern", NULL); + + uint8_t *raster = NULL; + size_t raster_len = 0; + esp_err_t rc = build_print_pattern(height, &raster, &raster_len); + if (rc != ESP_OK) { + emit_result(seq, name, "fail", rc, now_ms() - start, NULL, "build print pattern failed"); + return; + } + + printer_print_options_t options = { + .ignore_precheck = ignore_precheck, + }; + uint32_t job_id = 0; + char err[128] = {0}; + emit_progress(seq, name, "submit_print", NULL); + rc = printer_protocol_submit_raster_job_ex(raster, + raster_len, + 384, + height, + "test", + &options, + &job_id, + err, + sizeof(err)); + test_free(raster); + if (rc != ESP_OK) { + emit_result(seq, + name, + "fail", + rc, + now_ms() - start, + NULL, + err[0] != '\0' ? err : "print submit failed"); + return; + } + + print_job_info_t job = {0}; + if (wait_done) { + emit_progress(seq, name, "wait_print_done", job_payload(job_id, NULL)); + rc = wait_job_done(job_id, timeout_ms, &job); + } else { + (void)printer_protocol_get_job(job_id, &job); + } + + cJSON *data = job_payload(job_id, wait_done ? &job : NULL); + emit_result(seq, + name, + rc == ESP_OK ? "ok" : (wait_done ? "fail" : "ok"), + wait_done ? rc : ESP_OK, + now_ms() - start, + data, + (wait_done && job.error[0] != '\0') ? job.error : NULL); +} + +static void handle_image_generate(uint32_t seq, const char *name, cJSON *payload, bool should_print) { + const char *prompt = json_get_str(payload, + "prompt", + "可爱的猫咪线稿,白色背景,边缘清晰,适合热敏打印"); + const char *size = json_get_str(payload, "size", CONFIG_TQ_Z_IMAGE_DEFAULT_SIZE); + uint32_t generation_timeout_ms = json_get_u32(payload, + "generation_timeout_ms", + PRODUCT_TEST_IMAGE_DEFAULT_TIMEOUT_MS); + uint32_t download_timeout_ms = json_get_u32(payload, + "download_timeout_ms", + PRODUCT_TEST_IMAGE_DOWNLOAD_TIMEOUT_MS); + generation_timeout_ms = clamp_u32(generation_timeout_ms, 5000, 180000); + download_timeout_ms = clamp_u32(download_timeout_ms, 2000, 120000); + bool prompt_extend = json_get_bool(payload, "prompt_extend", true); + bool ignore_precheck = json_get_bool(payload, "ignore_precheck", false); + bool wait_done = json_get_bool(payload, "wait_done", true); + + int64_t start = now_ms(); + emit_progress(seq, name, "image_generate", NULL); + + image_generation_request_t req = { + .prompt = prompt, + .size = size, + .prompt_extend = prompt_extend, + .has_seed = false, + .seed = 0, + .generation_timeout_ms = generation_timeout_ms, + .download_timeout_ms = download_timeout_ms, + }; + image_generation_result_t result = {0}; + char err[160] = {0}; + esp_err_t rc = image_generation_generate_png(&req, &result, err, sizeof(err)); + if (rc != ESP_OK) { + emit_result(seq, + name, + "fail", + rc, + now_ms() - start, + NULL, + err[0] != '\0' ? err : "image generation failed"); + return; + } + + cJSON *image_data = cJSON_CreateObject(); + if (image_data != NULL) { + cJSON_AddStringToObject(image_data, "request_id", result.request_id); + cJSON_AddNumberToObject(image_data, "png_len", (double)result.png_len); + cJSON_AddNumberToObject(image_data, "width", result.width); + cJSON_AddNumberToObject(image_data, "height", result.height); + cJSON_AddStringToObject(image_data, "image_url", result.image_url); + } + + if (!should_print) { + image_generation_result_free(&result); + emit_result(seq, name, "ok", ESP_OK, now_ms() - start, image_data, NULL); + return; + } + + emit_progress(seq, name, "image_preview", NULL); + char preview_err[96] = {0}; + esp_err_t preview_rc = screen_preview_show_png(result.png, + result.png_len, + PRODUCT_TEST_PREVIEW_TIMEOUT_MS, + preview_err, + sizeof(preview_err)); + if (preview_rc != ESP_OK && preview_rc != ESP_ERR_NOT_SUPPORTED) { + ESP_LOGW(TAG, + "product test image preview failed: rc=0x%x msg=%s", + (unsigned)preview_rc, + preview_err[0] != '\0' ? preview_err : "-"); + } + + uint16_t raster_width = 0; + uint16_t raster_height = 0; + uint8_t *raster = NULL; + size_t raster_len = 0; + emit_progress(seq, name, "image_rasterize", NULL); + rc = raster_tools_convert_png_to_raster_384(result.png, + result.png_len, + true, + 160, + false, + PRODUCT_TEST_IMAGE_PRINT_MAX_HEIGHT, + &raster_width, + &raster_height, + &raster, + &raster_len, + err, + sizeof(err)); + image_generation_result_free(&result); + if (rc != ESP_OK) { + emit_result(seq, + name, + "fail", + rc, + now_ms() - start, + image_data, + err[0] != '\0' ? err : "image rasterize failed"); + return; + } + + printer_print_options_t options = { + .ignore_precheck = ignore_precheck, + }; + uint32_t job_id = 0; + emit_progress(seq, name, "submit_print", NULL); + rc = printer_protocol_submit_raster_job_ex(raster, + raster_len, + raster_width, + raster_height, + "test", + &options, + &job_id, + err, + sizeof(err)); + test_free(raster); + if (rc != ESP_OK) { + emit_result(seq, + name, + "fail", + rc, + now_ms() - start, + image_data, + err[0] != '\0' ? err : "image print submit failed"); + return; + } + + if (image_data != NULL) { + cJSON_AddNumberToObject(image_data, "job_id", job_id); + cJSON_AddNumberToObject(image_data, "raster_width", raster_width); + cJSON_AddNumberToObject(image_data, "raster_height", raster_height); + cJSON_AddNumberToObject(image_data, "raster_len", (double)raster_len); + } + + print_job_info_t job = {0}; + if (wait_done) { + uint32_t print_timeout_ms = json_get_u32(payload, "print_timeout_ms", PRODUCT_TEST_JOB_DEFAULT_TIMEOUT_MS); + print_timeout_ms = clamp_u32(print_timeout_ms, 3000, 300000); + emit_progress(seq, name, "wait_print_done", job_payload(job_id, NULL)); + rc = wait_job_done(job_id, print_timeout_ms, &job); + if (image_data != NULL) { + cJSON_AddStringToObject(image_data, "job_state", printer_protocol_job_state_str(job.state)); + cJSON_AddNumberToObject(image_data, "progress", job.progress); + cJSON_AddStringToObject(image_data, "job_error", job.error); + } + } + + emit_result(seq, + name, + rc == ESP_OK ? "ok" : "fail", + rc, + now_ms() - start, + image_data, + (job.error[0] != '\0') ? job.error : NULL); +} + +static void handle_voice_session(uint32_t seq, const char *name, cJSON *payload) { + uint32_t timeout_ms = json_get_u32(payload, "timeout_ms", PRODUCT_TEST_VOICE_START_TIMEOUT_MS); + timeout_ms = clamp_u32(timeout_ms, 1000, 30000); + + int64_t start = now_ms(); + emit_progress(seq, name, "voice_start", NULL); + char err[160] = {0}; + esp_err_t rc = voice_interaction_start_with_timeout(timeout_ms, err, sizeof(err)); + if (rc == ESP_OK) { + emit_progress(seq, name, "voice_wait_started", NULL); + rc = wait_voice_started(timeout_ms); + } + + voice_interaction_status_t voice = {0}; + voice_interaction_get_status(&voice); + cJSON *data = cJSON_CreateObject(); + if (data != NULL) { + cJSON_AddBoolToObject(data, "session_active", voice.session_active); + cJSON_AddBoolToObject(data, "ws_connected", voice.ws_connected); + cJSON_AddBoolToObject(data, "started", voice.started); + cJSON_AddStringToObject(data, "dialog_state", voice_interaction_dialog_state_str(voice.dialog_state)); + cJSON_AddNumberToObject(data, "upstream_packets", voice.upstream_packets); + cJSON_AddNumberToObject(data, "downstream_packets", voice.downstream_packets); + cJSON_AddStringToObject(data, "last_error", voice.last_error); + } + emit_result(seq, + name, + rc == ESP_OK ? "ok" : "fail", + rc, + now_ms() - start, + data, + err[0] != '\0' ? err : (voice.last_error[0] != '\0' ? voice.last_error : NULL)); +} + +static void handle_voice_stop(uint32_t seq, const char *name, cJSON *payload) { + uint32_t timeout_ms = json_get_u32(payload, "timeout_ms", 3000); + timeout_ms = clamp_u32(timeout_ms, 500, 10000); + + int64_t start = now_ms(); + emit_progress(seq, name, "voice_stop", NULL); + char err[128] = {0}; + esp_err_t rc = voice_interaction_stop_with_timeout(timeout_ms, err, sizeof(err)); + emit_result(seq, + name, + rc == ESP_OK ? "ok" : "fail", + rc, + now_ms() - start, + NULL, + err[0] != '\0' ? err : NULL); +} + +static void handle_audio_probe(uint32_t seq, const char *name, cJSON *payload) { + uint32_t timeout_ms = json_get_u32(payload, "timeout_ms", 2000); + timeout_ms = clamp_u32(timeout_ms, 500, 10000); + uint32_t samples_arg = json_get_u32(payload, "samples", PRODUCT_TEST_VOICE_PROBE_SAMPLES); + size_t samples = (size_t)clamp_u32(samples_arg, 80, 1600); + + int64_t start = now_ms(); + emit_progress(seq, name, "audio_open", NULL); + char err[128] = {0}; + voice_audio_open_config_t cfg = { + .sample_rate = CONFIG_TQ_VOICE_SAMPLE_RATE, + .channels = 1, + .bits_per_sample = 16, + .output_volume = CONFIG_TQ_VOICE_CODEC_OUT_VOL, + .input_gain_db = (float)CONFIG_TQ_VOICE_CODEC_MIC_GAIN_DB, + }; + esp_err_t rc = voice_audio_open(&cfg, err, sizeof(err)); + int16_t *pcm = NULL; + if (rc == ESP_OK) { + pcm = (int16_t *)test_malloc(samples * sizeof(int16_t)); + if (pcm == NULL) { + rc = ESP_ERR_NO_MEM; + } + } + if (rc == ESP_OK) { + emit_progress(seq, name, "audio_read", NULL); + rc = voice_audio_read_pcm(pcm, samples, timeout_ms, err, sizeof(err)); + } + + uint32_t abs_avg = 0; + int16_t peak = 0; + if (rc == ESP_OK && pcm != NULL) { + uint64_t sum = 0; + int32_t peak_abs = 0; + for (size_t i = 0; i < samples; ++i) { + int32_t v = pcm[i]; + int32_t a = v < 0 ? -v : v; + sum += (uint32_t)a; + if (a > peak_abs) { + peak_abs = a; + } + } + abs_avg = (uint32_t)(sum / samples); + peak = (int16_t)peak_abs; + } + test_free(pcm); + voice_audio_close(); + + cJSON *data = cJSON_CreateObject(); + if (data != NULL) { + cJSON_AddNumberToObject(data, "samples", (double)samples); + cJSON_AddNumberToObject(data, "abs_avg", abs_avg); + cJSON_AddNumberToObject(data, "peak_abs", peak); + } + emit_result(seq, + name, + rc == ESP_OK ? "ok" : "fail", + rc, + now_ms() - start, + data, + err[0] != '\0' ? err : NULL); +} + +static void handle_negative(uint32_t seq, const char *name, cJSON *payload) { + const char *case_name = json_get_str(payload, "case", "invalid_print"); + int64_t start = now_ms(); + esp_err_t rc = ESP_OK; + bool expected_failure = false; + char err[128] = {0}; + cJSON *data = cJSON_CreateObject(); + + if (strcmp(case_name, "invalid_print") == 0) { + uint8_t bad_raster[8] = {0}; + uint32_t job_id = 0; + emit_progress(seq, name, "invalid_print_submit", NULL); + rc = printer_protocol_submit_raster_job(bad_raster, + sizeof(bad_raster), + 128, + 1, + "test", + &job_id, + err, + sizeof(err)); + expected_failure = (rc != ESP_OK); + if (data != NULL) { + cJSON_AddNumberToObject(data, "submit_rc", (double)rc); + cJSON_AddStringToObject(data, "submit_err", err); + } + } else if (strcmp(case_name, "voice_tap_without_session") == 0) { + (void)voice_interaction_stop_with_timeout(1000, NULL, 0); + emit_progress(seq, name, "voice_tap_without_session", NULL); + rc = voice_interaction_tap_start(err, sizeof(err)); + expected_failure = (rc != ESP_OK); + if (data != NULL) { + cJSON_AddNumberToObject(data, "tap_rc", (double)rc); + cJSON_AddStringToObject(data, "tap_err", err); + } + } else { + if (data != NULL) { + cJSON_AddStringToObject(data, "case", case_name); + } + emit_result(seq, name, "error", ESP_ERR_INVALID_ARG, now_ms() - start, data, "unknown negative case"); + return; + } + + if (data != NULL) { + cJSON_AddStringToObject(data, "case", case_name); + cJSON_AddBoolToObject(data, "expected_failure", expected_failure); + } + emit_result(seq, + name, + expected_failure ? "ok" : "fail", + expected_failure ? ESP_OK : ESP_FAIL, + now_ms() - start, + data, + expected_failure ? NULL : "negative case unexpectedly succeeded"); +} + +static void handle_stability(uint32_t seq, const char *name, cJSON *payload) { + uint32_t loops = json_get_u32(payload, "loops", 3); + loops = clamp_u32(loops, 1, 100); + bool include_print = json_get_bool(payload, "include_print", false); + bool include_audio = json_get_bool(payload, "include_audio", true); + + int64_t start = now_ms(); + uint32_t passed = 0; + uint32_t failed = 0; + for (uint32_t i = 0; i < loops && !s_stop_requested; ++i) { + cJSON *progress = cJSON_CreateObject(); + if (progress != NULL) { + cJSON_AddNumberToObject(progress, "loop", i + 1); + cJSON_AddNumberToObject(progress, "loops", loops); + } + emit_progress(seq, name, "loop", progress); + + esp_err_t rc = wait_wifi_ready(1000); + if (rc == ESP_OK && include_audio) { + char audio_err[96] = {0}; + voice_audio_open_config_t cfg = { + .sample_rate = CONFIG_TQ_VOICE_SAMPLE_RATE, + .channels = 1, + .bits_per_sample = 16, + .output_volume = CONFIG_TQ_VOICE_CODEC_OUT_VOL, + .input_gain_db = (float)CONFIG_TQ_VOICE_CODEC_MIC_GAIN_DB, + }; + rc = voice_audio_open(&cfg, audio_err, sizeof(audio_err)); + if (rc == ESP_OK) { + int16_t pcm[80] = {0}; + rc = voice_audio_read_pcm(pcm, 80, 1000, audio_err, sizeof(audio_err)); + } + voice_audio_close(); + } + if (rc == ESP_OK && include_print) { + char gap_err[96] = {0}; + rc = printer_protocol_gap_move(5000, gap_err, sizeof(gap_err)); + } + + if (rc == ESP_OK) { + ++passed; + } else { + ++failed; + } + vTaskDelay(pdMS_TO_TICKS(50)); + } + + cJSON *data = cJSON_CreateObject(); + if (data != NULL) { + cJSON_AddNumberToObject(data, "loops", loops); + cJSON_AddNumberToObject(data, "passed", passed); + cJSON_AddNumberToObject(data, "failed", failed); + } + emit_result(seq, + name, + failed == 0 ? "ok" : "fail", + failed == 0 ? ESP_OK : ESP_FAIL, + now_ms() - start, + data, + failed == 0 ? NULL : "stability loop failure"); +} + +static void handle_full_flow(uint32_t seq, const char *name, cJSON *payload) { + bool include_image = json_get_bool(payload, "include_image", false); + bool include_print = json_get_bool(payload, "include_print", true); + bool include_voice = json_get_bool(payload, "include_voice", true); + bool include_audio = json_get_bool(payload, "include_audio", true); + bool ignore_precheck = json_get_bool(payload, "ignore_precheck", false); + + int64_t start = now_ms(); + uint32_t passed = 0; + uint32_t failed = 0; + + emit_progress(seq, name, "wifi", NULL); + esp_err_t rc = wait_wifi_ready(PRODUCT_TEST_WIFI_WAIT_MS); + rc == ESP_OK ? ++passed : ++failed; + + emit_progress(seq, name, "screen", NULL); + char err[160] = {0}; + rc = screen_preview_show_text("TQ Printer\nFull Flow Test", + PRODUCT_TEST_PREVIEW_TIMEOUT_MS, + err, + sizeof(err)); + (rc == ESP_OK || rc == ESP_ERR_NOT_SUPPORTED) ? ++passed : ++failed; + + if (include_audio) { + emit_progress(seq, name, "audio", NULL); + voice_audio_open_config_t cfg = { + .sample_rate = CONFIG_TQ_VOICE_SAMPLE_RATE, + .channels = 1, + .bits_per_sample = 16, + .output_volume = CONFIG_TQ_VOICE_CODEC_OUT_VOL, + .input_gain_db = (float)CONFIG_TQ_VOICE_CODEC_MIC_GAIN_DB, + }; + rc = voice_audio_open(&cfg, err, sizeof(err)); + if (rc == ESP_OK) { + int16_t pcm[PRODUCT_TEST_VOICE_PROBE_SAMPLES] = {0}; + rc = voice_audio_read_pcm(pcm, PRODUCT_TEST_VOICE_PROBE_SAMPLES, 2000, err, sizeof(err)); + } + voice_audio_close(); + rc == ESP_OK ? ++passed : ++failed; + } + + if (include_voice) { + emit_progress(seq, name, "voice", NULL); + rc = voice_interaction_start_with_timeout(PRODUCT_TEST_VOICE_START_TIMEOUT_MS, err, sizeof(err)); + if (rc == ESP_OK) { + rc = wait_voice_started(PRODUCT_TEST_VOICE_START_TIMEOUT_MS); + } + rc == ESP_OK ? ++passed : ++failed; + } + + if (include_print) { + emit_progress(seq, name, "print_pattern", NULL); + uint8_t *raster = NULL; + size_t raster_len = 0; + rc = build_print_pattern(PRODUCT_TEST_PRINT_PATTERN_HEIGHT, &raster, &raster_len); + uint32_t job_id = 0; + print_job_info_t job = {0}; + if (rc == ESP_OK) { + printer_print_options_t options = { + .ignore_precheck = ignore_precheck, + }; + rc = printer_protocol_submit_raster_job_ex(raster, + raster_len, + 384, + PRODUCT_TEST_PRINT_PATTERN_HEIGHT, + "test", + &options, + &job_id, + err, + sizeof(err)); + } + test_free(raster); + if (rc == ESP_OK) { + rc = wait_job_done(job_id, PRODUCT_TEST_JOB_DEFAULT_TIMEOUT_MS, &job); + } + rc == ESP_OK ? ++passed : ++failed; + } + + if (include_image) { + emit_progress(seq, name, "image_generate", NULL); + image_generation_request_t req = { + .prompt = json_get_str(payload, + "prompt", + "简单笑脸线稿,白色背景,适合热敏打印"), + .size = json_get_str(payload, "size", CONFIG_TQ_Z_IMAGE_DEFAULT_SIZE), + .prompt_extend = true, + .generation_timeout_ms = PRODUCT_TEST_IMAGE_DEFAULT_TIMEOUT_MS, + .download_timeout_ms = PRODUCT_TEST_IMAGE_DOWNLOAD_TIMEOUT_MS, + }; + image_generation_result_t result = {0}; + rc = image_generation_generate_png(&req, &result, err, sizeof(err)); + image_generation_result_free(&result); + rc == ESP_OK ? ++passed : ++failed; + } + + cJSON *data = cJSON_CreateObject(); + if (data != NULL) { + cJSON_AddNumberToObject(data, "passed", passed); + cJSON_AddNumberToObject(data, "failed", failed); + fill_status_payload(data); + } + emit_result(seq, + name, + failed == 0 ? "ok" : "fail", + failed == 0 ? ESP_OK : ESP_FAIL, + now_ms() - start, + data, + failed == 0 ? NULL : "full flow has failed steps"); +} + +typedef struct { + uint32_t seq; + const char *name; + cJSON *payload; +} command_context_t; + +static void dispatch_command(const command_context_t *cmd) { + if (cmd == NULL || cmd->name == NULL) { + return; + } + + if (strcmp(cmd->name, "PING") == 0) { + handle_ping(cmd->seq, cmd->name); + } else if (strcmp(cmd->name, "STATUS") == 0) { + handle_status(cmd->seq, cmd->name); + } else if (strcmp(cmd->name, "WAIT_WIFI") == 0) { + handle_wait_wifi(cmd->seq, cmd->name, cmd->payload); + } else if (strcmp(cmd->name, "SCREEN") == 0) { + handle_screen(cmd->seq, cmd->name, cmd->payload); + } else if (strcmp(cmd->name, "PRINTER_STATUS") == 0) { + handle_printer_status(cmd->seq, cmd->name); + } else if (strcmp(cmd->name, "GAP") == 0) { + handle_gap(cmd->seq, cmd->name, cmd->payload); + } else if (strcmp(cmd->name, "PRINT_PATTERN") == 0) { + handle_print_pattern(cmd->seq, cmd->name, cmd->payload); + } else if (strcmp(cmd->name, "IMAGE_GENERATE") == 0) { + handle_image_generate(cmd->seq, cmd->name, cmd->payload, false); + } else if (strcmp(cmd->name, "IMAGE_PRINT") == 0) { + handle_image_generate(cmd->seq, cmd->name, cmd->payload, true); + } else if (strcmp(cmd->name, "VOICE_SESSION") == 0) { + handle_voice_session(cmd->seq, cmd->name, cmd->payload); + } else if (strcmp(cmd->name, "VOICE_STOP") == 0) { + handle_voice_stop(cmd->seq, cmd->name, cmd->payload); + } else if (strcmp(cmd->name, "AUDIO_PROBE") == 0) { + handle_audio_probe(cmd->seq, cmd->name, cmd->payload); + } else if (strcmp(cmd->name, "NEGATIVE") == 0) { + handle_negative(cmd->seq, cmd->name, cmd->payload); + } else if (strcmp(cmd->name, "STABILITY") == 0) { + handle_stability(cmd->seq, cmd->name, cmd->payload); + } else if (strcmp(cmd->name, "FULL_FLOW") == 0) { + handle_full_flow(cmd->seq, cmd->name, cmd->payload); + } else if (strcmp(cmd->name, "STOP") == 0) { + s_stop_requested = true; + emit_result(cmd->seq, cmd->name, "ok", ESP_OK, 0, NULL, NULL); + } else { + emit_error(cmd->seq, cmd->name, ESP_ERR_NOT_SUPPORTED, "unsupported command: %s", cmd->name); + } +} + +static bool parse_command_line(char *line, command_context_t *out_cmd) { + if (line == NULL || out_cmd == NULL) { + return false; + } + memset(out_cmd, 0, sizeof(*out_cmd)); + + char *cursor = line; + if (strncmp(cursor, "TQTEST_CMD", strlen("TQTEST_CMD")) != 0) { + return false; + } + cursor += strlen("TQTEST_CMD"); + if (*cursor != '\0' && !isspace((unsigned char)*cursor)) { + return false; + } + while (*cursor != '\0' && isspace((unsigned char)*cursor)) { + cursor++; + } + if (*cursor == '\0') { + return false; + } + + cJSON *root = cJSON_Parse(cursor); + if (root == NULL) { + emit_error(0, "PARSE", ESP_ERR_INVALID_ARG, "invalid json command"); + return false; + } + + cJSON *seq = cJSON_GetObjectItemCaseSensitive(root, "seq"); + cJSON *cmd = cJSON_GetObjectItemCaseSensitive(root, "cmd"); + cJSON *payload = cJSON_GetObjectItemCaseSensitive(root, "payload"); + if (!cJSON_IsNumber(seq) || seq->valuedouble < 0 || + !cJSON_IsString(cmd) || cmd->valuestring == NULL) { + cJSON_Delete(root); + emit_error(0, "PARSE", ESP_ERR_INVALID_ARG, "command requires seq and cmd"); + return false; + } + + out_cmd->seq = (uint32_t)seq->valuedouble; + out_cmd->name = cmd->valuestring; + out_cmd->payload = cJSON_IsObject(payload) ? payload : NULL; + dispatch_command(out_cmd); + cJSON_Delete(root); + return true; +} + +static void product_test_task_main(void *arg) { + (void)arg; + + emit_ready(); + + char line[PRODUCT_TEST_LINE_MAX] = {0}; + size_t line_len = 0; + + while (!s_stop_requested) { + uint8_t ch = 0; + esp_err_t rc = platform_test_uart_read_byte(&ch, PRODUCT_TEST_UART_READ_TIMEOUT_MS); + if (rc == ESP_ERR_TIMEOUT) { + continue; + } + if (rc != ESP_OK) { + vTaskDelay(pdMS_TO_TICKS(50)); + continue; + } + + if (ch == '\r') { + continue; + } + if (ch == '\n') { + line[line_len] = '\0'; + if (line_len > 0) { + command_context_t cmd = {0}; + (void)parse_command_line(line, &cmd); + } + line_len = 0; + continue; + } + + if (line_len + 1 >= sizeof(line)) { + line_len = 0; + emit_error(0, "PARSE", ESP_ERR_INVALID_SIZE, "command line too long"); + continue; + } + line[line_len++] = (char)ch; + } + + s_task = NULL; + vTaskDelete(NULL); +} + +esp_err_t product_test_service_start(void) { +#if !CONFIG_TQ_PRODUCT_TEST_UART_ENABLE + return ESP_ERR_NOT_SUPPORTED; +#else + if (s_task != NULL) { + return ESP_OK; + } + + if (s_write_lock == NULL) { + s_write_lock = xSemaphoreCreateMutex(); + if (s_write_lock == NULL) { + return ESP_ERR_NO_MEM; + } + } + + platform_test_uart_config_t cfg = { + .port = CONFIG_TQ_PRODUCT_TEST_UART_PORT, + .baud_rate = CONFIG_TQ_PRODUCT_TEST_UART_BAUD, + .tx_pin = CONFIG_TQ_PRODUCT_TEST_UART_TX_PIN, + .rx_pin = CONFIG_TQ_PRODUCT_TEST_UART_RX_PIN, + .rx_buffer_size = PRODUCT_TEST_UART_RX_BUFFER, + .tx_buffer_size = PRODUCT_TEST_UART_TX_BUFFER, + }; + esp_err_t rc = platform_test_uart_init(&cfg); + if (rc != ESP_OK) { + return rc; + } + + s_stop_requested = false; + BaseType_t ok = xTaskCreate(product_test_task_main, + "product_test", + PRODUCT_TEST_TASK_STACK, + NULL, + PRODUCT_TEST_TASK_PRIO, + &s_task); + if (ok != pdPASS) { + platform_test_uart_deinit(); + s_task = NULL; + return ESP_ERR_NO_MEM; + } + + ESP_LOGI(TAG, "product test service started"); + return ESP_OK; +#endif +} + +esp_err_t product_test_service_stop(uint32_t timeout_ms) { + s_stop_requested = true; + if (timeout_ms == 0) { + timeout_ms = 1000; + } + + int64_t deadline = now_ms() + timeout_ms; + while (s_task != NULL && now_ms() < deadline) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + + if (s_task != NULL) { + return ESP_ERR_TIMEOUT; + } + platform_test_uart_deinit(); + return ESP_OK; +} diff --git a/components/platform/CMakeLists.txt b/components/platform/CMakeLists.txt index e29a689..11935bd 100644 --- a/components/platform/CMakeLists.txt +++ b/components/platform/CMakeLists.txt @@ -10,6 +10,7 @@ idf_component_register( "src/platform_mic_key.c" "src/platform_power_key.c" "src/voice_audio.c" + "src/product_test_uart.c" "src/runtime_policy.c" INCLUDE_DIRS "include" diff --git a/components/platform/include/platform.h b/components/platform/include/platform.h index fb3eb6c..42acae3 100644 --- a/components/platform/include/platform.h +++ b/components/platform/include/platform.h @@ -37,6 +37,11 @@ typedef struct { bool has_paper; int8_t paper_gpio_level; uint8_t paper_present_level; + bool battery_adc_ready; + bool ntc_adc_ready; + int battery_adc_raw; + int ntc_adc_raw; + uint16_t battery_mv; uint8_t battery_percent; float temperature_c; int64_t updated_ms; @@ -177,6 +182,21 @@ uint32_t runtime_policy_image_download_timeout_default_ms(void); uint32_t runtime_policy_image_download_timeout_min_ms(void); uint32_t runtime_policy_image_download_timeout_max_ms(void); +// ---------- product_test_uart ---------- +typedef struct { + int port; + int baud_rate; + int tx_pin; + int rx_pin; + size_t rx_buffer_size; + size_t tx_buffer_size; +} platform_test_uart_config_t; + +esp_err_t platform_test_uart_init(const platform_test_uart_config_t *cfg); +void platform_test_uart_deinit(void); +esp_err_t platform_test_uart_read_byte(uint8_t *out_byte, uint32_t timeout_ms); +esp_err_t platform_test_uart_write_line(const char *line, uint32_t timeout_ms); + #ifdef __cplusplus } #endif diff --git a/components/platform/src/product_test_uart.c b/components/platform/src/product_test_uart.c new file mode 100644 index 0000000..bf16849 --- /dev/null +++ b/components/platform/src/product_test_uart.c @@ -0,0 +1,207 @@ +#include "platform.h" + +#include +#include + +#include "driver/uart.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +static const char *TAG = "product_test_uart"; + +static SemaphoreHandle_t s_lock; +static SemaphoreHandle_t s_output_lock; +static uart_port_t s_uart_port = UART_NUM_MAX; +static bool s_ready; +static bool s_installed_by_us; + +static bool valid_uart_port(int port) { + return port >= 0 && port < UART_NUM_MAX; +} + +static esp_err_t ensure_output_lock(void) { + if (s_output_lock == NULL) { + s_output_lock = xSemaphoreCreateMutex(); + if (s_output_lock == NULL) { + return ESP_ERR_NO_MEM; + } + } + return ESP_OK; +} + +static bool output_lock_take(uint32_t timeout_ms) { + if (s_output_lock == NULL) { + return true; + } + return xSemaphoreTake(s_output_lock, pdMS_TO_TICKS(timeout_ms)) == pdTRUE; +} + +static void output_lock_give(void) { + if (s_output_lock != NULL) { + xSemaphoreGive(s_output_lock); + } +} + +esp_err_t platform_test_uart_init(const platform_test_uart_config_t *cfg) { + if (cfg == NULL || !valid_uart_port(cfg->port) || cfg->baud_rate <= 0) { + return ESP_ERR_INVALID_ARG; + } + + if (s_lock == NULL) { + s_lock = xSemaphoreCreateMutex(); + if (s_lock == NULL) { + return ESP_ERR_NO_MEM; + } + } + esp_err_t lock_rc = ensure_output_lock(); + if (lock_rc != ESP_OK) { + return lock_rc; + } + + if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + uart_port_t port = (uart_port_t)cfg->port; + if (s_ready) { + if (s_uart_port == port) { + xSemaphoreGive(s_lock); + return ESP_OK; + } + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_STATE; + } + + bool already_installed = uart_is_driver_installed(port); + if (!already_installed) { + int rx_buffer_size = (int)(cfg->rx_buffer_size > 0 ? cfg->rx_buffer_size : 2048); + int tx_buffer_size = (int)(cfg->tx_buffer_size > 0 ? cfg->tx_buffer_size : 1024); + esp_err_t install_rc = uart_driver_install(port, + rx_buffer_size, + tx_buffer_size, + 0, + NULL, + 0); + if (install_rc != ESP_OK) { + xSemaphoreGive(s_lock); + return install_rc; + } + s_installed_by_us = true; + } else { + s_installed_by_us = false; + } + + uart_config_t uart_cfg = { + .baud_rate = cfg->baud_rate, + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .rx_flow_ctrl_thresh = 0, + .source_clk = UART_SCLK_DEFAULT, + }; + + esp_err_t rc = uart_param_config(port, &uart_cfg); + if (rc == ESP_OK) { + rc = uart_set_pin(port, cfg->tx_pin, cfg->rx_pin, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); + } + if (rc == ESP_OK) { + rc = uart_flush_input(port); + } + if (rc != ESP_OK) { + if (s_installed_by_us && uart_is_driver_installed(port)) { + (void)uart_driver_delete(port); + } + s_installed_by_us = false; + xSemaphoreGive(s_lock); + return rc; + } + + s_uart_port = port; + s_ready = true; + ESP_LOGI(TAG, + "product test uart ready: port=%d baud=%d tx=%d rx=%d", + cfg->port, + cfg->baud_rate, + cfg->tx_pin, + cfg->rx_pin); + + xSemaphoreGive(s_lock); + return ESP_OK; +} + +void platform_test_uart_deinit(void) { + if (s_lock == NULL) { + return; + } + if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + return; + } + + if (s_ready && s_installed_by_us && s_uart_port != UART_NUM_MAX && uart_is_driver_installed(s_uart_port)) { + (void)uart_wait_tx_done(s_uart_port, pdMS_TO_TICKS(200)); + (void)uart_driver_delete(s_uart_port); + } + s_uart_port = UART_NUM_MAX; + s_ready = false; + s_installed_by_us = false; + + xSemaphoreGive(s_lock); +} + +esp_err_t platform_test_uart_read_byte(uint8_t *out_byte, uint32_t timeout_ms) { + if (out_byte == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (!s_ready || s_uart_port == UART_NUM_MAX) { + return ESP_ERR_INVALID_STATE; + } + + int n = uart_read_bytes(s_uart_port, out_byte, 1, pdMS_TO_TICKS(timeout_ms)); + if (n == 1) { + return ESP_OK; + } + if (n == 0) { + return ESP_ERR_TIMEOUT; + } + return ESP_FAIL; +} + +esp_err_t platform_test_uart_write_line(const char *line, uint32_t timeout_ms) { + if (line == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (!s_ready || s_uart_port == UART_NUM_MAX) { + return ESP_ERR_INVALID_STATE; + } + + size_t len = strlen(line); + if (len == 0) { + return ESP_ERR_INVALID_ARG; + } + + if (!output_lock_take(timeout_ms)) { + return ESP_ERR_TIMEOUT; + } + + flockfile(stdout); + int n = uart_write_bytes(s_uart_port, line, len); + if (n < 0 || (size_t)n != len) { + funlockfile(stdout); + output_lock_give(); + return ESP_FAIL; + } + + int newline = uart_write_bytes(s_uart_port, "\r\n", 2); + if (newline != 2) { + funlockfile(stdout); + output_lock_give(); + return ESP_FAIL; + } + + esp_err_t rc = uart_wait_tx_done(s_uart_port, pdMS_TO_TICKS(timeout_ms)); + funlockfile(stdout); + output_lock_give(); + return rc; +} diff --git a/components/platform/src/thermal_printer.c b/components/platform/src/thermal_printer.c index dbc4fc2..8dfbf53 100644 --- a/components/platform/src/thermal_printer.c +++ b/components/platform/src/thermal_printer.c @@ -148,6 +148,11 @@ static void set_stb_level_locked(int level) { set_gpio_level_if_valid(CONFIG_TQ_PRINT_STB_56_PIN, out_level); } +static void set_stb_pin_level_locked(int gpio_num, int level) { + int out_level = level ? strobe_active_level_locked() : strobe_idle_level_locked(); + set_gpio_level_if_valid(gpio_num, out_level); +} + static void motor_apply_pattern_locked(uint8_t pattern) { set_gpio_level_if_valid(CONFIG_TQ_PRINT_OUTA_P_PIN, (pattern >> 0) & 0x01); set_gpio_level_if_valid(CONFIG_TQ_PRINT_OUTA_N_PIN, (pattern >> 1) & 0x01); @@ -176,15 +181,21 @@ static void pulse_latch_locked(void) { set_gpio_level_if_valid(CONFIG_TQ_PRINT_LAT_PIN, 1); } -static void fire_strobe_locked(uint16_t on_us, uint16_t interval_us) { - set_stb_level_locked(1); +static void fire_strobe_pin_locked(int gpio_num, uint16_t on_us, uint16_t interval_us) { + set_stb_pin_level_locked(gpio_num, 1); esp_rom_delay_us(on_us); - set_stb_level_locked(0); + set_stb_pin_level_locked(gpio_num, 0); if (interval_us > 0) { esp_rom_delay_us(interval_us); } } +static void fire_strobe_locked(uint16_t on_us, uint16_t interval_us) { + fire_strobe_pin_locked(CONFIG_TQ_PRINT_STB_12_PIN, on_us, interval_us); + fire_strobe_pin_locked(CONFIG_TQ_PRINT_STB_34_PIN, on_us, interval_us); + fire_strobe_pin_locked(CONFIG_TQ_PRINT_STB_56_PIN, on_us, interval_us); +} + static void motor_step_once_locked(uint16_t step_delay_us) { uint8_t pattern = k_motor_step_table[s_state.motor_phase & 0x03]; motor_apply_pattern_locked(pattern); @@ -384,18 +395,26 @@ static void sample_sensors_locked(platform_printer_sensors_t *out_sensors) { out_sensors->has_paper = read_paper_present_locked(&paper_level); out_sensors->paper_gpio_level = (int8_t)paper_level; out_sensors->paper_present_level = runtime_policy_printer_paper_present_level(); + out_sensors->battery_adc_ready = s_state.battery_adc.ready; + out_sensors->ntc_adc_ready = s_state.ntc_adc.ready; + out_sensors->battery_adc_raw = -1; + out_sensors->ntc_adc_raw = -1; + out_sensors->battery_mv = 0; out_sensors->battery_percent = 100; out_sensors->temperature_c = 25.0f; out_sensors->updated_ms = esp_timer_get_time() / 1000; int battery_raw = 0; if (read_adc_raw_locked(&s_state.battery_adc, &battery_raw) == ESP_OK) { + out_sensors->battery_adc_raw = battery_raw; uint16_t batt_mv = battery_raw_to_mv(battery_raw); + out_sensors->battery_mv = batt_mv; out_sensors->battery_percent = battery_mv_to_percent(batt_mv); } int ntc_raw = 0; if (read_adc_raw_locked(&s_state.ntc_adc, &ntc_raw) == ESP_OK) { + out_sensors->ntc_adc_raw = ntc_raw; out_sensors->temperature_c = ntc_raw_to_temp_c(ntc_raw); } } diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 535389c..469f201 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -529,4 +529,36 @@ config TQ_VOICE_CODEC_MIC_GAIN_DB range 0 42 default 24 +menu "Product Test UART" + +config TQ_PRODUCT_TEST_UART_ENABLE + bool "Enable product test UART protocol" + default y + +config TQ_PRODUCT_TEST_UART_PORT + int "Product test UART port" + range 0 2 + default 0 + depends on TQ_PRODUCT_TEST_UART_ENABLE + +config TQ_PRODUCT_TEST_UART_BAUD + int "Product test UART baud rate" + range 9600 3000000 + default 115200 + depends on TQ_PRODUCT_TEST_UART_ENABLE + +config TQ_PRODUCT_TEST_UART_TX_PIN + int "Product test UART TX GPIO (-1 keeps current pin)" + range -1 48 + default -1 + depends on TQ_PRODUCT_TEST_UART_ENABLE + +config TQ_PRODUCT_TEST_UART_RX_PIN + int "Product test UART RX GPIO (-1 keeps current pin)" + range -1 48 + default -1 + depends on TQ_PRODUCT_TEST_UART_ENABLE + +endmenu + endmenu diff --git a/sdkconfig.defaults b/sdkconfig.defaults index dbe5ee9..304e7fc 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -103,6 +103,12 @@ CONFIG_TQ_SCREEN_SWAP_XY=y CONFIG_TQ_SCREEN_X_GAP=0 CONFIG_TQ_SCREEN_Y_GAP=0 CONFIG_TQ_SCREEN_RESET_PIN=-1 +CONFIG_TQ_PRODUCT_TEST_UART_ENABLE=y +CONFIG_TQ_PRODUCT_TEST_UART_PORT=0 +CONFIG_TQ_PRODUCT_TEST_UART_BAUD=115200 +CONFIG_TQ_PRODUCT_TEST_UART_TX_PIN=-1 +CONFIG_TQ_PRODUCT_TEST_UART_RX_PIN=-1 +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 # Security features (dev mode: Secure Boot v2 + keep UART ROM download enabled) CONFIG_SECURE_BOOT=y diff --git a/tools/product_test_uart.py b/tools/product_test_uart.py new file mode 100755 index 0000000..9bcbdcc --- /dev/null +++ b/tools/product_test_uart.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +"""UART host runner for the TQ printer product-test protocol.""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +try: + import serial + from serial.tools import list_ports +except ImportError as exc: # pragma: no cover - depends on operator env + raise SystemExit( + "缺少 pyserial,请先执行:python3 -m pip install pyserial" + ) from exc + + +TOKEN_READY = "TQTEST_READY" +TOKEN_PROGRESS = "TQTEST_PROGRESS" +TOKEN_RESULT = "TQTEST_RESULT" +TOKEN_ERROR = "TQTEST_ERROR" +TOKEN_CMD = "TQTEST_CMD" + + +@dataclass +class ProtocolFrame: + token: str + payload: Dict[str, Any] + raw: str + + +class ProtocolError(RuntimeError): + pass + + +class ProductTestClient: + def __init__( + self, + port: str, + baud: int, + timeout: float, + echo_logs: bool = False, + jsonl_path: Optional[Path] = None, + ) -> None: + self.port = port + self.baud = baud + self.timeout = timeout + self.echo_logs = echo_logs + self.jsonl_path = jsonl_path + self.seq = 1 + self.serial = serial.Serial( + port=port, + baudrate=baud, + timeout=0.2, + write_timeout=2.0, + ) + self.jsonl = jsonl_path.open("a", encoding="utf-8") if jsonl_path else None + self._last_probe = 0.0 + + def close(self) -> None: + if self.jsonl: + self.jsonl.close() + self.jsonl = None + if self.serial and self.serial.is_open: + self.serial.close() + + def __enter__(self) -> "ProductTestClient": + return self + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + self.close() + + def _log_jsonl(self, direction: str, item: Dict[str, Any]) -> None: + if not self.jsonl: + return + record = { + "ts": time.time(), + "direction": direction, + **item, + } + self.jsonl.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") + self.jsonl.flush() + + def _read_line(self, deadline: float) -> Optional[str]: + while time.monotonic() < deadline: + raw = self.serial.readline() + if not raw: + continue + text = raw.decode("utf-8", errors="replace").rstrip("\r\n") + if not text: + continue + self._log_jsonl("rx", {"raw": text}) + if self.echo_logs and not text.startswith("TQTEST_"): + print(f"[log] {text}") + return text + return None + + def hard_reset(self, delay_s: float) -> None: + self.serial.reset_input_buffer() + self.serial.dtr = False + self.serial.rts = True + time.sleep(0.1) + self.serial.rts = False + time.sleep(delay_s) + self.serial.reset_input_buffer() + self._last_probe = 0.0 + + @staticmethod + def parse_frame(line: str) -> Optional[ProtocolFrame]: + if not line.startswith("TQTEST_"): + return None + parts = line.split(" ", 1) + token = parts[0] + if token not in {TOKEN_READY, TOKEN_PROGRESS, TOKEN_RESULT, TOKEN_ERROR}: + return ProtocolFrame( + token=token, + payload={"protocol_error": "unknown_token"}, + raw=line, + ) + payload_text = parts[1] if len(parts) == 2 else "{}" + try: + payload = json.loads(payload_text) + except json.JSONDecodeError: + payload = {"parse_error": True, "raw_payload": payload_text} + if not isinstance(payload, dict): + payload = {"value": payload} + return ProtocolFrame(token=token, payload=payload, raw=line) + + @staticmethod + def _frame_parse_error(frame: ProtocolFrame, context: str) -> ProtocolError: + raw = frame.raw + if len(raw) > 240: + raw = raw[:237] + "..." + if frame.payload.get("protocol_error") == "unknown_token": + return ProtocolError( + f"{context} 收到未知协议帧: token={frame.token} raw={raw!r}" + ) + return ProtocolError(f"{context} 收到协议帧 JSON 损坏: token={frame.token} raw={raw!r}") + + def wait_ready(self, timeout_s: float) -> Dict[str, Any]: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + now = time.monotonic() + if now - self._last_probe >= 1.0: + self._last_probe = now + self.send_command("PING", {}) + line = self._read_line(deadline) + if line is None: + break + frame = self.parse_frame(line) + if not frame: + continue + if frame.payload.get("parse_error") or frame.payload.get("protocol_error"): + raise self._frame_parse_error(frame, "等待 READY") + if frame.token == TOKEN_READY: + return frame.payload + if frame.token == TOKEN_RESULT and frame.payload.get("name") == "PING": + return { + "version": frame.payload.get("data", {}).get("version"), + "device": "TQ_printer", + "probe": "PING", + } + raise ProtocolError(f"等待 {TOKEN_READY} 超时") + + def send_command(self, command: str, payload: Optional[Dict[str, Any]] = None) -> int: + seq = self.seq + self.seq += 1 + body = { + "seq": seq, + "cmd": command, + "payload": payload or {}, + } + line = TOKEN_CMD + " " + json.dumps(body, ensure_ascii=False, separators=(",", ":")) + self._log_jsonl("tx", {"raw": line, "seq": seq, "cmd": command}) + self.serial.write((line + "\n").encode("utf-8")) + self.serial.flush() + return seq + + def command( + self, + command: str, + payload: Optional[Dict[str, Any]] = None, + timeout_s: Optional[float] = None, + ) -> Dict[str, Any]: + seq = self.send_command(command, payload) + deadline = time.monotonic() + (timeout_s or self.timeout) + while time.monotonic() < deadline: + line = self._read_line(deadline) + if line is None: + break + frame = self.parse_frame(line) + if not frame: + continue + if frame.payload.get("parse_error") or frame.payload.get("protocol_error"): + raise self._frame_parse_error(frame, command) + frame_seq = int(frame.payload.get("seq", -1)) + if frame_seq != seq: + if frame.token == TOKEN_READY: + raise ProtocolError(f"{command} 执行期间设备重启,等待结果失败 seq={seq}") + continue + if frame.token == TOKEN_PROGRESS: + stage = frame.payload.get("stage", "-") + print(f" - {command}: {stage}") + continue + if frame.token == TOKEN_RESULT: + return frame.payload + if frame.token == TOKEN_ERROR: + raise ProtocolError(f"{command} 返回错误帧: {frame.payload}") + raise ProtocolError(f"{command} 等待结果超时 seq={seq}") + + +def list_serial_ports() -> None: + ports = list(list_ports.comports()) + if not ports: + print("未发现串口") + return + for item in ports: + desc = item.description or "-" + hwid = item.hwid or "-" + print(f"{item.device}\t{desc}\t{hwid}") + + +def default_steps(args: argparse.Namespace) -> List[Dict[str, Any]]: + steps: List[Dict[str, Any]] = [ + {"cmd": "PING", "timeout": 10}, + {"cmd": "STATUS", "timeout": 10}, + {"cmd": "WAIT_WIFI", "payload": {"timeout_ms": args.wifi_timeout_ms}, "timeout": args.wifi_timeout_ms / 1000 + 5}, + {"cmd": "SCREEN", "payload": {"text": "TQ Printer\nProduct Test\nScreen OK"}, "timeout": 15}, + {"cmd": "PRINTER_STATUS", "timeout": 10}, + {"cmd": "AUDIO_PROBE", "payload": {"samples": 320, "timeout_ms": 3000}, "timeout": 15}, + {"cmd": "VOICE_SESSION", "payload": {"timeout_ms": args.voice_timeout_ms}, "timeout": args.voice_timeout_ms / 1000 + 10}, + {"cmd": "NEGATIVE", "payload": {"case": "invalid_print"}, "timeout": 10}, + ] + + if args.print: + steps.append( + { + "cmd": "PRINT_PATTERN", + "payload": { + "height": args.print_height, + "wait_done": True, + "ignore_precheck": args.ignore_precheck, + "timeout_ms": args.print_timeout_ms, + }, + "timeout": args.print_timeout_ms / 1000 + 10, + } + ) + + if args.image: + image_cmd = "IMAGE_PRINT" if args.image_print else "IMAGE_GENERATE" + steps.append( + { + "cmd": image_cmd, + "payload": { + "prompt": args.prompt, + "size": args.image_size, + "prompt_extend": True, + "ignore_precheck": args.ignore_precheck, + "generation_timeout_ms": args.image_timeout_ms, + "download_timeout_ms": args.image_download_timeout_ms, + "print_timeout_ms": args.print_timeout_ms, + }, + "timeout": (args.image_timeout_ms + args.image_download_timeout_ms + args.print_timeout_ms) / 1000 + 20, + } + ) + + if args.stability_loops > 0: + steps.append( + { + "cmd": "STABILITY", + "payload": { + "loops": args.stability_loops, + "include_print": args.stability_print, + "include_audio": True, + }, + "timeout": max(30, args.stability_loops * (8 if args.stability_print else 3)), + } + ) + + steps.append({"cmd": "VOICE_STOP", "payload": {"timeout_ms": 3000}, "timeout": 10}) + steps.append({"cmd": "STATUS", "timeout": 10}) + return steps + + +def load_plan(path: Path) -> List[Dict[str, Any]]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, list): + raise SystemExit("测试计划必须是 JSON 数组") + steps: List[Dict[str, Any]] = [] + for idx, item in enumerate(data, start=1): + if not isinstance(item, dict) or "cmd" not in item: + raise SystemExit(f"测试计划第 {idx} 项缺少 cmd") + steps.append(item) + return steps + + +def _printer_status_from_result(result: Dict[str, Any]) -> Optional[Dict[str, Any]]: + data = result.get("data") + if not isinstance(data, dict): + return None + printer = data.get("printer") + if isinstance(printer, dict): + return printer + if "temperature_c" in data or "battery_percent" in data or "has_paper" in data: + return data + return None + + +def _printer_status_summary(result: Dict[str, Any]) -> str: + printer = _printer_status_from_result(result) + if not printer: + return "" + + temp = printer.get("temperature_c") + temp_min = printer.get("temperature_min_c") + temp_max = printer.get("temperature_max_c") + battery = printer.get("battery_percent") + has_paper = printer.get("has_paper") + ntc_raw = printer.get("ntc_adc_raw") + + parts: List[str] = [] + if has_paper is not None: + parts.append(f"paper={'ok' if has_paper else 'missing'}") + if battery is not None: + parts.append(f"batt={battery}%") + if temp is not None: + if temp_min is not None and temp_max is not None: + parts.append(f"temp={float(temp):.1f}C[{temp_min}-{temp_max}]") + else: + parts.append(f"temp={float(temp):.1f}C") + if ntc_raw is not None: + parts.append(f"ntc_raw={ntc_raw}") + return " printer={" + " ".join(parts) + "}" if parts else "" + + +def summarize_result(result: Dict[str, Any]) -> str: + status = result.get("status", "-") + rc = result.get("rc", "-") + duration = result.get("duration_ms", "-") + message = result.get("message", "") + suffix = f" msg={message}" if message else "" + return f"status={status} rc={rc} duration_ms={duration}{suffix}{_printer_status_summary(result)}" + + +def run_steps( + client: ProductTestClient, + steps: Iterable[Dict[str, Any]], + continue_on_fail: bool, +) -> int: + failures = 0 + for index, step in enumerate(steps, start=1): + cmd = str(step["cmd"]) + payload = step.get("payload") or {} + timeout = float(step.get("timeout", client.timeout)) + print(f"[{index}] {cmd}") + try: + result = client.command(cmd, payload=payload, timeout_s=timeout) + except ProtocolError as exc: + failures += 1 + print(f" FAIL {exc}") + if not continue_on_fail: + break + continue + + status = result.get("status") + ok = status == "ok" + print(f" {'OK' if ok else 'FAIL'} {summarize_result(result)}") + if not ok: + failures += 1 + if not continue_on_fail: + break + return failures + + +def parse_args(argv: List[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run TQ printer product tests over UART.") + parser.add_argument("-p", "--port", help="串口设备,例如 /dev/cu.usbserial-xxx") + parser.add_argument("-b", "--baud", type=int, default=115200, help="UART 波特率") + parser.add_argument("--list-ports", action="store_true", help="列出可用串口后退出") + parser.add_argument("--ready-timeout", type=float, default=30.0, help="等待 TQTEST_READY 的秒数") + parser.add_argument("--timeout", type=float, default=60.0, help="单条命令默认超时秒数") + parser.add_argument("--reset", action="store_true", help="等待协议就绪前通过串口控制线硬复位设备") + parser.add_argument("--reset-delay", type=float, default=2.0, help="硬复位后等待设备启动的秒数") + parser.add_argument("--jsonl", type=Path, help="保存收发帧 JSONL") + parser.add_argument("--echo-logs", action="store_true", help="打印非协议串口日志") + parser.add_argument("--continue-on-fail", action="store_true", help="失败后继续执行后续步骤") + parser.add_argument("--plan", type=Path, help="从 JSON 测试计划读取步骤") + + parser.add_argument("--print", action="store_true", help="执行实际打印测试") + parser.add_argument("--print-height", type=int, default=96, help="打印测试图案高度") + parser.add_argument("--print-timeout-ms", type=int, default=90000, help="打印完成等待超时") + parser.add_argument("--ignore-precheck", action="store_true", help="打印时跳过纸张/温度/电量预检") + + parser.add_argument("--image", action="store_true", help="执行云端图像生成测试") + parser.add_argument("--image-print", action="store_true", help="图像生成后继续打印") + parser.add_argument("--prompt", default="可爱的猫咪线稿,白色背景,边缘清晰,适合热敏打印", help="图像生成提示词") + parser.add_argument("--image-size", default="512*768", help="图像生成尺寸") + parser.add_argument("--image-timeout-ms", type=int, default=90000, help="图像生成超时") + parser.add_argument("--image-download-timeout-ms", type=int, default=30000, help="图像下载超时") + + parser.add_argument("--wifi-timeout-ms", type=int, default=20000, help="等待 Wi-Fi 就绪超时") + parser.add_argument("--voice-timeout-ms", type=int, default=12000, help="语音会话启动超时") + parser.add_argument("--stability-loops", type=int, default=0, help="稳定性循环次数,0 表示跳过") + parser.add_argument("--stability-print", action="store_true", help="稳定性循环包含走纸") + return parser.parse_args(argv) + + +def main(argv: List[str]) -> int: + args = parse_args(argv) + if args.list_ports: + list_serial_ports() + return 0 + if not args.port: + raise SystemExit("必须指定 --port,或使用 --list-ports 查看串口") + + steps = load_plan(args.plan) if args.plan else default_steps(args) + with ProductTestClient( + port=args.port, + baud=args.baud, + timeout=args.timeout, + echo_logs=args.echo_logs, + jsonl_path=args.jsonl, + ) as client: + if args.reset: + print(f"复位设备: {args.port}") + client.hard_reset(args.reset_delay) + print(f"等待设备协议就绪: {args.port} @ {args.baud}") + ready = client.wait_ready(args.ready_timeout) + print( + "READY " + + json.dumps(ready, ensure_ascii=False, separators=(",", ":")) + ) + failures = run_steps(client, steps, args.continue_on_fail) + + if failures: + print(f"测试完成,失败项: {failures}") + return 1 + print("测试完成,全部通过") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))