feat(product-test): add UART automation flow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
1283
components/domain/src/product_test_service.c
Normal file
1283
components/domain/src/product_test_service.c
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
207
components/platform/src/product_test_uart.c
Normal file
207
components/platform/src/product_test_uart.c
Normal file
@@ -0,0 +1,207 @@
|
||||
#include "platform.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
448
tools/product_test_uart.py
Executable file
448
tools/product_test_uart.py
Executable file
@@ -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:]))
|
||||
Reference in New Issue
Block a user