commit 8df2095acf89d1fdab3d3056558d3248d8898383 Author: admin Date: Sat Feb 28 16:41:12 2026 +0800 chore(repo): keep only current main snapshot History squashed to one root commit to permanently drop old branch history and objects. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eef899c --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +build/ + +# ESP-IDF local config artifacts +sdkconfig* +!sdkconfig.defaults + +# Local editor/OS/cache noise +.DS_Store +__pycache__/ +.pytest_cache/ + +managed_components +JX_2R_01测试程序_STM32F103RF \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..235fa80 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "idf.currentSetup": "/Users/moyyang/esp/v5.5.2/esp-idf", + "idf.flashType": "UART", + "idf.port": "/dev/tty.usbserial-110", + "idf.openOcdConfigs": [ + "board/esp32s3-builtin.cfg" + ], + "idf.customExtraVars": { + "IDF_TARGET": "esp32s3" + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..49817d8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# Repository Guidelines + +## Project Structure & Layering +- `components/platform/{include,internal,src}`: hardware/system abstraction only. +- `components/domain/{include,internal,src}`: business-domain services. +- `components/control_plane/{include,internal,src}`: lifecycle, policy, health, retry/backoff orchestration. +- `components/app_composition/{include,internal,src}`: composition boundary; `app_main` only wires modules. +- `dependency_whitelist.md`: approved exceptions for unavoidable lateral dependencies. +- `components/domain/third_party/`, `components/domain/assets/fonts/`, `tools/`: vendored code, embedded assets, utility scripts. +- `build/`: generated artifacts only (ignored). + +## Mandatory Architecture Constraints +1. Dependencies are one-way only: `app_composition -> control_plane -> domain -> platform`. +2. Lateral direct dependencies are forbidden unless listed in `dependency_whitelist.md`. +3. Cross-domain communication must use events or Port interfaces; never include another domain's `internal/*.h`. +4. Each component keeps exactly one public facade header in `include/`; private headers stay in `internal/` via `PRIV_INCLUDE_DIRS`. + - Long-term canonical facade headers in this repo: + - `platform/include/platform.h` + - `domain/include/domain.h` + - `control_plane/include/control_plane.h` + - `app_composition/include/app_composition.h` +5. Upper layers may include only immediate downstream public facade headers; leapfrog includes across multiple layers are forbidden. +6. Cross-cutting contracts must have a single declared owner layer and one public declaration point; duplicated declarations across layers are forbidden. +7. Runtime state must have a single source of truth (SSOT); orchestration layers should query/subscribe rather than duplicate lower-layer lifecycle or readiness state. +8. Blocking APIs must define timeout and idempotent semantics (no destructive background continuation after caller timeout). + +## Build, Flash, and Validation +Activate ESP-IDF v5.5.2 before running any `idf.py` command: +```bash +export PATH=/opt/homebrew/bin:$PATH +export IDF_PATH=/Users/moyyang/esp/v5.5.2/esp-idf +source $IDF_PATH/export.sh +``` +- `idf.py set-target esp32s3`: one-time target setup. +- `idf.py build`: compile and validate. +- `idf.py -p flash monitor`: flash and open serial monitor (`Ctrl+]` to exit). +- `idf.py menuconfig`: adjust project options. +- `idf.py fullclean && idf.py build`: clear stale artifacts. + +## Coding Style & Naming Conventions +- Use 4-space indentation and keep braces/function style consistent with existing `components/*/*.c`. +- Prefer `lower_snake_case` for functions and variables; use module-prefixed public APIs. +- Use `UPPER_SNAKE_CASE` for macros/constants (`CMD_SEND_DATA`, `WIFI_CONNECTED_BIT`). +- Prefix file-local statics with `s_` (for example `s_server`, `s_jobs`) and keep per-file `TAG` logging constants. + +## Testing Guidelines +There is no dedicated unit-test directory currently. Minimum validation: +- Build check: `idf.py build`. +- Device smoke test after flash: `/v1/health`, printer connect/disconnect, and one print flow (text or image) via `curl`. +- For Control Plane or REST changes, include one request/response or lifecycle scenario in PR notes. + +## Commit & Pull Request Guidelines +Use a clear conventional format: +- Commit style: `feat(rest): add label offset endpoint`, `fix(wifi): handle STA timeout`. +- Keep commits focused and atomic by layer/module (`platform`, `domain`, `control_plane`, `app_composition`). +- PRs include purpose, affected layer(s), whitelist changes (if any), config changes, and validation evidence. + +## Security & Configuration Tips +- Never hardcode credentials or API keys in source files; configure through `menuconfig`. +- Do not commit local `sdkconfig` variants, serial-port-specific commands, or generated `build/` artifacts. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..1497b6f --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,3 @@ +cmake_minimum_required(VERSION 3.16) +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(TQ_printer_controller) diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a4b0b7 --- /dev/null +++ b/README.md @@ -0,0 +1,374 @@ +# ESP32-S3 TQ Printer Controller (Wi-Fi REST API) + +ESP32-S3 works as a Wi-Fi REST controller and replaces Android App logic: +- REST client -> ESP32-S3 (`esp_http_server`) +- ESP32-S3 -> BLE printer (`TQPrinter` / `lyfPrinter`, `FFF2` write / `FFF1` notify) +- Command compatibility: + - Print path: `0x00~0x07` + - OTA path: `0xA0~0xA4` + +## Features + +- Wi-Fi STA-only mode (configured SSID/password; no SoftAP fallback) +- Built-in web UI at `/` for connect/status, text print, and Z-Image prompt print +- BLE central client auto-scan/connect to printer name +- Async print queue with jobs (`queued/running/success/failed/canceled`) +- Printer precheck (paper / battery / temperature) +- Built-in UTF-8 text rendering with Chinese support (GB2312 character set, 16x16 bitmap) +- Tap2talk multimodal voice interaction over DashScope WebSocket: + - ES8311 microphone/speaker via `esp_codec_dev` + - raw-opus uplink/downlink via `esp_audio_codec` + - WebSocket transport via `esp_websocket_client` +- Android-compatible print flow: + - `0x00` power on + - `0x03` set print param + - `0x04` chunked raster send + ACK + - `0x00` power off + - `0x02` feed paper +- REST APIs: + - Health/connection: + - `GET /v1/health` + - `POST /v1/printer/connect` + - `POST /v1/printer/disconnect` + - `GET /v1/printer/status` + - Print: + - `POST /v1/print/raster` + - `POST /v1/print/image` (JSON prompt -> generate image -> print, also compatible with direct PNG binary upload) + - `POST /v1/print/qr` + - `POST /v1/print/text` + - `POST /v1/print/receipt` + - `POST /v1/print/label` + - Jobs: + - `GET /v1/jobs` + - `DELETE /v1/jobs` + - `GET /v1/jobs/{id}` + - `DELETE /v1/jobs/{id}` + - Label control: + - `POST /v1/label/gap_move` + - `GET /v1/label/offset` + - `POST /v1/label/offset` + - OTA: + - `GET /v1/ota/version` + - `POST /v1/ota/jump_boot` + - `POST /v1/ota/jump_app` + - `POST /v1/ota/erase_page` + - `POST /v1/ota/write_frame` + - `POST /v1/ota/upgrade` + - Voice: + - `GET /v1/voice/status` + - `POST /v1/voice/session/start` + - `POST /v1/voice/session/stop` + - `POST /v1/voice/tap/start` + - `POST /v1/voice/tap/cancel` + +## Build + +Prerequisites: +- ESP-IDF v5.x +- ESP32-S3 board + +```bash +cd ai_printer +idf.py set-target esp32s3 +idf.py menuconfig +idf.py build +``` + +## Config + +In `menuconfig -> TQ Controller Config`: +- `TQ_WIFI_SSID` +- `TQ_WIFI_PASSWORD` +- `TQ_HTTP_PORT` +- `TQ_API_KEY` (optional) +- Z-Image HTTP auth and model fields: + - `TQ_Z_IMAGE_API_KEY` (optional, empty means fallback to `TQ_VOICE_API_KEY`) + - `TQ_Z_IMAGE_API_ENDPOINT` + - `TQ_Z_IMAGE_MODEL` + - `TQ_Z_IMAGE_DEFAULT_SIZE` + - `TQ_Z_IMAGE_TIMEOUT_MS` + - `TQ_Z_IMAGE_DOWNLOAD_TIMEOUT_MS` +- Voice WebSocket auth and app fields: + - `TQ_VOICE_API_KEY` + - `TQ_VOICE_WORKSPACE_ID` + - `TQ_VOICE_APP_ID` +- Voice audio and codec fields: + - `TQ_VOICE_SAMPLE_RATE` + - `TQ_VOICE_OPUS_FRAME_MS` + - `TQ_VOICE_OPUS_BITRATE_KBPS` + - `TQ_VOICE_I2C_*` / `TQ_VOICE_I2S_*` + - `TQ_VOICE_CODEC_*` +- Board GPIO map fields: + - `TQ_POWER_*`, `TQ_LED_*`, `TQ_SCREEN_*`, `TQ_PRINT_*`, `TQ_SPI_*` + - `TQ_KEY_PRINT_BOOST_ENABLE_ON_BOOT` / `TQ_KEY_PRINT_BOOST_ACTIVE_HIGH` +- ST7789 screen fields: + - `TQ_SCREEN_ENABLE` + - `TQ_SCREEN_TEST_PATTERN_ON_BOOT` + - `TQ_SCREEN_PIXEL_CLOCK_HZ` + - `TQ_SCREEN_SPI_MODE` + - `TQ_SCREEN_H_RES` / `TQ_SCREEN_V_RES` + - `TQ_SCREEN_DRAW_LINES` + - `TQ_SCREEN_COLOR_ORDER_BGR` + - `TQ_SCREEN_BACKLIGHT_ACTIVE_HIGH` + - `TQ_SCREEN_MIRROR_*` / `TQ_SCREEN_SWAP_XY` + - `TQ_SCREEN_X_GAP` / `TQ_SCREEN_Y_GAP` + - `TQ_SCREEN_RESET_PIN` + +Board pin assignment reference: +- [`docs/gpio-map.md`](docs/gpio-map.md) + +When `TQ_API_KEY` is not empty, each request must include: + +```text +X-API-Key: +``` + +STA-only behavior: +- If SSID is empty, startup fails. +- If STA connect fails, startup fails (no AP fallback). + +## Flash + +```bash +cd ai_printer +idf.py -p /dev/tty.usbmodemXXXX flash monitor +``` + +## Web UI + +Open the controller IP in a browser: + +```text +http:/// +``` + +The page provides: +- health/status check +- connect/disconnect printer +- submit a simple text print job +- input an image prompt, then let ESP32-S3 call DashScope Z-Image, download PNG, decode/threshold/scale/raster conversion, and print +- view jobs list + +If API key is enabled, input it in the page before invoking actions. + +## REST Examples + +### Health +```bash +curl http:///v1/health +``` + +### Connect printer +```bash +curl -X POST http:///v1/printer/connect \ + -H 'Content-Type: application/json' \ + -d '{"name":"TQPrinter","timeout_ms":15000}' +# Legacy device name is also supported: +# -d '{"name":"lyfPrinter","timeout_ms":15000}' +``` + +Auto-match a compatible printer (by BLE service `0xFFF0`): + +```bash +curl -X POST http:///v1/printer/connect \ + -H 'Content-Type: application/json' \ + -d '{"name":"*","timeout_ms":20000}' +``` + +### Raster print +```bash +curl -X POST http:///v1/print/raster \ + -H 'Content-Type: application/json' \ + -d '{ + "width":384, + "height":200, + "density":"中等", + "encoding":"base64_msb_1bpp", + "data":"" + }' +``` + +### Image print (generate with prompt, then print) +```bash +curl -X POST http:///v1/print/image \ + -H 'Content-Type: application/json' \ + -d '{ + "prompt":"一只坐在窗边的橘猫,午后阳光,胶片质感,写实风格。", + "size":"1120*1440", + "prompt_extend":false, + "threshold":160, + "max_height":2200, + "scale_to_width":true, + "invert":false, + "density":"中等", + "timeout_ms":45000, + "fetch_timeout_ms":15000 + }' +``` + +### Image print (direct PNG upload, backward-compatible) +```bash +curl -X POST 'http:///v1/print/image?scale_to_width=1&threshold=160&invert=0&max_height=2200&density=medium' \ + -H 'Content-Type: image/png' \ + --data-binary @./sample.png +``` + +Request constraints: +- Request body limit: 4 MB +- Suggested image size: <= 3 MB when using direct PNG upload compatibility mode + +### QR print +```bash +curl -X POST http:///v1/print/qr \ + -H 'Content-Type: application/json' \ + -d '{ + "text":"https://example.com/pay/123", + "ecc":"H", + "module_scale":0, + "margin_modules":2, + "density":"中等" + }' +``` + +### Text print +```bash +curl -X POST http:///v1/print/text \ + -H 'Content-Type: application/json' \ + -d '{ + "text":"欢迎使用TQ打印机\\n订单号: A1024\\n谢谢惠顾", + "density":"中等", + "scale":2, + "line_spacing":2, + "max_height":1800 + }' +``` + +### Receipt print +```bash +curl -X POST http:///v1/print/receipt \ + -H 'Content-Type: application/json' \ + -d '{ + "title":"TQ FOOD", + "density":"中等", + "footer":"Thanks!", + "items":[ + {"name":"DishA","qty":2,"price":12.5}, + {"name":"DishB","qty":1,"price":8.0} + ] + }' +``` + +### Label print (with optional gap/offset) +```bash +curl -X POST http:///v1/print/label \ + -H 'Content-Type: application/json' \ + -d '{ + "width":384, + "height":260, + "encoding":"base64_msb_1bpp", + "data":"", + "gap_move_before":true, + "offset_tenths_mm":128, + "density":"中等" + }' +``` + +### Jobs +```bash +curl http:///v1/jobs +curl http:///v1/jobs/1 +curl -X DELETE http:///v1/jobs/1 +curl -X DELETE http:///v1/jobs -d '{"include_success":true,"include_failed":true,"include_canceled":true}' +``` + +### Label control +```bash +curl -X POST http:///v1/label/gap_move +curl http:///v1/label/offset +curl -X POST http:///v1/label/offset \ + -H 'Content-Type: application/json' \ + -d '{"offset_tenths_mm":128}' +``` + +### OTA primitives +```bash +curl http:///v1/ota/version +curl -X POST http:///v1/ota/jump_boot +curl -X POST http:///v1/ota/erase_page -H 'Content-Type: application/json' -d '{"page_num":0}' +curl -X POST http:///v1/ota/write_frame -H 'Content-Type: application/json' -d '{"packet_num":0,"is_last_frame":false,"data":""}' +curl -X POST http:///v1/ota/jump_app +``` + +### OTA full upgrade +```bash +curl -X POST http:///v1/ota/upgrade \ + -H 'Content-Type: application/json' \ + -d '{ + "firmware":"", + "jump_boot":true, + "jump_app":true, + "page_size":1024, + "packet_size":236, + "timeout_ms_per_step":5000 + }' +``` + +### Voice tap2talk +Start session: + +```bash +curl -X POST http:///v1/voice/session/start +``` + +Start one tap2talk round: + +```bash +curl -X POST http:///v1/voice/tap/start +``` + +Cancel current tap2talk round: + +```bash +curl -X POST http:///v1/voice/tap/cancel +``` + +Get voice status: + +```bash +curl http:///v1/voice/status +``` + +Stop session: + +```bash +curl -X POST http:///v1/voice/session/stop +``` + +## Notes + +- BLE side is central/client role, not printer peripheral role. +- `base64_msb_1bpp` uses Android-compatible bit order (MSB first). +- `/v1/print/image` supports two modes: JSON prompt generation (DashScope Z-Image) and raw `image/png` upload (no base64 wrapper). +- Large buffers for image upload/decode prefer PSRAM first, then fallback to internal RAM. +- Partition table uses `partitions.csv` with a 4MB `factory` app partition on 16MB flash modules. +- `/v1/print/text` and `/v1/print/receipt` now support UTF-8 Chinese via embedded 16x16 GB2312 glyphs. +- Characters outside embedded glyph set are rendered as square fallback boxes. +- QR encoding uses embedded `qrcodegen` (Project Nayuki C implementation). +- Voice flow is now single-round: audio uplink stops after `SpeechEnded`, then `LocalRespondingEnded` and `Stop` are sent after local playback drain. +- Web UI blocks starting the next voice round while voice is Thinking/Responding, marker image generation is running, or printer queue is still busy. + +## Font Assets + +Embedded files: +- `components/domain/assets/fonts/cn16_index.bin` +- `components/domain/assets/fonts/cn16_glyphs.bin` + +Regenerate from your own CJK font: + +```bash +cd ai_printer +python3 -m pip install --user pillow +python3 tools/gen_cn16_font.py \ + --font app/src/main/assets/fonts/msyh.ttc \ + --font-index 0 +``` diff --git a/components/app_composition/CMakeLists.txt b/components/app_composition/CMakeLists.txt new file mode 100644 index 0000000..88257ec --- /dev/null +++ b/components/app_composition/CMakeLists.txt @@ -0,0 +1,10 @@ +idf_component_register( + SRCS + "src/app_main.c" + INCLUDE_DIRS + "include" + PRIV_INCLUDE_DIRS + "internal" + REQUIRES + control_plane +) diff --git a/components/app_composition/README.md b/components/app_composition/README.md new file mode 100644 index 0000000..13f019e --- /dev/null +++ b/components/app_composition/README.md @@ -0,0 +1,8 @@ +# App Composition Layer + +Purpose: top-level assembly only. + +Rules: +- `app_main` wires components, startup order, and shutdown hooks. +- No feature/business logic in composition code. +- No direct hardware operations outside platform abstractions. diff --git a/components/app_composition/include/.gitkeep b/components/app_composition/include/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/app_composition/include/app_composition.h b/components/app_composition/include/app_composition.h new file mode 100644 index 0000000..a278028 --- /dev/null +++ b/components/app_composition/include/app_composition.h @@ -0,0 +1,13 @@ +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t app_composition_start(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/app_composition/internal/.gitkeep b/components/app_composition/internal/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/app_composition/src/.gitkeep b/components/app_composition/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/app_composition/src/app_main.c b/components/app_composition/src/app_main.c new file mode 100644 index 0000000..9ecdbd5 --- /dev/null +++ b/components/app_composition/src/app_main.c @@ -0,0 +1,21 @@ +#include "app_composition.h" +#include "control_plane.h" + +#include "esp_err.h" +#include "esp_log.h" + +static const char *TAG = "app_main"; + +esp_err_t app_composition_start(void) { + return controller_lifecycle_start(); +} + +void app_main(void) { + esp_err_t rc = app_composition_start(); + if (rc != ESP_OK) { + ESP_LOGE(TAG, "Controller lifecycle start failed: %s", esp_err_to_name(rc)); + return; + } + + ESP_LOGI(TAG, "TQ controller started"); +} diff --git a/components/control_plane/CMakeLists.txt b/components/control_plane/CMakeLists.txt new file mode 100644 index 0000000..5b35a3f --- /dev/null +++ b/components/control_plane/CMakeLists.txt @@ -0,0 +1,21 @@ +idf_component_register( + SRCS + "src/controller_lifecycle.c" + "src/rest_server.c" + "src/rest_server_common.c" + "src/rest_server_ops.c" + "src/rest_server_print.c" + "src/rest_server_print_image.c" + "src/rest_server_print_render.c" + "src/rest_server_jobs.c" + "src/rest_server_voice.c" + INCLUDE_DIRS + "include" + PRIV_INCLUDE_DIRS + "internal" + REQUIRES + domain + esp_http_server + json + mbedtls +) diff --git a/components/control_plane/README.md b/components/control_plane/README.md new file mode 100644 index 0000000..00acd56 --- /dev/null +++ b/components/control_plane/README.md @@ -0,0 +1,8 @@ +# Control Plane Layer + +Purpose: lifecycle, policy, health checks, retry/backoff, and orchestration decisions. + +Rules: +- Contains "decision + orchestration" only. +- Calls domain APIs; does not absorb hardware details. +- Maintains dependency whitelist decisions for exceptional lateral coupling. diff --git a/components/control_plane/include/.gitkeep b/components/control_plane/include/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/control_plane/include/control_plane.h b/components/control_plane/include/control_plane.h new file mode 100644 index 0000000..c3e7c64 --- /dev/null +++ b/components/control_plane/include/control_plane.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + CONTROLLER_LIFECYCLE_STATE_STOPPED = 0, + CONTROLLER_LIFECYCLE_STATE_STARTING, + CONTROLLER_LIFECYCLE_STATE_RUNNING, + CONTROLLER_LIFECYCLE_STATE_DEGRADED, + CONTROLLER_LIFECYCLE_STATE_STOPPING, +} controller_lifecycle_state_t; + +typedef struct { + controller_lifecycle_state_t state; + esp_err_t last_error; + uint32_t start_attempt; + int64_t last_transition_ms; + char last_stage[32]; +} controller_lifecycle_status_t; + +esp_err_t controller_lifecycle_start(void); +esp_err_t controller_lifecycle_stop(void); +void controller_lifecycle_get_status(controller_lifecycle_status_t *out_status); +const char *controller_lifecycle_state_str(controller_lifecycle_state_t state); + +esp_err_t rest_server_start(void); +void rest_server_stop(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/control_plane/internal/.gitkeep b/components/control_plane/internal/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/control_plane/internal/rest_server_internal.h b/components/control_plane/internal/rest_server_internal.h new file mode 100644 index 0000000..be010f9 --- /dev/null +++ b/components/control_plane/internal/rest_server_internal.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include + +#include "cJSON.h" +#include "esp_err.h" +#include "esp_http_server.h" + +bool rest_server_auth_ok(httpd_req_t *req); +esp_err_t rest_server_send_json(httpd_req_t *req, const char *status, cJSON *root); +esp_err_t rest_server_send_error(httpd_req_t *req, const char *status, const char *message); +esp_err_t rest_server_read_body(httpd_req_t *req, char **out_body); +bool rest_server_parse_uri_u32_tail(const char *uri, uint32_t *out_value); +esp_err_t rest_server_base64_decode_alloc(const char *b64, uint8_t **out_raw, size_t *out_len); +void rest_server_appendf(char *dst, size_t cap, size_t *offset, const char *fmt, ...); +bool rest_server_json_bool_with_default(cJSON *item, bool default_value); +esp_err_t rest_server_print_submit_raster_job_and_reply(httpd_req_t *req, + const uint8_t *raw, + size_t raw_len, + uint16_t width, + uint16_t height, + const char *density, + bool direct_ignore_precheck, + const char *warning, + const char *trace_id); + +esp_err_t rest_server_health_get(httpd_req_t *req); +esp_err_t rest_server_connect_post(httpd_req_t *req); +esp_err_t rest_server_disconnect_post(httpd_req_t *req); +esp_err_t rest_server_status_get(httpd_req_t *req); +esp_err_t rest_server_printer_direct_debug_get(httpd_req_t *req); +esp_err_t rest_server_printer_direct_debug_post(httpd_req_t *req); + +esp_err_t rest_server_print_raster_post(httpd_req_t *req); +esp_err_t rest_server_print_image_post(httpd_req_t *req); +esp_err_t rest_server_print_qr_post(httpd_req_t *req); +esp_err_t rest_server_print_label_post(httpd_req_t *req); +esp_err_t rest_server_print_text_post(httpd_req_t *req); +esp_err_t rest_server_print_receipt_post(httpd_req_t *req); + +esp_err_t rest_server_job_get(httpd_req_t *req); +esp_err_t rest_server_jobs_get(httpd_req_t *req); +esp_err_t rest_server_job_delete(httpd_req_t *req); +esp_err_t rest_server_jobs_delete(httpd_req_t *req); + +esp_err_t rest_server_label_gap_move_post(httpd_req_t *req); +esp_err_t rest_server_label_offset_get(httpd_req_t *req); +esp_err_t rest_server_label_offset_post(httpd_req_t *req); + +esp_err_t rest_server_ota_version_get(httpd_req_t *req); +esp_err_t rest_server_ota_jump_boot_post(httpd_req_t *req); +esp_err_t rest_server_ota_jump_app_post(httpd_req_t *req); +esp_err_t rest_server_ota_erase_page_post(httpd_req_t *req); +esp_err_t rest_server_ota_write_frame_post(httpd_req_t *req); +esp_err_t rest_server_ota_upgrade_post(httpd_req_t *req); + +esp_err_t rest_server_voice_status_get(httpd_req_t *req); +esp_err_t rest_server_voice_session_start_post(httpd_req_t *req); +esp_err_t rest_server_voice_session_stop_post(httpd_req_t *req); +esp_err_t rest_server_voice_tap_start_post(httpd_req_t *req); +esp_err_t rest_server_voice_tap_cancel_post(httpd_req_t *req); diff --git a/components/control_plane/src/.gitkeep b/components/control_plane/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/control_plane/src/controller_lifecycle.c b/components/control_plane/src/controller_lifecycle.c new file mode 100644 index 0000000..25bf147 --- /dev/null +++ b/components/control_plane/src/controller_lifecycle.c @@ -0,0 +1,310 @@ +#include "control_plane.h" + +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "domain.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +static const char *TAG = "controller_lifecycle"; +static SemaphoreHandle_t s_lock; +static controller_lifecycle_status_t s_status = { + .state = CONTROLLER_LIFECYCLE_STATE_STOPPED, + .last_error = ESP_OK, + .start_attempt = 0, + .last_transition_ms = 0, + .last_stage = "stopped", +}; + +static void lifecycle_lock_init_if_needed(void) { + if (s_lock == NULL) { + s_lock = xSemaphoreCreateMutex(); + } +} + +static bool lifecycle_lock_take(uint32_t timeout_ms) { + lifecycle_lock_init_if_needed(); + return s_lock != NULL && xSemaphoreTake(s_lock, pdMS_TO_TICKS(timeout_ms)) == pdTRUE; +} + +static void lifecycle_set_state_locked(controller_lifecycle_state_t state, + esp_err_t last_error, + const char *stage) { + s_status.state = state; + s_status.last_error = last_error; + s_status.last_transition_ms = esp_timer_get_time() / 1000; + strlcpy(s_status.last_stage, stage != NULL ? stage : "unknown", sizeof(s_status.last_stage)); + domain_diag_set_gauge(DOMAIN_DIAG_GAUGE_LIFECYCLE_STATE, (int32_t)state); +} + +const char *controller_lifecycle_state_str(controller_lifecycle_state_t state) { + switch (state) { + case CONTROLLER_LIFECYCLE_STATE_STOPPED: + return "stopped"; + case CONTROLLER_LIFECYCLE_STATE_STARTING: + return "starting"; + case CONTROLLER_LIFECYCLE_STATE_RUNNING: + return "running"; + case CONTROLLER_LIFECYCLE_STATE_DEGRADED: + return "degraded"; + case CONTROLLER_LIFECYCLE_STATE_STOPPING: + return "stopping"; + default: + return "unknown"; + } +} + +static esp_err_t lifecycle_start_system_runtime(void) { + return system_runtime_start(); +} + +static esp_err_t lifecycle_start_printer_protocol(void) { + return printer_protocol_init(); +} + +static esp_err_t lifecycle_start_voice(void) { + return voice_interaction_init(); +} + +static esp_err_t lifecycle_start_rest_server(void) { + return rest_server_start(); +} + +static esp_err_t lifecycle_stop_rest_server(void) { + rest_server_stop(); + return ESP_OK; +} + +static esp_err_t lifecycle_stop_voice(void) { + esp_err_t rc = voice_interaction_stop(NULL, 0); + if (rc == ESP_OK || 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) { + return ESP_OK; + } + return rc; +} + +static esp_err_t lifecycle_stop_system_runtime(void) { + return system_runtime_shutdown(); +} + +typedef esp_err_t (*lifecycle_step_fn_t)(void); + +static esp_err_t lifecycle_run_step_with_retry(const char *stage, lifecycle_step_fn_t fn) { + uint32_t retry_count = domain_policy_lifecycle_start_retry_count(); + uint32_t max_attempts = retry_count + 1; + + for (uint32_t attempt = 0; attempt < max_attempts; ++attempt) { + esp_err_t rc = fn(); + if (rc == ESP_OK) { + if (attempt > 0) { + ESP_LOGW(TAG, "stage %s recovered after retries, attempt=%u", stage, (unsigned)(attempt + 1)); + } + return ESP_OK; + } + + bool can_retry = (attempt + 1 < max_attempts) && domain_policy_is_retryable_error(rc); + ESP_LOGW(TAG, + "stage %s failed, rc=0x%x, attempt=%u/%u, retry=%d", + stage, + (unsigned)rc, + (unsigned)(attempt + 1), + (unsigned)max_attempts, + can_retry ? 1 : 0); + + if (!can_retry) { + return rc; + } + + domain_diag_counter_add(DOMAIN_DIAG_COUNTER_LIFECYCLE_START_RETRY, 1); + vTaskDelay(pdMS_TO_TICKS(domain_policy_lifecycle_retry_backoff_ms(attempt))); + } + + return ESP_FAIL; +} + +esp_err_t controller_lifecycle_start(void) { + domain_diag_counter_add(DOMAIN_DIAG_COUNTER_LIFECYCLE_START_ATTEMPT, 1); + + if (!lifecycle_lock_take(1000)) { + domain_diag_counter_add(DOMAIN_DIAG_COUNTER_LIFECYCLE_START_FAILED, 1); + domain_diag_record_error("lifecycle_start", ESP_ERR_TIMEOUT, "lifecycle lock timeout"); + return ESP_ERR_TIMEOUT; + } + + if (s_status.state == CONTROLLER_LIFECYCLE_STATE_RUNNING) { + xSemaphoreGive(s_lock); + return ESP_OK; + } + if (s_status.state == CONTROLLER_LIFECYCLE_STATE_STARTING || + s_status.state == CONTROLLER_LIFECYCLE_STATE_STOPPING) { + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_STATE; + } + + s_status.start_attempt++; + lifecycle_set_state_locked(CONTROLLER_LIFECYCLE_STATE_STARTING, ESP_OK, "bootstrap"); + xSemaphoreGive(s_lock); + + bool system_started = false; + bool printer_started = false; + bool voice_started = false; + bool rest_started = false; + const char *failed_stage = "unknown"; + + failed_stage = "system_runtime"; + esp_err_t rc = lifecycle_run_step_with_retry("system_runtime", lifecycle_start_system_runtime); + if (rc != ESP_OK) { + goto start_failed; + } + system_started = true; + + failed_stage = "printer_protocol"; + rc = lifecycle_run_step_with_retry("printer_protocol", lifecycle_start_printer_protocol); + if (rc != ESP_OK) { + goto start_failed; + } + printer_started = true; + + failed_stage = "voice_interaction"; + rc = lifecycle_run_step_with_retry("voice_interaction", lifecycle_start_voice); + if (rc != ESP_OK) { + goto start_failed; + } + voice_started = true; + + failed_stage = "rest_server"; + rc = lifecycle_run_step_with_retry("rest_server", lifecycle_start_rest_server); + if (rc != ESP_OK) { + goto start_failed; + } + rest_started = true; + + image_generation_schedule_prewarm(); + + if (lifecycle_lock_take(1000)) { + lifecycle_set_state_locked(CONTROLLER_LIFECYCLE_STATE_RUNNING, ESP_OK, "running"); + xSemaphoreGive(s_lock); + } + + domain_diag_counter_add(DOMAIN_DIAG_COUNTER_LIFECYCLE_START_SUCCESS, 1); + ESP_LOGI(TAG, "controller lifecycle started"); + return ESP_OK; + +start_failed: + if (rest_started) { + (void)lifecycle_stop_rest_server(); + } + if (voice_started) { + (void)lifecycle_stop_voice(); + } + if (printer_started) { + (void)lifecycle_stop_printer_protocol(); + } + if (system_started) { + (void)lifecycle_stop_system_runtime(); + } + + if (lifecycle_lock_take(1000)) { + lifecycle_set_state_locked(CONTROLLER_LIFECYCLE_STATE_DEGRADED, rc, failed_stage); + xSemaphoreGive(s_lock); + } + + domain_diag_counter_add(DOMAIN_DIAG_COUNTER_LIFECYCLE_START_FAILED, 1); + domain_diag_record_error("lifecycle_start", rc, failed_stage); + ESP_LOGE(TAG, + "controller lifecycle start failed at stage=%s, rc=0x%x", + failed_stage, + (unsigned)rc); + return rc; +} + +esp_err_t controller_lifecycle_stop(void) { + domain_diag_counter_add(DOMAIN_DIAG_COUNTER_LIFECYCLE_STOP_ATTEMPT, 1); + + if (!lifecycle_lock_take(1000)) { + domain_diag_counter_add(DOMAIN_DIAG_COUNTER_LIFECYCLE_STOP_FAILED, 1); + domain_diag_record_error("lifecycle_stop", ESP_ERR_TIMEOUT, "lifecycle lock timeout"); + return ESP_ERR_TIMEOUT; + } + + if (s_status.state == CONTROLLER_LIFECYCLE_STATE_STOPPED) { + xSemaphoreGive(s_lock); + return ESP_OK; + } + if (s_status.state == CONTROLLER_LIFECYCLE_STATE_STARTING || + s_status.state == CONTROLLER_LIFECYCLE_STATE_STOPPING) { + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_STATE; + } + + lifecycle_set_state_locked(CONTROLLER_LIFECYCLE_STATE_STOPPING, ESP_OK, "stopping"); + xSemaphoreGive(s_lock); + + esp_err_t first_err = ESP_OK; + + esp_err_t rc = lifecycle_stop_rest_server(); + 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; + } + + rc = lifecycle_stop_printer_protocol(); + if (rc != ESP_OK && first_err == ESP_OK) { + first_err = rc; + } + + rc = lifecycle_stop_system_runtime(); + if (rc != ESP_OK && first_err == ESP_OK) { + first_err = rc; + } + + if (lifecycle_lock_take(1000)) { + if (first_err == ESP_OK) { + lifecycle_set_state_locked(CONTROLLER_LIFECYCLE_STATE_STOPPED, ESP_OK, "stopped"); + } else { + lifecycle_set_state_locked(CONTROLLER_LIFECYCLE_STATE_DEGRADED, first_err, "stop_failed"); + } + xSemaphoreGive(s_lock); + } + + if (first_err == ESP_OK) { + domain_diag_counter_add(DOMAIN_DIAG_COUNTER_LIFECYCLE_STOP_SUCCESS, 1); + ESP_LOGI(TAG, "controller lifecycle stopped"); + } else { + domain_diag_counter_add(DOMAIN_DIAG_COUNTER_LIFECYCLE_STOP_FAILED, 1); + domain_diag_record_error("lifecycle_stop", first_err, "controller stop failed"); + ESP_LOGE(TAG, "controller lifecycle stop failed, rc=0x%x", (unsigned)first_err); + } + + return first_err; +} + +void controller_lifecycle_get_status(controller_lifecycle_status_t *out_status) { + if (out_status == NULL) { + return; + } + + memset(out_status, 0, sizeof(*out_status)); + + if (!lifecycle_lock_take(200)) { + return; + } + + *out_status = s_status; + xSemaphoreGive(s_lock); +} diff --git a/components/control_plane/src/rest_server.c b/components/control_plane/src/rest_server.c new file mode 100644 index 0000000..85c9b84 --- /dev/null +++ b/components/control_plane/src/rest_server.c @@ -0,0 +1,1216 @@ +#include "control_plane.h" + +#include + +#include "rest_server_internal.h" +#include "esp_http_server.h" +#include "esp_log.h" + +static const char *TAG = "rest_server"; + +static httpd_handle_t s_server; + +static const char *s_web_index = + "\n" + "\n" + "\n" + " \n" + " \n" + " TQ Printer Controller\n" + " \n" + "\n" + "\n" + "
\n" + "

TQ Printer Controller

\n" + "

ESP32-S3 local control page. This page calls the onboard REST API directly.

\n" + "\n" + " \n" + " \n" + "\n" + "
\n" + " \n" + " \n" + " \n" + "
\n" + "\n" + " \n" + " \n" + " \n" + " \n" + "
\n" + " \n" + " \n" + " \n" + "
\n" + "\n" + " \n" + "
\n" + " \n" + "
\n" + "
纸张状态:未知
\n" + "
Backend: -, Connected: -, Transport: -, GPIO: -, Expect: -
\n" + "
\n" + " \n" + "
\n" + "
Only effective on Direct backend. Use for sensor bring-up/debug.
\n" + "\n" + " \n" + "
参数标签中的字段名与 /v1/printer/direct/debug 的 JSON 键保持一致。
\n" + "
\n" + "
\n" + " \n" + " \n" + "
\n" + "
\n" + " \n" + " \n" + "
\n" + "
\n" + " \n" + " \n" + "
\n" + "
\n" + "
\n" + "
\n" + " \n" + " \n" + "
\n" + "
\n" + " \n" + " \n" + "
\n" + "
\n" + " \n" + " \n" + "
\n" + "
\n" + " \n" + " \n" + "
\n" + "
\n" + "
\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
\n" + "
override_* 参数填 0 表示不覆盖,恢复为运行时默认策略。
\n" + "
Auto Diagnose 使用低功耗测试图案;若点击后仍重启(Brownout),请先改善供电或降低热头功率。
\n" + "\n" + " \n" + " \n" + "
\n" + " \n" + "
\n" + "\n" + " \n" + " \n" + "
\n" + " \n" + " \n" + " \n" + "
\n" + "
\n" + " \n" + " \n" + " \n" + "
\n" + "
\n" + " \n" + " \n" + " \n" + " \n" + "
\n" + "
Submit prompt only: ESP32-S3 calls DashScope Z-Image, downloads PNG, rasterizes and prints.
\n" + "\n" + " \n" + "
\n" + " \n" + "
\n" + "
语音状态:初始化中
\n" + "
\n" + "
单击开始本轮语音输入;识别结束后自动停止上传,等待语音播报和打印完成。
\n" + "\n" + "
Tip: connect printer first, then submit print jobs.
\n" + "
Ready.
\n" + "
\n" + "\n" + " \n" + "\n" + "\n"; + +static esp_err_t index_get(httpd_req_t *req) { + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return httpd_resp_send(req, s_web_index, HTTPD_RESP_USE_STRLEN); +} + +static esp_err_t favicon_get(httpd_req_t *req) { + httpd_resp_set_status(req, "204 No Content"); + return httpd_resp_send(req, NULL, 0); +} + +esp_err_t rest_server_start(void) { + if (s_server != NULL) { + return ESP_OK; + } + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = CONFIG_TQ_HTTP_PORT; + config.uri_match_fn = httpd_uri_match_wildcard; + config.max_uri_handlers = 36; + config.stack_size = 10240; +#if CONFIG_FREERTOS_UNICORE + config.core_id = 0; +#else + config.core_id = 1; +#endif + + esp_err_t err = httpd_start(&s_server, &config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "httpd_start failed: %s", esp_err_to_name(err)); + return err; + } + + httpd_uri_t index = { + .uri = "/", + .method = HTTP_GET, + .handler = index_get, + }; + httpd_register_uri_handler(s_server, &index); + + httpd_uri_t favicon = { + .uri = "/favicon.ico", + .method = HTTP_GET, + .handler = favicon_get, + }; + httpd_register_uri_handler(s_server, &favicon); + + httpd_uri_t health = { + .uri = "/v1/health", + .method = HTTP_GET, + .handler = rest_server_health_get, + }; + httpd_register_uri_handler(s_server, &health); + + httpd_uri_t connect = { + .uri = "/v1/printer/connect", + .method = HTTP_POST, + .handler = rest_server_connect_post, + }; + httpd_register_uri_handler(s_server, &connect); + + httpd_uri_t disconnect = { + .uri = "/v1/printer/disconnect", + .method = HTTP_POST, + .handler = rest_server_disconnect_post, + }; + httpd_register_uri_handler(s_server, &disconnect); + + httpd_uri_t status = { + .uri = "/v1/printer/status", + .method = HTTP_GET, + .handler = rest_server_status_get, + }; + httpd_register_uri_handler(s_server, &status); + + httpd_uri_t direct_debug_get_uri = { + .uri = "/v1/printer/direct/debug", + .method = HTTP_GET, + .handler = rest_server_printer_direct_debug_get, + }; + httpd_register_uri_handler(s_server, &direct_debug_get_uri); + + httpd_uri_t direct_debug_post_uri = { + .uri = "/v1/printer/direct/debug", + .method = HTTP_POST, + .handler = rest_server_printer_direct_debug_post, + }; + httpd_register_uri_handler(s_server, &direct_debug_post_uri); + + httpd_uri_t voice_status = { + .uri = "/v1/voice/status", + .method = HTTP_GET, + .handler = rest_server_voice_status_get, + }; + httpd_register_uri_handler(s_server, &voice_status); + + httpd_uri_t voice_session_start = { + .uri = "/v1/voice/session/start", + .method = HTTP_POST, + .handler = rest_server_voice_session_start_post, + }; + httpd_register_uri_handler(s_server, &voice_session_start); + + httpd_uri_t voice_session_stop = { + .uri = "/v1/voice/session/stop", + .method = HTTP_POST, + .handler = rest_server_voice_session_stop_post, + }; + httpd_register_uri_handler(s_server, &voice_session_stop); + + httpd_uri_t voice_tap_start = { + .uri = "/v1/voice/tap/start", + .method = HTTP_POST, + .handler = rest_server_voice_tap_start_post, + }; + httpd_register_uri_handler(s_server, &voice_tap_start); + + httpd_uri_t voice_tap_cancel = { + .uri = "/v1/voice/tap/cancel", + .method = HTTP_POST, + .handler = rest_server_voice_tap_cancel_post, + }; + httpd_register_uri_handler(s_server, &voice_tap_cancel); + + httpd_uri_t print_raster = { + .uri = "/v1/print/raster", + .method = HTTP_POST, + .handler = rest_server_print_raster_post, + }; + httpd_register_uri_handler(s_server, &print_raster); + + httpd_uri_t print_image = { + .uri = "/v1/print/image", + .method = HTTP_POST, + .handler = rest_server_print_image_post, + }; + httpd_register_uri_handler(s_server, &print_image); + + httpd_uri_t print_qr = { + .uri = "/v1/print/qr", + .method = HTTP_POST, + .handler = rest_server_print_qr_post, + }; + httpd_register_uri_handler(s_server, &print_qr); + + httpd_uri_t print_text = { + .uri = "/v1/print/text", + .method = HTTP_POST, + .handler = rest_server_print_text_post, + }; + httpd_register_uri_handler(s_server, &print_text); + + httpd_uri_t print_label = { + .uri = "/v1/print/label", + .method = HTTP_POST, + .handler = rest_server_print_label_post, + }; + httpd_register_uri_handler(s_server, &print_label); + + httpd_uri_t print_receipt = { + .uri = "/v1/print/receipt", + .method = HTTP_POST, + .handler = rest_server_print_receipt_post, + }; + httpd_register_uri_handler(s_server, &print_receipt); + + httpd_uri_t jobs = { + .uri = "/v1/jobs", + .method = HTTP_GET, + .handler = rest_server_jobs_get, + }; + httpd_register_uri_handler(s_server, &jobs); + + httpd_uri_t jobs_delete_uri = { + .uri = "/v1/jobs", + .method = HTTP_DELETE, + .handler = rest_server_jobs_delete, + }; + httpd_register_uri_handler(s_server, &jobs_delete_uri); + + httpd_uri_t job = { + .uri = "/v1/jobs/*", + .method = HTTP_GET, + .handler = rest_server_job_get, + }; + httpd_register_uri_handler(s_server, &job); + + httpd_uri_t job_delete_uri = { + .uri = "/v1/jobs/*", + .method = HTTP_DELETE, + .handler = rest_server_job_delete, + }; + httpd_register_uri_handler(s_server, &job_delete_uri); + + httpd_uri_t label_gap = { + .uri = "/v1/label/gap_move", + .method = HTTP_POST, + .handler = rest_server_label_gap_move_post, + }; + httpd_register_uri_handler(s_server, &label_gap); + + httpd_uri_t label_offset_get_uri = { + .uri = "/v1/label/offset", + .method = HTTP_GET, + .handler = rest_server_label_offset_get, + }; + httpd_register_uri_handler(s_server, &label_offset_get_uri); + + httpd_uri_t label_offset_post_uri = { + .uri = "/v1/label/offset", + .method = HTTP_POST, + .handler = rest_server_label_offset_post, + }; + httpd_register_uri_handler(s_server, &label_offset_post_uri); + + httpd_uri_t ota_version = { + .uri = "/v1/ota/version", + .method = HTTP_GET, + .handler = rest_server_ota_version_get, + }; + httpd_register_uri_handler(s_server, &ota_version); + + httpd_uri_t ota_jump_boot = { + .uri = "/v1/ota/jump_boot", + .method = HTTP_POST, + .handler = rest_server_ota_jump_boot_post, + }; + httpd_register_uri_handler(s_server, &ota_jump_boot); + + httpd_uri_t ota_jump_app = { + .uri = "/v1/ota/jump_app", + .method = HTTP_POST, + .handler = rest_server_ota_jump_app_post, + }; + httpd_register_uri_handler(s_server, &ota_jump_app); + + httpd_uri_t ota_erase_page = { + .uri = "/v1/ota/erase_page", + .method = HTTP_POST, + .handler = rest_server_ota_erase_page_post, + }; + httpd_register_uri_handler(s_server, &ota_erase_page); + + httpd_uri_t ota_write_frame = { + .uri = "/v1/ota/write_frame", + .method = HTTP_POST, + .handler = rest_server_ota_write_frame_post, + }; + httpd_register_uri_handler(s_server, &ota_write_frame); + + httpd_uri_t ota_upgrade = { + .uri = "/v1/ota/upgrade", + .method = HTTP_POST, + .handler = rest_server_ota_upgrade_post, + }; + httpd_register_uri_handler(s_server, &ota_upgrade); + + ESP_LOGI(TAG, "REST server started on port %d", CONFIG_TQ_HTTP_PORT); + return ESP_OK; +} + +void rest_server_stop(void) { + if (s_server != NULL) { + httpd_stop(s_server); + s_server = NULL; + } +} diff --git a/components/control_plane/src/rest_server_common.c b/components/control_plane/src/rest_server_common.c new file mode 100644 index 0000000..05953e5 --- /dev/null +++ b/components/control_plane/src/rest_server_common.c @@ -0,0 +1,187 @@ +#include "rest_server_internal.h" + +#include +#include +#include +#include +#include + +#include "esp_heap_caps.h" +#include "mbedtls/base64.h" +#include "domain.h" + +#define REST_SERVER_MAX_BODY_BYTES (4 * 1024 * 1024) + +static void *alloc_prefer_psram(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 *calloc_prefer_psram(size_t n, size_t size) { + void *ptr = heap_caps_calloc(n, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (ptr == NULL) { + ptr = calloc(n, size); + } + return ptr; +} +bool rest_server_auth_ok(httpd_req_t *req) { + if (strlen(CONFIG_TQ_API_KEY) == 0) { + return true; + } + + char buf[96] = {0}; + if (httpd_req_get_hdr_value_str(req, "X-API-Key", buf, sizeof(buf)) != ESP_OK) { + return false; + } + + return strcmp(buf, CONFIG_TQ_API_KEY) == 0; +} + +esp_err_t rest_server_send_json(httpd_req_t *req, const char *status, cJSON *root) { + char *text = cJSON_PrintUnformatted(root); + if (text == NULL) { + return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "json encode failed"); + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_status(req, status); + esp_err_t err = httpd_resp_sendstr(req, text); + cJSON_free(text); + domain_diag_counter_add(DOMAIN_DIAG_COUNTER_REST_RESPONSES_TOTAL, 1); + return err; +} + +esp_err_t rest_server_send_error(httpd_req_t *req, const char *status, const char *message) { + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", false); + cJSON_AddStringToObject(root, "error", message != NULL ? message : "unknown"); + esp_err_t err = rest_server_send_json(req, status, root); + cJSON_Delete(root); + domain_diag_counter_add(DOMAIN_DIAG_COUNTER_REST_ERRORS_TOTAL, 1); + domain_diag_record_error("rest_api", ESP_FAIL, message != NULL ? message : "unknown"); + return err; +} + +esp_err_t rest_server_read_body(httpd_req_t *req, char **out_body) { + if (out_body == NULL) { + return ESP_ERR_INVALID_ARG; + } + + *out_body = NULL; + + if (req->content_len <= 0 || req->content_len > REST_SERVER_MAX_BODY_BYTES) { + return ESP_ERR_INVALID_SIZE; + } + + char *buf = (char *)calloc_prefer_psram(1, (size_t)req->content_len + 1); + if (buf == NULL) { + return ESP_ERR_NO_MEM; + } + + int received = 0; + while (received < req->content_len) { + int ret = httpd_req_recv(req, buf + received, req->content_len - received); + if (ret <= 0) { + free(buf); + return ESP_FAIL; + } + received += ret; + } + + *out_body = buf; + return ESP_OK; +} + +bool rest_server_parse_uri_u32_tail(const char *uri, uint32_t *out_value) { + if (uri == NULL || out_value == NULL) { + return false; + } + + const char *slash = strrchr(uri, '/'); + if (slash == NULL || *(slash + 1) == '\0') { + return false; + } + + errno = 0; + char *endptr = NULL; + unsigned long val = strtoul(slash + 1, &endptr, 10); + if (errno != 0 || endptr == slash + 1 || *endptr != '\0' || val > UINT32_MAX) { + return false; + } + + *out_value = (uint32_t)val; + return true; +} + +esp_err_t rest_server_base64_decode_alloc(const char *b64, uint8_t **out_raw, size_t *out_len) { + if (b64 == NULL || out_raw == NULL || out_len == NULL) { + return ESP_ERR_INVALID_ARG; + } + + *out_raw = NULL; + *out_len = 0; + + size_t decoded_len = 0; + int rc = mbedtls_base64_decode(NULL, + 0, + &decoded_len, + (const unsigned char *)b64, + strlen(b64)); + if (!(rc == 0 || rc == MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL)) { + return ESP_ERR_INVALID_ARG; + } + + uint8_t *buf = (uint8_t *)alloc_prefer_psram(decoded_len); + if (buf == NULL) { + return ESP_ERR_NO_MEM; + } + + rc = mbedtls_base64_decode(buf, + decoded_len, + &decoded_len, + (const unsigned char *)b64, + strlen(b64)); + if (rc != 0) { + free(buf); + return ESP_ERR_INVALID_ARG; + } + + *out_raw = buf; + *out_len = decoded_len; + return ESP_OK; +} + +void rest_server_appendf(char *dst, size_t cap, size_t *offset, const char *fmt, ...) { + if (dst == NULL || cap == 0 || offset == NULL || fmt == NULL || *offset >= cap) { + return; + } + + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(dst + *offset, cap - *offset, fmt, ap); + va_end(ap); + + if (n <= 0) { + return; + } + + size_t written = (size_t)n; + if (written >= cap - *offset) { + *offset = cap - 1; + return; + } + *offset += written; +} + +bool rest_server_json_bool_with_default(cJSON *item, bool default_value) { + if (item == NULL) { + return default_value; + } + if (cJSON_IsBool(item)) { + return cJSON_IsTrue(item); + } + return default_value; +} diff --git a/components/control_plane/src/rest_server_jobs.c b/components/control_plane/src/rest_server_jobs.c new file mode 100644 index 0000000..3fdebc0 --- /dev/null +++ b/components/control_plane/src/rest_server_jobs.c @@ -0,0 +1,173 @@ +#include "rest_server_internal.h" + +#include + +#include "domain.h" +esp_err_t rest_server_job_get(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + uint32_t job_id = 0; + if (!rest_server_parse_uri_u32_tail(req->uri, &job_id)) { + return rest_server_send_error(req, "400 Bad Request", "invalid job id"); + } + + print_job_info_t info; + if (!printer_protocol_get_job(job_id, &info)) { + return rest_server_send_error(req, "404 Not Found", "job not found"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddNumberToObject(root, "job_id", info.id); + cJSON_AddStringToObject(root, "state", printer_protocol_job_state_str(info.state)); + cJSON_AddNumberToObject(root, "progress", info.progress); + cJSON_AddStringToObject(root, "density", info.density); + cJSON_AddNumberToObject(root, "width", info.width); + cJSON_AddNumberToObject(root, "height", info.height); + cJSON_AddNumberToObject(root, "data_len", (double)info.data_len); + cJSON_AddStringToObject(root, "error", info.error); + cJSON_AddNumberToObject(root, "created_ms", (double)info.created_ms); + cJSON_AddNumberToObject(root, "started_ms", (double)info.started_ms); + cJSON_AddNumberToObject(root, "finished_ms", (double)info.finished_ms); + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_jobs_get(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + print_job_info_t jobs[16]; + size_t total_count = 0; + esp_err_t list_rc = printer_protocol_list_jobs(jobs, 16, &total_count); + if (list_rc != ESP_OK) { + return rest_server_send_error(req, "500 Internal Server Error", "list jobs failed"); + } + + size_t emit_count = total_count; + if (emit_count > 16) { + emit_count = 16; + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddNumberToObject(root, "total", (double)total_count); + cJSON_AddNumberToObject(root, "returned", (double)emit_count); + + cJSON *arr = cJSON_AddArrayToObject(root, "jobs"); + for (size_t i = 0; i < emit_count; ++i) { + cJSON *item = cJSON_CreateObject(); + cJSON_AddNumberToObject(item, "job_id", jobs[i].id); + cJSON_AddStringToObject(item, "state", printer_protocol_job_state_str(jobs[i].state)); + cJSON_AddNumberToObject(item, "progress", jobs[i].progress); + cJSON_AddStringToObject(item, "density", jobs[i].density); + cJSON_AddNumberToObject(item, "width", jobs[i].width); + cJSON_AddNumberToObject(item, "height", jobs[i].height); + cJSON_AddNumberToObject(item, "data_len", (double)jobs[i].data_len); + cJSON_AddStringToObject(item, "error", jobs[i].error); + cJSON_AddNumberToObject(item, "created_ms", (double)jobs[i].created_ms); + cJSON_AddNumberToObject(item, "started_ms", (double)jobs[i].started_ms); + cJSON_AddNumberToObject(item, "finished_ms", (double)jobs[i].finished_ms); + cJSON_AddItemToArray(arr, item); + } + + if (total_count > emit_count) { + cJSON_AddStringToObject(root, "warning", "job list truncated"); + } + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_job_delete(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + uint32_t job_id = 0; + if (!rest_server_parse_uri_u32_tail(req->uri, &job_id)) { + return rest_server_send_error(req, "400 Bad Request", "invalid job id"); + } + + char cancel_err[128] = {0}; + esp_err_t cancel_rc = printer_protocol_cancel_job(job_id, cancel_err, sizeof(cancel_err)); + if (cancel_rc == ESP_ERR_NOT_FOUND) { + return rest_server_send_error(req, "404 Not Found", "job not found"); + } + if (cancel_rc == ESP_ERR_INVALID_STATE) { + return rest_server_send_error(req, + "409 Conflict", + cancel_err[0] != '\0' ? cancel_err : "job already finished"); + } + if (cancel_rc != ESP_OK) { + return rest_server_send_error(req, + "409 Conflict", + cancel_err[0] != '\0' ? cancel_err : "cancel failed"); + } + + print_job_info_t info; + bool ok = printer_protocol_get_job(job_id, &info); + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddNumberToObject(root, "job_id", job_id); + if (ok) { + cJSON_AddStringToObject(root, "state", printer_protocol_job_state_str(info.state)); + cJSON_AddStringToObject(root, "error", info.error); + } else { + cJSON_AddStringToObject(root, "state", "canceled"); + } + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_jobs_delete(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + bool include_success = true; + bool include_failed = true; + bool include_canceled = true; + + if (req->content_len > 0) { + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + cJSON *json = cJSON_Parse(body); + free(body); + if (json == NULL) { + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + + include_success = rest_server_json_bool_with_default(cJSON_GetObjectItemCaseSensitive(json, "include_success"), true); + include_failed = rest_server_json_bool_with_default(cJSON_GetObjectItemCaseSensitive(json, "include_failed"), true); + include_canceled = rest_server_json_bool_with_default(cJSON_GetObjectItemCaseSensitive(json, "include_canceled"), true); + cJSON_Delete(json); + } + + size_t removed = printer_protocol_cleanup_jobs(include_success, include_failed, include_canceled); + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddNumberToObject(root, "removed", (double)removed); + cJSON_AddBoolToObject(root, "include_success", include_success); + cJSON_AddBoolToObject(root, "include_failed", include_failed); + cJSON_AddBoolToObject(root, "include_canceled", include_canceled); + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + diff --git a/components/control_plane/src/rest_server_ops.c b/components/control_plane/src/rest_server_ops.c new file mode 100644 index 0000000..7a2c773 --- /dev/null +++ b/components/control_plane/src/rest_server_ops.c @@ -0,0 +1,807 @@ +#include "rest_server_internal.h" + +#include +#include +#include +#include + +#include "control_plane.h" +#include "domain.h" + +static const char *printer_backend_str(printer_backend_t backend) { + switch (backend) { + case PRINTER_BACKEND_BLE: + return "ble"; + case PRINTER_BACKEND_DIRECT: + return "direct"; + default: + return "unknown"; + } +} + +static bool parse_printer_backend(const cJSON *json, printer_backend_t *out_backend) { + if (json == NULL || out_backend == NULL) { + return false; + } + cJSON *jbackend = cJSON_GetObjectItemCaseSensitive((cJSON *)json, "backend"); + if (!cJSON_IsString(jbackend) || jbackend->valuestring == NULL) { + return false; + } + if (strcasecmp(jbackend->valuestring, "ble") == 0) { + *out_backend = PRINTER_BACKEND_BLE; + return true; + } + if (strcasecmp(jbackend->valuestring, "direct") == 0) { + *out_backend = PRINTER_BACKEND_DIRECT; + return true; + } + return false; +} + +static void fill_runtime_diag_json(cJSON *root) { + domain_diag_snapshot_t snapshot = {0}; + domain_diag_get_snapshot(&snapshot); + + cJSON *diag = cJSON_AddObjectToObject(root, "diagnostics"); + if (diag == NULL) { + return; + } + + cJSON *counters = cJSON_AddObjectToObject(diag, "counters"); + if (counters != NULL) { + for (int i = 0; i < DOMAIN_DIAG_COUNTER_MAX; ++i) { + cJSON_AddNumberToObject(counters, + domain_diag_counter_name((domain_diag_counter_t)i), + (double)snapshot.counters[i]); + } + } + + cJSON *gauges = cJSON_AddObjectToObject(diag, "gauges"); + if (gauges != NULL) { + for (int i = 0; i < DOMAIN_DIAG_GAUGE_MAX; ++i) { + cJSON_AddNumberToObject(gauges, + domain_diag_gauge_name((domain_diag_gauge_t)i), + snapshot.gauges[i]); + } + } + + cJSON *last_error = cJSON_AddObjectToObject(diag, "last_error"); + if (last_error != NULL) { + cJSON_AddNumberToObject(last_error, "ts_ms", (double)snapshot.last_error_ms); + cJSON_AddNumberToObject(last_error, "code", (double)snapshot.last_error_code); + cJSON_AddStringToObject(last_error, "source", snapshot.last_error_source); + cJSON_AddStringToObject(last_error, "message", snapshot.last_error_message); + } +} + +static void fill_runtime_json(cJSON *root) { + char ip[32] = {0}; + system_runtime_get_ip(ip, sizeof(ip)); + + printer_runtime_status_t st = {0}; + printer_protocol_get_runtime_status(&st); + + cJSON_AddBoolToObject(root, "wifi_ready", system_runtime_wifi_ready()); + cJSON_AddStringToObject(root, "wifi_mode", "sta"); + cJSON_AddStringToObject(root, "ip", ip); + + cJSON_AddStringToObject(root, "printer_backend", printer_backend_str(st.backend)); + cJSON_AddBoolToObject(root, "printer_connected", st.connected); + cJSON_AddBoolToObject(root, "printer_transport_ready", st.transport_ready); + cJSON_AddBoolToObject(root, "ble_connected", st.backend == PRINTER_BACKEND_BLE && st.connected); + cJSON_AddBoolToObject(root, "ble_notify_ready", st.backend == PRINTER_BACKEND_BLE && st.notify_ready); + cJSON_AddBoolToObject(root, "printer_busy", st.busy); + cJSON_AddBoolToObject(root, "has_paper", st.has_paper); + cJSON_AddNumberToObject(root, "paper_gpio_level", st.paper_gpio_level); + cJSON_AddNumberToObject(root, "paper_present_level", st.paper_present_level); + cJSON_AddNumberToObject(root, "battery_percent", st.battery_percent); + cJSON_AddNumberToObject(root, "temperature", st.temperature); + cJSON_AddBoolToObject(root, "supports_gap_move", st.supports_gap_move); + cJSON_AddBoolToObject(root, "supports_label_offset", st.supports_label_offset); + cJSON_AddBoolToObject(root, "supports_ota", st.supports_ota); + cJSON_AddNumberToObject(root, "queue_depth", st.queue_depth); + cJSON_AddNumberToObject(root, "mtu", st.mtu); + cJSON_AddNumberToObject(root, "last_status_ms", (double)st.last_status_ms); + + voice_interaction_status_t voice = {0}; + voice_interaction_get_status(&voice); + cJSON_AddBoolToObject(root, "voice_ready", voice.ready); + cJSON_AddBoolToObject(root, "voice_session_active", voice.session_active); + cJSON_AddBoolToObject(root, "voice_ws_connected", voice.ws_connected); + cJSON_AddBoolToObject(root, "voice_started", voice.started); + cJSON_AddBoolToObject(root, "voice_tap_active", voice.tap_active); + cJSON_AddStringToObject(root, + "voice_dialog_state", + voice_interaction_dialog_state_str(voice.dialog_state)); + cJSON_AddStringToObject(root, "voice_task_id", voice.task_id); + cJSON_AddStringToObject(root, "voice_dialog_id", voice.dialog_id); + cJSON_AddStringToObject(root, "voice_last_error", voice.last_error); + cJSON_AddNumberToObject(root, "voice_last_event_ms", (double)voice.last_event_ms); + cJSON_AddNumberToObject(root, "voice_upstream_packets", (double)voice.upstream_packets); + cJSON_AddNumberToObject(root, "voice_downstream_packets", (double)voice.downstream_packets); + + controller_lifecycle_status_t lifecycle = {0}; + controller_lifecycle_get_status(&lifecycle); + cJSON_AddStringToObject(root, "lifecycle_state", controller_lifecycle_state_str(lifecycle.state)); + cJSON_AddStringToObject(root, "lifecycle_stage", lifecycle.last_stage); + cJSON_AddNumberToObject(root, "lifecycle_last_error", (double)lifecycle.last_error); + cJSON_AddNumberToObject(root, "lifecycle_start_attempt", (double)lifecycle.start_attempt); + cJSON_AddNumberToObject(root, "lifecycle_last_transition_ms", (double)lifecycle.last_transition_ms); + + fill_runtime_diag_json(root); +} + +esp_err_t rest_server_health_get(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + cJSON *root = cJSON_CreateObject(); + controller_lifecycle_status_t lifecycle = {0}; + controller_lifecycle_get_status(&lifecycle); + bool healthy = (lifecycle.state == CONTROLLER_LIFECYCLE_STATE_RUNNING) && system_runtime_wifi_ready(); + cJSON_AddBoolToObject(root, "ok", healthy); + fill_runtime_json(root); + + esp_err_t err = rest_server_send_json(req, healthy ? "200 OK" : "503 Service Unavailable", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_connect_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + char *body = NULL; + char name[32] = "TQPrinter"; + uint32_t timeout_ms = domain_policy_rest_printer_connect_timeout_ms(); + printer_backend_t backend = printer_protocol_get_backend(); + bool backend_specified = false; + + if (req->content_len > 0) { + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + cJSON *json = cJSON_Parse(body); + if (json == NULL) { + free(body); + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + + cJSON *jname = cJSON_GetObjectItemCaseSensitive(json, "name"); + if (cJSON_IsString(jname) && jname->valuestring != NULL) { + strlcpy(name, jname->valuestring, sizeof(name)); + } + + cJSON *jtimeout = cJSON_GetObjectItemCaseSensitive(json, "timeout_ms"); + if (cJSON_IsNumber(jtimeout)) { + uint32_t min_ms = domain_policy_rest_printer_connect_timeout_min_ms(); + uint32_t max_ms = domain_policy_rest_printer_connect_timeout_max_ms(); + if (jtimeout->valuedouble >= (double)min_ms && jtimeout->valuedouble <= (double)max_ms) { + timeout_ms = (uint32_t)jtimeout->valuedouble; + } else { + cJSON_Delete(json); + free(body); + return rest_server_send_error(req, "400 Bad Request", "timeout_ms out of range"); + } + } + + cJSON *jbackend = cJSON_GetObjectItemCaseSensitive(json, "backend"); + if (jbackend != NULL) { + backend_specified = true; + if (!parse_printer_backend(json, &backend)) { + cJSON_Delete(json); + free(body); + return rest_server_send_error(req, "400 Bad Request", "backend must be ble or direct"); + } + } + + cJSON_Delete(json); + free(body); + } + + printer_connect_options_t opt = { + .backend = backend, + .name = backend == PRINTER_BACKEND_BLE ? name : NULL, + .timeout_ms = timeout_ms, + }; + char connect_err[96] = {0}; + esp_err_t err = printer_protocol_connect_ex(&opt, connect_err, sizeof(connect_err)); + if (err != ESP_OK) { + if (err == ESP_ERR_TIMEOUT) { + return rest_server_send_error(req, + "504 Gateway Timeout", + connect_err[0] != '\0' ? connect_err : "connect timeout"); + } + if (err == ESP_ERR_NOT_SUPPORTED || err == ESP_ERR_INVALID_STATE) { + return rest_server_send_error(req, + "409 Conflict", + connect_err[0] != '\0' ? connect_err : "connect failed"); + } + if (err == ESP_ERR_INVALID_ARG) { + return rest_server_send_error(req, + "400 Bad Request", + connect_err[0] != '\0' ? connect_err : "invalid connect params"); + } + return rest_server_send_error(req, + "500 Internal Server Error", + connect_err[0] != '\0' ? connect_err : "connect failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddStringToObject(root, "message", "connected"); + cJSON_AddStringToObject(root, "backend", printer_backend_str(backend)); + cJSON_AddBoolToObject(root, "backend_specified", backend_specified); + fill_runtime_json(root); + + err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_disconnect_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + printer_protocol_disconnect(); + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddStringToObject(root, "message", "disconnected"); + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_status_get(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + fill_runtime_json(root); + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +static void add_direct_debug_json_fields(cJSON *root, const printer_direct_debug_config_t *cfg) { + cJSON_AddNumberToObject(root, "shift_clock_high_us", cfg->shift_clock_high_us); + cJSON_AddNumberToObject(root, "shift_clock_low_us", cfg->shift_clock_low_us); + cJSON_AddNumberToObject(root, "latch_pulse_us", cfg->latch_pulse_us); + cJSON_AddBoolToObject(root, "strobe_active_high", cfg->strobe_active_high); + cJSON_AddBoolToObject(root, "boost_active_high", cfg->boost_active_high); + cJSON_AddNumberToObject(root, "override_strobe_on_us", cfg->override_strobe_on_us); + cJSON_AddNumberToObject(root, "override_strobe_interval_us", cfg->override_strobe_interval_us); + cJSON_AddNumberToObject(root, "override_motor_step_us", cfg->override_motor_step_us); + cJSON_AddNumberToObject(root, "override_steps_per_line", cfg->override_steps_per_line); +} + +static esp_err_t direct_debug_error_response(httpd_req_t *req, esp_err_t rc, const char *msg) { + if (rc == ESP_ERR_INVALID_ARG) { + return rest_server_send_error(req, "400 Bad Request", msg); + } + if (rc == ESP_ERR_NOT_SUPPORTED || rc == ESP_ERR_INVALID_STATE) { + return rest_server_send_error(req, "409 Conflict", msg); + } + if (rc == ESP_ERR_TIMEOUT) { + return rest_server_send_error(req, "504 Gateway Timeout", msg); + } + return rest_server_send_error(req, "500 Internal Server Error", msg); +} + +esp_err_t rest_server_printer_direct_debug_get(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + printer_direct_debug_config_t cfg = {0}; + char get_err[128] = {0}; + esp_err_t rc = printer_protocol_get_direct_debug_config(&cfg, get_err, sizeof(get_err)); + if (rc != ESP_OK) { + return direct_debug_error_response(req, + rc, + get_err[0] != '\0' ? get_err : "get direct debug failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddStringToObject(root, "backend", printer_backend_str(printer_protocol_get_backend())); + add_direct_debug_json_fields(root, &cfg); + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_printer_direct_debug_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + printer_direct_debug_config_t cfg = {0}; + char get_err[128] = {0}; + esp_err_t rc = printer_protocol_get_direct_debug_config(&cfg, get_err, sizeof(get_err)); + if (rc != ESP_OK) { + return direct_debug_error_response(req, + rc, + get_err[0] != '\0' ? get_err : "get direct debug failed"); + } + + bool reset_defaults = false; + bool has_update = false; + if (req->content_len > 0) { + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + cJSON *json = cJSON_Parse(body); + free(body); + if (json == NULL) { + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + + cJSON *jreset = cJSON_GetObjectItemCaseSensitive(json, "reset_defaults"); + if (cJSON_IsBool(jreset)) { + reset_defaults = cJSON_IsTrue(jreset); + } + + cJSON *jshift_hi = cJSON_GetObjectItemCaseSensitive(json, "shift_clock_high_us"); + if (jshift_hi != NULL) { + if (!cJSON_IsNumber(jshift_hi) || jshift_hi->valuedouble < 1 || jshift_hi->valuedouble > 50) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "shift_clock_high_us must be 1..50"); + } + cfg.shift_clock_high_us = (uint16_t)jshift_hi->valuedouble; + has_update = true; + } + + cJSON *jshift_lo = cJSON_GetObjectItemCaseSensitive(json, "shift_clock_low_us"); + if (jshift_lo != NULL) { + if (!cJSON_IsNumber(jshift_lo) || jshift_lo->valuedouble < 1 || jshift_lo->valuedouble > 50) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "shift_clock_low_us must be 1..50"); + } + cfg.shift_clock_low_us = (uint16_t)jshift_lo->valuedouble; + has_update = true; + } + + cJSON *jlatch = cJSON_GetObjectItemCaseSensitive(json, "latch_pulse_us"); + if (jlatch != NULL) { + if (!cJSON_IsNumber(jlatch) || jlatch->valuedouble < 1 || jlatch->valuedouble > 50) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "latch_pulse_us must be 1..50"); + } + cfg.latch_pulse_us = (uint16_t)jlatch->valuedouble; + has_update = true; + } + + cJSON *jstb = cJSON_GetObjectItemCaseSensitive(json, "strobe_active_high"); + if (cJSON_IsBool(jstb)) { + cfg.strobe_active_high = cJSON_IsTrue(jstb); + has_update = true; + } + + cJSON *jboost = cJSON_GetObjectItemCaseSensitive(json, "boost_active_high"); + if (cJSON_IsBool(jboost)) { + cfg.boost_active_high = cJSON_IsTrue(jboost); + has_update = true; + } + + cJSON *jstrobe_on = cJSON_GetObjectItemCaseSensitive(json, "override_strobe_on_us"); + if (jstrobe_on != NULL) { + if (!cJSON_IsNumber(jstrobe_on) || jstrobe_on->valuedouble < 0 || jstrobe_on->valuedouble > 10000) { + cJSON_Delete(json); + return rest_server_send_error(req, + "400 Bad Request", + "override_strobe_on_us must be 0..10000"); + } + if (jstrobe_on->valuedouble > 0 && jstrobe_on->valuedouble < 100) { + cJSON_Delete(json); + return rest_server_send_error(req, + "400 Bad Request", + "override_strobe_on_us must be 0 or 100..10000"); + } + cfg.override_strobe_on_us = (uint16_t)jstrobe_on->valuedouble; + has_update = true; + } + + cJSON *jstrobe_gap = cJSON_GetObjectItemCaseSensitive(json, "override_strobe_interval_us"); + if (jstrobe_gap != NULL) { + if (!cJSON_IsNumber(jstrobe_gap) || jstrobe_gap->valuedouble < 0 || jstrobe_gap->valuedouble > 10000) { + cJSON_Delete(json); + return rest_server_send_error(req, + "400 Bad Request", + "override_strobe_interval_us must be 0..10000"); + } + cfg.override_strobe_interval_us = (uint16_t)jstrobe_gap->valuedouble; + has_update = true; + } + + cJSON *jmotor = cJSON_GetObjectItemCaseSensitive(json, "override_motor_step_us"); + if (jmotor != NULL) { + if (!cJSON_IsNumber(jmotor) || jmotor->valuedouble < 0 || jmotor->valuedouble > 20000) { + cJSON_Delete(json); + return rest_server_send_error(req, + "400 Bad Request", + "override_motor_step_us must be 0..20000"); + } + if (jmotor->valuedouble > 0 && jmotor->valuedouble < 100) { + cJSON_Delete(json); + return rest_server_send_error(req, + "400 Bad Request", + "override_motor_step_us must be 0 or 100..20000"); + } + cfg.override_motor_step_us = (uint16_t)jmotor->valuedouble; + has_update = true; + } + + cJSON *jsteps = cJSON_GetObjectItemCaseSensitive(json, "override_steps_per_line"); + if (jsteps != NULL) { + if (!cJSON_IsNumber(jsteps) || jsteps->valuedouble < 0 || jsteps->valuedouble > 8) { + cJSON_Delete(json); + return rest_server_send_error(req, + "400 Bad Request", + "override_steps_per_line must be 0..8"); + } + cfg.override_steps_per_line = (uint8_t)jsteps->valuedouble; + has_update = true; + } + + cJSON_Delete(json); + } + + char set_err[128] = {0}; + rc = printer_protocol_set_direct_debug_config(has_update ? &cfg : NULL, + reset_defaults, + set_err, + sizeof(set_err)); + if (rc != ESP_OK) { + return direct_debug_error_response(req, + rc, + set_err[0] != '\0' ? set_err : "set direct debug failed"); + } + + memset(&cfg, 0, sizeof(cfg)); + memset(get_err, 0, sizeof(get_err)); + rc = printer_protocol_get_direct_debug_config(&cfg, get_err, sizeof(get_err)); + if (rc != ESP_OK) { + return direct_debug_error_response(req, + rc, + get_err[0] != '\0' ? get_err : "get direct debug failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddBoolToObject(root, "reset_defaults", reset_defaults); + cJSON_AddBoolToObject(root, "updated", has_update); + cJSON_AddStringToObject(root, "backend", printer_backend_str(printer_protocol_get_backend())); + add_direct_debug_json_fields(root, &cfg); + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_label_gap_move_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + uint32_t timeout_ms = domain_policy_rest_label_timeout_ms(); + if (req->content_len > 0) { + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + cJSON *json = cJSON_Parse(body); + free(body); + if (json == NULL) { + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + cJSON *jtimeout = cJSON_GetObjectItemCaseSensitive(json, "timeout_ms"); + if (jtimeout != NULL) { + if (!cJSON_IsNumber(jtimeout) || + jtimeout->valuedouble < (double)domain_policy_rest_label_timeout_min_ms() || + jtimeout->valuedouble > (double)domain_policy_rest_label_timeout_max_ms()) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "timeout_ms out of range"); + } + timeout_ms = (uint32_t)jtimeout->valuedouble; + } + cJSON_Delete(json); + } + + char cmd_err[128] = {0}; + esp_err_t rc = printer_protocol_gap_move(timeout_ms, cmd_err, sizeof(cmd_err)); + if (rc != ESP_OK) { + return rest_server_send_error(req, "409 Conflict", cmd_err[0] != '\0' ? cmd_err : "gap move failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddStringToObject(root, "message", "gap move success"); + cJSON_AddNumberToObject(root, "timeout_ms", timeout_ms); + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_label_offset_get(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + uint8_t offset = 0; + char cmd_err[128] = {0}; + esp_err_t rc = printer_protocol_get_label_offset(&offset, + domain_policy_rest_label_timeout_ms(), + cmd_err, + sizeof(cmd_err)); + if (rc != ESP_OK) { + return rest_server_send_error(req, "409 Conflict", cmd_err[0] != '\0' ? cmd_err : "get offset failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddNumberToObject(root, "offset_tenths_mm", offset); + cJSON_AddNumberToObject(root, "offset_mm", ((double)offset) / 10.0); + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_label_offset_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + cJSON *json = cJSON_Parse(body); + free(body); + if (json == NULL) { + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + + cJSON *jvalue = cJSON_GetObjectItemCaseSensitive(json, "offset_tenths_mm"); + if (!cJSON_IsNumber(jvalue) || jvalue->valuedouble < 0 || jvalue->valuedouble > 254) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "offset_tenths_mm must be 0..254"); + } + + uint8_t value = (uint8_t)jvalue->valuedouble; + cJSON_Delete(json); + + char cmd_err[128] = {0}; + esp_err_t rc = printer_protocol_set_label_offset(value, + domain_policy_rest_label_timeout_ms(), + cmd_err, + sizeof(cmd_err)); + if (rc != ESP_OK) { + return rest_server_send_error(req, "409 Conflict", cmd_err[0] != '\0' ? cmd_err : "set offset failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddNumberToObject(root, "offset_tenths_mm", value); + cJSON_AddNumberToObject(root, "offset_mm", ((double)value) / 10.0); + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_ota_version_get(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + printer_ota_version_t version = {0}; + char cmd_err[128] = {0}; + esp_err_t rc = printer_protocol_ota_get_version(&version, + domain_policy_rest_ota_timeout_ms(), + cmd_err, + sizeof(cmd_err)); + if (rc != ESP_OK) { + return rest_server_send_error(req, "409 Conflict", cmd_err[0] != '\0' ? cmd_err : "get version failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddNumberToObject(root, "major", version.major); + cJSON_AddNumberToObject(root, "minor", version.minor); + cJSON_AddNumberToObject(root, "patch", version.patch); + char version_text[32]; + snprintf(version_text, sizeof(version_text), "V%u.%u.%u", version.major, version.minor, version.patch); + cJSON_AddStringToObject(root, "version", version_text); + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_ota_jump_boot_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + char cmd_err[128] = {0}; + esp_err_t rc = printer_protocol_ota_jump_boot(domain_policy_rest_ota_timeout_ms(), + cmd_err, + sizeof(cmd_err)); + if (rc != ESP_OK) { + return rest_server_send_error(req, "409 Conflict", cmd_err[0] != '\0' ? cmd_err : "jump boot failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddStringToObject(root, "message", "jumped to boot"); + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_ota_jump_app_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + char cmd_err[128] = {0}; + esp_err_t rc = printer_protocol_ota_jump_app(domain_policy_rest_ota_timeout_ms(), + cmd_err, + sizeof(cmd_err)); + if (rc != ESP_OK) { + return rest_server_send_error(req, "409 Conflict", cmd_err[0] != '\0' ? cmd_err : "jump app failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddStringToObject(root, "message", "jumped to app"); + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_ota_erase_page_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + cJSON *json = cJSON_Parse(body); + free(body); + if (json == NULL) { + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + + cJSON *jpage = cJSON_GetObjectItemCaseSensitive(json, "page_num"); + cJSON *jtimeout = cJSON_GetObjectItemCaseSensitive(json, "timeout_ms"); + if (!cJSON_IsNumber(jpage) || jpage->valuedouble < 0 || jpage->valuedouble > 65535) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "page_num must be 0..65535"); + } + + uint32_t timeout_ms = domain_policy_rest_ota_timeout_ms(); + if (jtimeout != NULL) { + if (!cJSON_IsNumber(jtimeout) || + jtimeout->valuedouble < (double)domain_policy_rest_ota_timeout_min_ms() || + jtimeout->valuedouble > (double)domain_policy_rest_ota_timeout_max_ms()) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "timeout_ms out of range"); + } + timeout_ms = (uint32_t)jtimeout->valuedouble; + } + + uint16_t page_num = (uint16_t)jpage->valuedouble; + cJSON_Delete(json); + + char cmd_err[128] = {0}; + esp_err_t rc = printer_protocol_ota_erase_page(page_num, timeout_ms, cmd_err, sizeof(cmd_err)); + if (rc != ESP_OK) { + return rest_server_send_error(req, "409 Conflict", cmd_err[0] != '\0' ? cmd_err : "erase page failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddNumberToObject(root, "page_num", page_num); + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_ota_write_frame_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + cJSON *json = cJSON_Parse(body); + free(body); + if (json == NULL) { + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + + cJSON *jpack = cJSON_GetObjectItemCaseSensitive(json, "packet_num"); + cJSON *jlast = cJSON_GetObjectItemCaseSensitive(json, "is_last_frame"); + cJSON *jdata = cJSON_GetObjectItemCaseSensitive(json, "data"); + cJSON *jtimeout = cJSON_GetObjectItemCaseSensitive(json, "timeout_ms"); + + if (!cJSON_IsNumber(jpack) || jpack->valuedouble < 0 || jpack->valuedouble > 65535 || + !cJSON_IsString(jdata) || jdata->valuestring == NULL) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "packet_num and data are required"); + } + + uint32_t timeout_ms = domain_policy_rest_ota_timeout_ms(); + if (jtimeout != NULL) { + if (!cJSON_IsNumber(jtimeout) || + jtimeout->valuedouble < (double)domain_policy_rest_ota_timeout_min_ms() || + jtimeout->valuedouble > (double)domain_policy_rest_ota_timeout_max_ms()) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "timeout_ms out of range"); + } + timeout_ms = (uint32_t)jtimeout->valuedouble; + } + bool is_last = rest_server_json_bool_with_default(jlast, false); + uint16_t packet_num = (uint16_t)jpack->valuedouble; + + uint8_t *frame_data = NULL; + size_t frame_len = 0; + esp_err_t b64_rc = rest_server_base64_decode_alloc(jdata->valuestring, &frame_data, &frame_len); + cJSON_Delete(json); + if (b64_rc != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid base64 data"); + } + + char cmd_err[128] = {0}; + esp_err_t rc = printer_protocol_ota_write_frame(packet_num, + is_last, + frame_data, + frame_len, + timeout_ms, + cmd_err, + sizeof(cmd_err)); + free(frame_data); + if (rc != ESP_OK) { + return rest_server_send_error(req, "409 Conflict", cmd_err[0] != '\0' ? cmd_err : "write frame failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddNumberToObject(root, "packet_num", packet_num); + cJSON_AddBoolToObject(root, "is_last_frame", is_last); + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_ota_upgrade_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + return rest_server_send_error(req, + "409 Conflict", + "bulk ota upgrade endpoint disabled; use jump/erase/write/version step APIs"); +} diff --git a/components/control_plane/src/rest_server_print.c b/components/control_plane/src/rest_server_print.c new file mode 100644 index 0000000..b664e86 --- /dev/null +++ b/components/control_plane/src/rest_server_print.c @@ -0,0 +1,135 @@ +#include "rest_server_internal.h" + +#include +#include + +#include "esp_log.h" +#include "domain.h" + +static const char *TAG = "rest_print"; + +static const char *trace_id_or_default(const char *trace_id) { + return (trace_id != NULL && trace_id[0] != '\0') ? trace_id : "-"; +} + +esp_err_t rest_server_print_submit_raster_job_and_reply(httpd_req_t *req, + const uint8_t *raw, + size_t raw_len, + uint16_t width, + uint16_t height, + const char *density, + bool direct_ignore_precheck, + const char *warning, + const char *trace_id) { + const char *trace = trace_id_or_default(trace_id); + ESP_LOGI(TAG, + "print submit start, trace=%s, raster_bytes=%u, size=%ux%u, density=%s, ignore_precheck=%d", + trace, + (unsigned)raw_len, + (unsigned)width, + (unsigned)height, + density != NULL ? density : "中等", + direct_ignore_precheck); + + char submit_err[128] = {0}; + uint32_t job_id = 0; + printer_print_options_t options = { + .direct_ignore_precheck = direct_ignore_precheck, + }; + esp_err_t submit_rc = printer_protocol_submit_raster_job_ex(raw, + raw_len, + width, + height, + density, + &options, + &job_id, + submit_err, + sizeof(submit_err)); + if (submit_rc != ESP_OK) { + ESP_LOGW(TAG, + "print submit failed, trace=%s, rc=0x%x, msg=%s", + trace, + (unsigned)submit_rc, + submit_err[0] != '\0' ? submit_err : "submit failed"); + return rest_server_send_error(req, + "400 Bad Request", + submit_err[0] != '\0' ? submit_err : "submit failed"); + } + + ESP_LOGI(TAG, "print submit done, trace=%s, job_id=%u", trace, (unsigned)job_id); + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddNumberToObject(root, "job_id", job_id); + cJSON_AddStringToObject(root, "state", "queued"); + cJSON_AddBoolToObject(root, "ignore_precheck", direct_ignore_precheck); + if (warning != NULL && warning[0] != '\0') { + cJSON_AddStringToObject(root, "warning", warning); + } + + esp_err_t err = rest_server_send_json(req, "202 Accepted", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_print_raster_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + cJSON *json = cJSON_Parse(body); + free(body); + if (json == NULL) { + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + + cJSON *jwidth = cJSON_GetObjectItemCaseSensitive(json, "width"); + cJSON *jheight = cJSON_GetObjectItemCaseSensitive(json, "height"); + cJSON *jencoding = cJSON_GetObjectItemCaseSensitive(json, "encoding"); + cJSON *jdata = cJSON_GetObjectItemCaseSensitive(json, "data"); + cJSON *jdensity = cJSON_GetObjectItemCaseSensitive(json, "density"); + cJSON *jignore = cJSON_GetObjectItemCaseSensitive(json, "ignore_precheck"); + + if (!cJSON_IsNumber(jwidth) || !cJSON_IsNumber(jheight) || + !cJSON_IsString(jencoding) || !cJSON_IsString(jdata)) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "missing required fields"); + } + + if (strcmp(jencoding->valuestring, "base64_msb_1bpp") != 0) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "unsupported encoding"); + } + + const char *density = (cJSON_IsString(jdensity) && jdensity->valuestring != NULL) + ? jdensity->valuestring + : "中等"; + bool direct_ignore_precheck = rest_server_json_bool_with_default(jignore, false); + + uint8_t *raw = NULL; + size_t raw_len = 0; + esp_err_t b64_rc = rest_server_base64_decode_alloc(jdata->valuestring, &raw, &raw_len); + if (b64_rc != ESP_OK) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "invalid base64"); + } + + esp_err_t submit_rc = rest_server_print_submit_raster_job_and_reply(req, + raw, + raw_len, + (uint16_t)jwidth->valuedouble, + (uint16_t)jheight->valuedouble, + density, + direct_ignore_precheck, + NULL, + NULL); + free(raw); + cJSON_Delete(json); + return submit_rc; +} diff --git a/components/control_plane/src/rest_server_print_image.c b/components/control_plane/src/rest_server_print_image.c new file mode 100644 index 0000000..a28a6ef --- /dev/null +++ b/components/control_plane/src/rest_server_print_image.c @@ -0,0 +1,661 @@ +#include "rest_server_internal.h" + +#include +#include +#include +#include + +#include "esp_log.h" +#include "domain.h" + +static const char *TAG = "rest_print"; +#define SCREEN_PREVIEW_TIMEOUT_MS 200 + +typedef struct { + bool scale_to_width; + uint8_t threshold; + bool invert; + uint16_t max_height; + const char *density; + bool direct_ignore_precheck; +} image_print_options_t; + +typedef struct { + char prompt[801]; + char size[24]; + bool has_size; + bool prompt_extend; + bool has_seed; + uint32_t seed; + uint32_t timeout_ms; + uint32_t fetch_timeout_ms; +} image_generate_options_t; + +static bool parse_bool_text(const char *text, bool *out_value) { + if (text == NULL || out_value == NULL) { + return false; + } + if (strcmp(text, "1") == 0 || strcasecmp(text, "true") == 0 || strcasecmp(text, "yes") == 0) { + *out_value = true; + return true; + } + if (strcmp(text, "0") == 0 || strcasecmp(text, "false") == 0 || strcasecmp(text, "no") == 0) { + *out_value = false; + return true; + } + return false; +} + +static const char *map_density_text(const char *text) { + if (text == NULL || text[0] == '\0') { + return "中等"; + } + if (strcasecmp(text, "light") == 0 || strcmp(text, "较淡") == 0) { + return "较淡"; + } + if (strcasecmp(text, "medium") == 0 || strcmp(text, "中等") == 0) { + return "中等"; + } + if (strcasecmp(text, "dark") == 0 || strcmp(text, "较浓") == 0) { + return "较浓"; + } + if (strcasecmp(text, "max") == 0 || strcmp(text, "最深") == 0) { + return "最深"; + } + return "中等"; +} + +static void parse_image_upload_options(httpd_req_t *req, + bool *out_scale_to_width, + uint8_t *out_threshold, + bool *out_invert, + uint16_t *out_max_height, + const char **out_density, + bool *out_direct_ignore_precheck) { + if (out_scale_to_width != NULL) { + *out_scale_to_width = true; + } + if (out_threshold != NULL) { + *out_threshold = 160; + } + if (out_invert != NULL) { + *out_invert = false; + } + if (out_max_height != NULL) { + *out_max_height = 2200; + } + if (out_density != NULL) { + *out_density = "中等"; + } + if (out_direct_ignore_precheck != NULL) { + *out_direct_ignore_precheck = false; + } + + if (req == NULL) { + return; + } + + int qlen = httpd_req_get_url_query_len(req); + if (qlen <= 0 || qlen > 512) { + return; + } + + char *query = (char *)calloc(1, (size_t)qlen + 1); + if (query == NULL) { + return; + } + + if (httpd_req_get_url_query_str(req, query, (size_t)qlen + 1) != ESP_OK) { + free(query); + return; + } + + char value[48] = {0}; + if (httpd_query_key_value(query, "scale_to_width", value, sizeof(value)) == ESP_OK && + out_scale_to_width != NULL) { + bool parsed = *out_scale_to_width; + if (parse_bool_text(value, &parsed)) { + *out_scale_to_width = parsed; + } + } + + if (httpd_query_key_value(query, "invert", value, sizeof(value)) == ESP_OK && out_invert != NULL) { + bool parsed = *out_invert; + if (parse_bool_text(value, &parsed)) { + *out_invert = parsed; + } + } + + if (httpd_query_key_value(query, "threshold", value, sizeof(value)) == ESP_OK && out_threshold != NULL) { + long v = strtol(value, NULL, 10); + if (v >= 0 && v <= 255) { + *out_threshold = (uint8_t)v; + } + } + + if (httpd_query_key_value(query, "max_height", value, sizeof(value)) == ESP_OK && out_max_height != NULL) { + long v = strtol(value, NULL, 10); + if (v >= 64 && v <= 3000) { + *out_max_height = (uint16_t)v; + } + } + + if (httpd_query_key_value(query, "density", value, sizeof(value)) == ESP_OK && out_density != NULL) { + *out_density = map_density_text(value); + } + + if (httpd_query_key_value(query, "ignore_precheck", value, sizeof(value)) == ESP_OK && + out_direct_ignore_precheck != NULL) { + bool parsed = *out_direct_ignore_precheck; + if (parse_bool_text(value, &parsed)) { + *out_direct_ignore_precheck = parsed; + } + } + + free(query); +} + +static void image_print_options_set_defaults(image_print_options_t *out) { + if (out == NULL) { + return; + } + out->scale_to_width = true; + out->threshold = 160; + out->invert = false; + out->max_height = 2200; + out->density = "中等"; + out->direct_ignore_precheck = false; +} + +static void parse_image_print_options_from_json(cJSON *json, image_print_options_t *out) { + if (json == NULL || out == NULL) { + return; + } + + cJSON *jscale = cJSON_GetObjectItemCaseSensitive(json, "scale_to_width"); + cJSON *jthreshold = cJSON_GetObjectItemCaseSensitive(json, "threshold"); + cJSON *jinvert = cJSON_GetObjectItemCaseSensitive(json, "invert"); + cJSON *jmaxh = cJSON_GetObjectItemCaseSensitive(json, "max_height"); + cJSON *jdensity = cJSON_GetObjectItemCaseSensitive(json, "density"); + cJSON *jignore = cJSON_GetObjectItemCaseSensitive(json, "ignore_precheck"); + + out->scale_to_width = rest_server_json_bool_with_default(jscale, out->scale_to_width); + out->invert = rest_server_json_bool_with_default(jinvert, out->invert); + if (cJSON_IsNumber(jthreshold) && jthreshold->valuedouble >= 0 && jthreshold->valuedouble <= 255) { + out->threshold = (uint8_t)jthreshold->valuedouble; + } + if (cJSON_IsNumber(jmaxh) && jmaxh->valuedouble >= 64 && jmaxh->valuedouble <= 3000) { + out->max_height = (uint16_t)jmaxh->valuedouble; + } + if (cJSON_IsString(jdensity) && jdensity->valuestring != NULL) { + out->density = map_density_text(jdensity->valuestring); + } + out->direct_ignore_precheck = rest_server_json_bool_with_default(jignore, out->direct_ignore_precheck); +} + +static bool parse_image_size_text(const char *text, uint32_t *out_w, uint32_t *out_h) { + if (text == NULL || out_w == NULL || out_h == NULL) { + return false; + } + + const char *sep = strchr(text, '*'); + if (sep == NULL || sep == text || *(sep + 1) == '\0') { + return false; + } + + char wbuf[12] = {0}; + char hbuf[12] = {0}; + size_t wlen = (size_t)(sep - text); + size_t hlen = strlen(sep + 1); + if (wlen == 0 || hlen == 0 || wlen >= sizeof(wbuf) || hlen >= sizeof(hbuf)) { + return false; + } + + memcpy(wbuf, text, wlen); + memcpy(hbuf, sep + 1, hlen); + wbuf[wlen] = '\0'; + hbuf[hlen] = '\0'; + + char *wend = NULL; + char *hend = NULL; + long w = strtol(wbuf, &wend, 10); + long h = strtol(hbuf, &hend, 10); + if (wend == wbuf || hend == hbuf || *wend != '\0' || *hend != '\0') { + return false; + } + if (w <= 0 || h <= 0) { + return false; + } + + *out_w = (uint32_t)w; + *out_h = (uint32_t)h; + return true; +} + +static bool parse_image_generation_options(cJSON *json, + image_generate_options_t *out, + char *err, + size_t err_len) { + if (json == NULL || out == NULL) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid args"); + } + return false; + } + + memset(out, 0, sizeof(*out)); + out->timeout_ms = domain_policy_image_generation_timeout_default_ms(); + out->fetch_timeout_ms = domain_policy_image_download_timeout_default_ms(); + + cJSON *jprompt = cJSON_GetObjectItemCaseSensitive(json, "prompt"); + if (!cJSON_IsString(jprompt) || jprompt->valuestring == NULL || jprompt->valuestring[0] == '\0') { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "prompt is required"); + } + return false; + } + + size_t prompt_len = strlen(jprompt->valuestring); + if (prompt_len > 800) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "prompt too long, max 800 chars"); + } + return false; + } + strlcpy(out->prompt, jprompt->valuestring, sizeof(out->prompt)); + + cJSON *jsize = cJSON_GetObjectItemCaseSensitive(json, "size"); + if (cJSON_IsString(jsize) && jsize->valuestring != NULL && jsize->valuestring[0] != '\0') { + uint32_t w = 0; + uint32_t h = 0; + if (!parse_image_size_text(jsize->valuestring, &w, &h)) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "size must be like 1120*1440"); + } + return false; + } + + uint64_t pixels = (uint64_t)w * (uint64_t)h; + if (pixels < (512ULL * 512ULL) || pixels > (2048ULL * 2048ULL)) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "size pixels must be in [512*512, 2048*2048]"); + } + return false; + } + + out->has_size = true; + strlcpy(out->size, jsize->valuestring, sizeof(out->size)); + } + + cJSON *jextend = cJSON_GetObjectItemCaseSensitive(json, "prompt_extend"); + out->prompt_extend = rest_server_json_bool_with_default(jextend, false); + + cJSON *jseed = cJSON_GetObjectItemCaseSensitive(json, "seed"); + if (cJSON_IsNumber(jseed)) { + if (jseed->valuedouble < 0 || jseed->valuedouble > 2147483647.0) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "seed must be in [0, 2147483647]"); + } + return false; + } + out->has_seed = true; + out->seed = (uint32_t)jseed->valuedouble; + } + + cJSON *jtimeout = cJSON_GetObjectItemCaseSensitive(json, "timeout_ms"); + if (cJSON_IsNumber(jtimeout)) { + if (jtimeout->valuedouble < domain_policy_image_generation_timeout_min_ms() || + jtimeout->valuedouble > domain_policy_image_generation_timeout_max_ms()) { + if (err != NULL && err_len > 0) { + snprintf(err, + err_len, + "timeout_ms must be %u..%u", + (unsigned)domain_policy_image_generation_timeout_min_ms(), + (unsigned)domain_policy_image_generation_timeout_max_ms()); + } + return false; + } + out->timeout_ms = (uint32_t)jtimeout->valuedouble; + } + + cJSON *jfetch_timeout = cJSON_GetObjectItemCaseSensitive(json, "fetch_timeout_ms"); + if (cJSON_IsNumber(jfetch_timeout)) { + if (jfetch_timeout->valuedouble < domain_policy_image_download_timeout_min_ms() || + jfetch_timeout->valuedouble > domain_policy_image_download_timeout_max_ms()) { + if (err != NULL && err_len > 0) { + snprintf(err, + err_len, + "fetch_timeout_ms must be %u..%u", + (unsigned)domain_policy_image_download_timeout_min_ms(), + (unsigned)domain_policy_image_download_timeout_max_ms()); + } + return false; + } + out->fetch_timeout_ms = (uint32_t)jfetch_timeout->valuedouble; + } + + return true; +} + +static esp_err_t rest_server_send_image_generation_error(httpd_req_t *req, + esp_err_t rc, + const char *msg) { + const char *message = (msg != NULL && msg[0] != '\0') ? msg : "image generation failed"; + if (rc == ESP_ERR_INVALID_ARG) { + return rest_server_send_error(req, "400 Bad Request", message); + } + if (rc == ESP_ERR_INVALID_STATE) { + return rest_server_send_error(req, "503 Service Unavailable", message); + } + if (rc == ESP_ERR_TIMEOUT) { + return rest_server_send_error(req, "504 Gateway Timeout", message); + } + if (rc == ESP_ERR_NO_MEM) { + return rest_server_send_error(req, "500 Internal Server Error", message); + } + return rest_server_send_error(req, "502 Bad Gateway", message); +} + +static void rest_server_try_preview_raster(const char *source, + const uint8_t *raster, + size_t raster_len, + uint16_t width, + uint16_t height) { + if (raster == NULL || raster_len == 0 || width == 0 || height == 0) { + return; + } + + char preview_err[96] = {0}; + esp_err_t preview_rc = screen_preview_show_raster(raster, + raster_len, + width, + height, + SCREEN_PREVIEW_TIMEOUT_MS, + preview_err, + sizeof(preview_err)); + if (preview_rc == ESP_OK) { + ESP_LOGI(TAG, + "screen preview updated (%s), size=%ux%u", + source != NULL ? source : "unknown", + (unsigned)width, + (unsigned)height); + return; + } + if (preview_rc == ESP_ERR_NOT_SUPPORTED) { + return; + } + + ESP_LOGW(TAG, + "screen preview skipped (%s), rc=0x%x, msg=%s", + source != NULL ? source : "unknown", + (unsigned)preview_rc, + preview_err[0] != '\0' ? preview_err : "preview failed"); +} + +static esp_err_t rest_server_print_image_binary(httpd_req_t *req) { + bool scale_to_width = true; + uint8_t threshold = 160; + bool invert = false; + uint16_t max_height = 2200; + const char *density = "中等"; + bool direct_ignore_precheck = false; + parse_image_upload_options(req, + &scale_to_width, + &threshold, + &invert, + &max_height, + &density, + &direct_ignore_precheck); + + ESP_LOGI(TAG, + "image process start (upload), png_bytes=%u, scale_to_width=%d, threshold=%u, invert=%d, max_height=%u, density=%s, ignore_precheck=%d", + (unsigned)req->content_len, + scale_to_width, + (unsigned)threshold, + invert, + (unsigned)max_height, + density != NULL ? density : "中等", + direct_ignore_precheck); + + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + uint16_t width = 0; + uint16_t height = 0; + uint8_t *raster = NULL; + size_t raster_len = 0; + char decode_err[128] = {0}; + esp_err_t decode_rc = raster_tools_convert_png_to_raster_384((const uint8_t *)body, + (size_t)req->content_len, + scale_to_width, + threshold, + invert, + max_height, + &width, + &height, + &raster, + &raster_len, + decode_err, + sizeof(decode_err)); + free(body); + if (decode_rc != ESP_OK) { + ESP_LOGW(TAG, + "image process failed (upload), rc=0x%x, msg=%s", + (unsigned)decode_rc, + decode_err[0] != '\0' ? decode_err : "decode png failed"); + return rest_server_send_error(req, + "400 Bad Request", + decode_err[0] != '\0' ? decode_err : "decode png failed"); + } + + ESP_LOGI(TAG, + "image process done (upload), raster_size=%ux%u, raster_bytes=%u", + (unsigned)width, + (unsigned)height, + (unsigned)raster_len); + rest_server_try_preview_raster("upload", raster, raster_len, width, height); + + esp_err_t submit_rc = rest_server_print_submit_raster_job_and_reply(req, + raster, + raster_len, + width, + height, + density, + direct_ignore_precheck, + NULL, + NULL); + free(raster); + return submit_rc; +} + +static esp_err_t rest_server_print_image_generate(httpd_req_t *req) { + char *body = NULL; + cJSON *json = NULL; + image_generation_result_t *gen_result = NULL; + uint8_t *raster = NULL; + char request_id[80] = {0}; + printer_status_poll_pause_token_t status_poll_pause_token = 0; + esp_err_t ret = ESP_FAIL; + + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + json = cJSON_Parse(body); + free(body); + body = NULL; + if (json == NULL) { + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + + image_generate_options_t gen_opt = {0}; + char parse_err[128] = {0}; + if (!parse_image_generation_options(json, &gen_opt, parse_err, sizeof(parse_err))) { + ret = rest_server_send_error(req, + "400 Bad Request", + parse_err[0] != '\0' ? parse_err : "invalid image options"); + goto cleanup; + } + + image_print_options_t print_opt = {0}; + image_print_options_set_defaults(&print_opt); + parse_image_print_options_from_json(json, &print_opt); + cJSON_Delete(json); + json = NULL; + + ESP_LOGI(TAG, + "image generate start, prompt_len=%u, size=%s, prompt_extend=%d, seed=%s, timeout_ms=%u, fetch_timeout_ms=%u", + (unsigned)strlen(gen_opt.prompt), + gen_opt.has_size ? gen_opt.size : "default", + gen_opt.prompt_extend, + gen_opt.has_seed ? "set" : "auto", + (unsigned)gen_opt.timeout_ms, + (unsigned)gen_opt.fetch_timeout_ms); + ESP_LOGI(TAG, + "image process config, scale_to_width=%d, threshold=%u, invert=%d, max_height=%u, density=%s, ignore_precheck=%d", + print_opt.scale_to_width, + (unsigned)print_opt.threshold, + print_opt.invert, + (unsigned)print_opt.max_height, + print_opt.density != NULL ? print_opt.density : "中等", + print_opt.direct_ignore_precheck); + + image_generation_request_t gen_req = { + .prompt = gen_opt.prompt, + .size = gen_opt.has_size ? gen_opt.size : NULL, + .prompt_extend = gen_opt.prompt_extend, + .has_seed = gen_opt.has_seed, + .seed = gen_opt.seed, + .generation_timeout_ms = gen_opt.timeout_ms, + .download_timeout_ms = gen_opt.fetch_timeout_ms, + }; + + gen_result = (image_generation_result_t *)calloc(1, sizeof(*gen_result)); + if (gen_result == NULL) { + ret = rest_server_send_error(req, "500 Internal Server Error", "no memory"); + goto cleanup; + } + image_generation_result_reset(gen_result); + + char model_err[160] = {0}; + status_poll_pause_token = printer_protocol_status_poll_pause_acquire(); + esp_err_t gen_rc = image_generation_generate_png(&gen_req, + gen_result, + model_err, + sizeof(model_err)); + printer_protocol_status_poll_pause_release(&status_poll_pause_token); + if (gen_rc != ESP_OK) { + ESP_LOGW(TAG, + "image generate failed, rc=0x%x, msg=%s", + (unsigned)gen_rc, + model_err[0] != '\0' ? model_err : "image generation failed"); + ret = rest_server_send_image_generation_error(req, gen_rc, model_err); + goto cleanup; + } + + if (gen_result->request_id[0] != '\0') { + strlcpy(request_id, gen_result->request_id, sizeof(request_id)); + } + + ESP_LOGI(TAG, + "image generate done, request_id=%s, model_size=%ux%u, png_bytes=%u", + request_id[0] != '\0' ? request_id : "-", + (unsigned)gen_result->width, + (unsigned)gen_result->height, + (unsigned)gen_result->png_len); + + uint16_t width = 0; + uint16_t height = 0; + size_t raster_len = 0; + char decode_err[128] = {0}; + esp_err_t decode_rc = raster_tools_convert_png_to_raster_384(gen_result->png, + gen_result->png_len, + print_opt.scale_to_width, + print_opt.threshold, + print_opt.invert, + print_opt.max_height, + &width, + &height, + &raster, + &raster_len, + decode_err, + sizeof(decode_err)); + if (decode_rc != ESP_OK) { + ESP_LOGW(TAG, + "image process failed (generated), rc=0x%x, msg=%s", + (unsigned)decode_rc, + decode_err[0] != '\0' ? decode_err : "decode generated image failed"); + ret = rest_server_send_error(req, + "400 Bad Request", + decode_err[0] != '\0' ? decode_err : "decode generated image failed"); + goto cleanup; + } + + ESP_LOGI(TAG, + "image process done (generated), raster_size=%ux%u, raster_bytes=%u", + (unsigned)width, + (unsigned)height, + (unsigned)raster_len); + rest_server_try_preview_raster("generated", raster, raster_len, width, height); + + char warning[160] = {0}; + if (request_id[0] != '\0') { + snprintf(warning, sizeof(warning), "z-image request_id=%s", request_id); + } + + ret = rest_server_print_submit_raster_job_and_reply(req, + raster, + raster_len, + width, + height, + print_opt.density, + print_opt.direct_ignore_precheck, + warning, + request_id); + +cleanup: + printer_protocol_status_poll_pause_release(&status_poll_pause_token); + if (body != NULL) { + free(body); + } + if (json != NULL) { + cJSON_Delete(json); + } + if (raster != NULL) { + free(raster); + } + if (gen_result != NULL) { + image_generation_result_free(gen_result); + free(gen_result); + } + printer_runtime_status_t runtime = {0}; + printer_protocol_get_runtime_status(&runtime); + if (!runtime.busy) { + image_generation_schedule_prewarm(); + } + return ret; +} + +esp_err_t rest_server_print_image_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + char content_type[96] = {0}; + if (httpd_req_get_hdr_value_str(req, "Content-Type", content_type, sizeof(content_type)) == ESP_OK) { + if (strstr(content_type, "application/json") != NULL) { + return rest_server_print_image_generate(req); + } + if (strstr(content_type, "image/png") == NULL) { + return rest_server_send_error(req, + "415 Unsupported Media Type", + "Content-Type must be application/json or image/png"); + } + } + + return rest_server_print_image_binary(req); +} diff --git a/components/control_plane/src/rest_server_print_render.c b/components/control_plane/src/rest_server_print_render.c new file mode 100644 index 0000000..cab1d82 --- /dev/null +++ b/components/control_plane/src/rest_server_print_render.c @@ -0,0 +1,540 @@ +#include "rest_server_internal.h" + +#include +#include +#include +#include + +#include "domain.h" + +static esp_err_t decode_image_json_to_raster(cJSON *json, + uint16_t *out_width, + uint16_t *out_height, + uint8_t **out_raster, + size_t *out_len, + char *err, + size_t err_len) { + if (json == NULL || out_width == NULL || out_height == NULL || out_raster == NULL || out_len == NULL) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid args"); + } + return ESP_ERR_INVALID_ARG; + } + + *out_width = 0; + *out_height = 0; + *out_raster = NULL; + *out_len = 0; + + cJSON *jwidth = cJSON_GetObjectItemCaseSensitive(json, "width"); + cJSON *jheight = cJSON_GetObjectItemCaseSensitive(json, "height"); + cJSON *jencoding = cJSON_GetObjectItemCaseSensitive(json, "encoding"); + cJSON *jdata = cJSON_GetObjectItemCaseSensitive(json, "data"); + + if (!cJSON_IsNumber(jwidth) || !cJSON_IsNumber(jheight) || + !cJSON_IsString(jencoding) || !cJSON_IsString(jdata)) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "missing required image fields"); + } + return ESP_ERR_INVALID_ARG; + } + + uint16_t width = (uint16_t)jwidth->valuedouble; + uint16_t height = (uint16_t)jheight->valuedouble; + if (width == 0 || height == 0) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid image size"); + } + return ESP_ERR_INVALID_ARG; + } + + if (strcmp(jencoding->valuestring, "base64_msb_1bpp") == 0) { + uint8_t *raw = NULL; + size_t raw_len = 0; + esp_err_t b64_rc = rest_server_base64_decode_alloc(jdata->valuestring, &raw, &raw_len); + if (b64_rc != ESP_OK) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid base64"); + } + return ESP_ERR_INVALID_ARG; + } + *out_width = width; + *out_height = height; + *out_raster = raw; + *out_len = raw_len; + return ESP_OK; + } + + if (strcmp(jencoding->valuestring, "base64_gray8") == 0) { + uint8_t *gray = NULL; + size_t gray_len = 0; + esp_err_t b64_rc = rest_server_base64_decode_alloc(jdata->valuestring, &gray, &gray_len); + if (b64_rc != ESP_OK) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid base64"); + } + return ESP_ERR_INVALID_ARG; + } + + size_t expected = (size_t)width * height; + if (gray_len != expected) { + free(gray); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "gray8 size mismatch"); + } + return ESP_ERR_INVALID_SIZE; + } + + cJSON *jscale = cJSON_GetObjectItemCaseSensitive(json, "scale_to_width"); + cJSON *jthreshold = cJSON_GetObjectItemCaseSensitive(json, "threshold"); + cJSON *jinvert = cJSON_GetObjectItemCaseSensitive(json, "invert"); + cJSON *jmaxh = cJSON_GetObjectItemCaseSensitive(json, "max_height"); + + bool scale_to_width = rest_server_json_bool_with_default(jscale, true); + bool invert = rest_server_json_bool_with_default(jinvert, false); + uint8_t threshold = 160; + uint16_t max_height = 2200; + if (cJSON_IsNumber(jthreshold) && jthreshold->valuedouble >= 0 && jthreshold->valuedouble <= 255) { + threshold = (uint8_t)jthreshold->valuedouble; + } + if (cJSON_IsNumber(jmaxh) && jmaxh->valuedouble >= 64 && jmaxh->valuedouble <= 3000) { + max_height = (uint16_t)jmaxh->valuedouble; + } + + uint16_t out_w = 0; + uint16_t out_h = 0; + uint8_t *raster = NULL; + size_t raster_len = 0; + esp_err_t conv_rc = raster_tools_convert_gray8_to_raster_384(gray, + width, + height, + scale_to_width, + threshold, + invert, + max_height, + &out_w, + &out_h, + &raster, + &raster_len, + err, + err_len); + free(gray); + if (conv_rc != ESP_OK) { + return conv_rc; + } + + *out_width = out_w; + *out_height = out_h; + *out_raster = raster; + *out_len = raster_len; + return ESP_OK; + } + + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "unsupported encoding"); + } + return ESP_ERR_NOT_SUPPORTED; +} + +esp_err_t rest_server_print_qr_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + cJSON *json = cJSON_Parse(body); + free(body); + if (json == NULL) { + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + + cJSON *jtext = cJSON_GetObjectItemCaseSensitive(json, "text"); + cJSON *jdensity = cJSON_GetObjectItemCaseSensitive(json, "density"); + cJSON *jecc = cJSON_GetObjectItemCaseSensitive(json, "ecc"); + cJSON *jscale = cJSON_GetObjectItemCaseSensitive(json, "module_scale"); + cJSON *jmargin = cJSON_GetObjectItemCaseSensitive(json, "margin_modules"); + cJSON *jmaxh = cJSON_GetObjectItemCaseSensitive(json, "max_height"); + cJSON *jignore = cJSON_GetObjectItemCaseSensitive(json, "ignore_precheck"); + + if (!cJSON_IsString(jtext) || jtext->valuestring == NULL || jtext->valuestring[0] == '\0') { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "text is required"); + } + + const char *density = (cJSON_IsString(jdensity) && jdensity->valuestring != NULL) + ? jdensity->valuestring + : "中等"; + bool direct_ignore_precheck = rest_server_json_bool_with_default(jignore, false); + + uint8_t ecc_level = 3; /* default H */ + if (cJSON_IsString(jecc) && jecc->valuestring != NULL) { + if (strcasecmp(jecc->valuestring, "L") == 0) { + ecc_level = 0; + } else if (strcasecmp(jecc->valuestring, "M") == 0) { + ecc_level = 1; + } else if (strcasecmp(jecc->valuestring, "Q") == 0) { + ecc_level = 2; + } else if (strcasecmp(jecc->valuestring, "H") == 0) { + ecc_level = 3; + } + } + + uint8_t module_scale = 0; + uint8_t margin_modules = 2; + uint16_t max_height = 2200; + if (cJSON_IsNumber(jscale) && jscale->valuedouble >= 0 && jscale->valuedouble <= 16) { + module_scale = (uint8_t)jscale->valuedouble; + } + if (cJSON_IsNumber(jmargin) && jmargin->valuedouble >= 1 && jmargin->valuedouble <= 12) { + margin_modules = (uint8_t)jmargin->valuedouble; + } + if (cJSON_IsNumber(jmaxh) && jmaxh->valuedouble >= 64 && jmaxh->valuedouble <= 3000) { + max_height = (uint16_t)jmaxh->valuedouble; + } + + uint16_t width = 0; + uint16_t height = 0; + uint8_t *raster = NULL; + size_t raster_len = 0; + char render_msg[128] = {0}; + esp_err_t qr_rc = raster_tools_render_qr_384(jtext->valuestring, + module_scale, + margin_modules, + ecc_level, + max_height, + &width, + &height, + &raster, + &raster_len, + render_msg, + sizeof(render_msg)); + cJSON_Delete(json); + if (qr_rc != ESP_OK) { + return rest_server_send_error(req, + "400 Bad Request", + render_msg[0] != '\0' ? render_msg : "qr render failed"); + } + + esp_err_t submit_rc = rest_server_print_submit_raster_job_and_reply(req, + raster, + raster_len, + width, + height, + density, + direct_ignore_precheck, + NULL, + NULL); + free(raster); + return submit_rc; +} + +esp_err_t rest_server_print_label_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + cJSON *json = cJSON_Parse(body); + free(body); + if (json == NULL) { + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + + cJSON *jdensity = cJSON_GetObjectItemCaseSensitive(json, "density"); + cJSON *jgap = cJSON_GetObjectItemCaseSensitive(json, "gap_move_before"); + cJSON *joffset = cJSON_GetObjectItemCaseSensitive(json, "offset_tenths_mm"); + cJSON *jignore = cJSON_GetObjectItemCaseSensitive(json, "ignore_precheck"); + + bool gap_move_before = rest_server_json_bool_with_default(jgap, true); + bool has_offset = cJSON_IsNumber(joffset); + uint8_t offset_value = 0; + if (has_offset) { + if (joffset->valuedouble < 0 || joffset->valuedouble > 254) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "offset_tenths_mm must be 0..254"); + } + offset_value = (uint8_t)joffset->valuedouble; + } + + const char *density = (cJSON_IsString(jdensity) && jdensity->valuestring != NULL) + ? jdensity->valuestring + : "中等"; + bool direct_ignore_precheck = rest_server_json_bool_with_default(jignore, false); + + uint16_t width = 0; + uint16_t height = 0; + uint8_t *raster = NULL; + size_t raster_len = 0; + char decode_err[128] = {0}; + esp_err_t decode_rc = decode_image_json_to_raster(json, + &width, + &height, + &raster, + &raster_len, + decode_err, + sizeof(decode_err)); + cJSON_Delete(json); + if (decode_rc != ESP_OK) { + return rest_server_send_error(req, + "400 Bad Request", + decode_err[0] != '\0' ? decode_err : "decode image failed"); + } + + if (has_offset) { + char cmd_err[128] = {0}; + esp_err_t offset_rc = printer_protocol_set_label_offset(offset_value, + domain_policy_rest_label_timeout_ms(), + cmd_err, + sizeof(cmd_err)); + if (offset_rc != ESP_OK) { + free(raster); + return rest_server_send_error(req, + "409 Conflict", + cmd_err[0] != '\0' ? cmd_err : "set label offset failed"); + } + } + + if (gap_move_before) { + char cmd_err[128] = {0}; + esp_err_t gap_rc = printer_protocol_gap_move(domain_policy_rest_label_timeout_ms(), + cmd_err, + sizeof(cmd_err)); + if (gap_rc != ESP_OK) { + free(raster); + return rest_server_send_error(req, + "409 Conflict", + cmd_err[0] != '\0' ? cmd_err : "gap move failed"); + } + } + + esp_err_t submit_rc = rest_server_print_submit_raster_job_and_reply(req, + raster, + raster_len, + width, + height, + density, + direct_ignore_precheck, + NULL, + NULL); + free(raster); + return submit_rc; +} + +esp_err_t rest_server_print_text_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + cJSON *json = cJSON_Parse(body); + free(body); + if (json == NULL) { + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + + cJSON *jtext = cJSON_GetObjectItemCaseSensitive(json, "text"); + cJSON *jdensity = cJSON_GetObjectItemCaseSensitive(json, "density"); + cJSON *jscale = cJSON_GetObjectItemCaseSensitive(json, "scale"); + cJSON *jline = cJSON_GetObjectItemCaseSensitive(json, "line_spacing"); + cJSON *jmaxh = cJSON_GetObjectItemCaseSensitive(json, "max_height"); + cJSON *jignore = cJSON_GetObjectItemCaseSensitive(json, "ignore_precheck"); + + if (!cJSON_IsString(jtext) || jtext->valuestring == NULL) { + cJSON_Delete(json); + return rest_server_send_error(req, "400 Bad Request", "text is required"); + } + + const char *density = (cJSON_IsString(jdensity) && jdensity->valuestring != NULL) + ? jdensity->valuestring + : "中等"; + bool direct_ignore_precheck = rest_server_json_bool_with_default(jignore, false); + uint8_t scale = 2; + uint8_t line_spacing = 2; + uint16_t max_height = 2000; + + if (cJSON_IsNumber(jscale) && jscale->valuedouble >= 1 && jscale->valuedouble <= 6) { + scale = (uint8_t)jscale->valuedouble; + } + if (cJSON_IsNumber(jline) && jline->valuedouble >= 0 && jline->valuedouble <= 12) { + line_spacing = (uint8_t)jline->valuedouble; + } + if (cJSON_IsNumber(jmaxh) && jmaxh->valuedouble >= 64 && jmaxh->valuedouble <= 3000) { + max_height = (uint16_t)jmaxh->valuedouble; + } + + uint16_t width = 0; + uint16_t height = 0; + uint8_t *raster = NULL; + size_t raster_len = 0; + char render_msg[128] = {0}; + + esp_err_t render_rc = raster_tools_render_text_384(jtext->valuestring, + scale, + line_spacing, + max_height, + &width, + &height, + &raster, + &raster_len, + render_msg, + sizeof(render_msg)); + cJSON_Delete(json); + + if (render_rc != ESP_OK) { + return rest_server_send_error(req, + "400 Bad Request", + render_msg[0] != '\0' ? render_msg : "text render failed"); + } + + esp_err_t submit_rc = rest_server_print_submit_raster_job_and_reply(req, + raster, + raster_len, + width, + height, + density, + direct_ignore_precheck, + render_msg, + NULL); + free(raster); + return submit_rc; +} + +esp_err_t rest_server_print_receipt_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + cJSON *json = cJSON_Parse(body); + free(body); + if (json == NULL) { + return rest_server_send_error(req, "400 Bad Request", "invalid json"); + } + + cJSON *jtitle = cJSON_GetObjectItemCaseSensitive(json, "title"); + cJSON *jitems = cJSON_GetObjectItemCaseSensitive(json, "items"); + cJSON *jfooter = cJSON_GetObjectItemCaseSensitive(json, "footer"); + cJSON *jdensity = cJSON_GetObjectItemCaseSensitive(json, "density"); + cJSON *jscale = cJSON_GetObjectItemCaseSensitive(json, "scale"); + cJSON *jignore = cJSON_GetObjectItemCaseSensitive(json, "ignore_precheck"); + + const char *title = (cJSON_IsString(jtitle) && jtitle->valuestring != NULL) + ? jtitle->valuestring + : "TQ RECEIPT"; + const char *footer = (cJSON_IsString(jfooter) && jfooter->valuestring != NULL) + ? jfooter->valuestring + : "Thank you"; + const char *density = (cJSON_IsString(jdensity) && jdensity->valuestring != NULL) + ? jdensity->valuestring + : "中等"; + bool direct_ignore_precheck = rest_server_json_bool_with_default(jignore, false); + uint8_t scale = 2; + if (cJSON_IsNumber(jscale) && jscale->valuedouble >= 1 && jscale->valuedouble <= 4) { + scale = (uint8_t)jscale->valuedouble; + } + + size_t text_cap = 8192; + char *text = (char *)calloc(1, text_cap); + if (text == NULL) { + cJSON_Delete(json); + return rest_server_send_error(req, "500 Internal Server Error", "no memory"); + } + + size_t off = 0; + rest_server_appendf(text, text_cap, &off, "%s\n", title); + rest_server_appendf(text, text_cap, &off, "--------------------------------\n"); + + double total = 0.0; + if (cJSON_IsArray(jitems)) { + int n = cJSON_GetArraySize(jitems); + for (int i = 0; i < n; ++i) { + cJSON *it = cJSON_GetArrayItem(jitems, i); + if (!cJSON_IsObject(it)) { + continue; + } + + cJSON *jname = cJSON_GetObjectItemCaseSensitive(it, "name"); + cJSON *jqty = cJSON_GetObjectItemCaseSensitive(it, "qty"); + cJSON *jprice = cJSON_GetObjectItemCaseSensitive(it, "price"); + + const char *name = (cJSON_IsString(jname) && jname->valuestring != NULL) + ? jname->valuestring + : "item"; + int qty = cJSON_IsNumber(jqty) ? (int)jqty->valuedouble : 1; + double price = cJSON_IsNumber(jprice) ? jprice->valuedouble : 0.0; + double line_total = qty * price; + total += line_total; + + rest_server_appendf(text, + text_cap, + &off, + "%-16.16s x%-3d %8.2f\n", + name, + qty, + line_total); + } + } else { + rest_server_appendf(text, text_cap, &off, "(no items)\n"); + } + + rest_server_appendf(text, text_cap, &off, "--------------------------------\n"); + rest_server_appendf(text, text_cap, &off, "TOTAL: %.2f\n", total); + rest_server_appendf(text, text_cap, &off, "%s\n", footer); + + uint16_t width = 0; + uint16_t height = 0; + uint8_t *raster = NULL; + size_t raster_len = 0; + char render_msg[128] = {0}; + + esp_err_t render_rc = raster_tools_render_text_384(text, + scale, + 2, + 2200, + &width, + &height, + &raster, + &raster_len, + render_msg, + sizeof(render_msg)); + free(text); + cJSON_Delete(json); + + if (render_rc != ESP_OK) { + return rest_server_send_error(req, + "400 Bad Request", + render_msg[0] != '\0' ? render_msg : "receipt render failed"); + } + + esp_err_t submit_rc = rest_server_print_submit_raster_job_and_reply(req, + raster, + raster_len, + width, + height, + density, + direct_ignore_precheck, + render_msg, + NULL); + free(raster); + return submit_rc; +} diff --git a/components/control_plane/src/rest_server_voice.c b/components/control_plane/src/rest_server_voice.c new file mode 100644 index 0000000..5e62b73 --- /dev/null +++ b/components/control_plane/src/rest_server_voice.c @@ -0,0 +1,213 @@ +#include "rest_server_internal.h" + +#include + +#include "domain.h" + +#define VOICE_CONTROL_TIMEOUT_MIN_MS 500 +#define VOICE_CONTROL_TIMEOUT_MAX_MS 30000 + +static esp_err_t rest_server_parse_voice_timeout(httpd_req_t *req, uint32_t *out_timeout_ms) { + if (out_timeout_ms == NULL) { + return ESP_ERR_INVALID_ARG; + } + *out_timeout_ms = 0; + if (req->content_len <= 0) { + return ESP_OK; + } + + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return body_err; + } + + cJSON *json = cJSON_Parse(body); + free(body); + if (json == NULL) { + return ESP_ERR_INVALID_ARG; + } + + cJSON *jtimeout = cJSON_GetObjectItemCaseSensitive(json, "timeout_ms"); + if (jtimeout != NULL) { + if (!cJSON_IsNumber(jtimeout) || + jtimeout->valuedouble < VOICE_CONTROL_TIMEOUT_MIN_MS || + jtimeout->valuedouble > VOICE_CONTROL_TIMEOUT_MAX_MS) { + cJSON_Delete(json); + return ESP_ERR_INVALID_ARG; + } + *out_timeout_ms = (uint32_t)jtimeout->valuedouble; + } + + cJSON_Delete(json); + return ESP_OK; +} + +static void rest_server_add_voice_status(cJSON *root) { + voice_interaction_status_t st = {0}; + voice_interaction_get_status(&st); + printer_runtime_status_t printer = {0}; + printer_protocol_get_runtime_status(&printer); + + cJSON_AddBoolToObject(root, "voice_ready", st.ready); + cJSON_AddBoolToObject(root, "voice_session_active", st.session_active); + cJSON_AddBoolToObject(root, "voice_ws_connected", st.ws_connected); + cJSON_AddBoolToObject(root, "voice_started", st.started); + cJSON_AddBoolToObject(root, "voice_tap_active", st.tap_active); + cJSON_AddBoolToObject(root, "voice_marker_image_gen_running", st.marker_image_gen_running); + cJSON_AddStringToObject(root, + "voice_dialog_state", + voice_interaction_dialog_state_str(st.dialog_state)); + cJSON_AddStringToObject(root, "voice_task_id", st.task_id); + cJSON_AddStringToObject(root, "voice_dialog_id", st.dialog_id); + cJSON_AddStringToObject(root, "voice_last_error", st.last_error); + cJSON_AddNumberToObject(root, "voice_last_event_ms", (double)st.last_event_ms); + cJSON_AddNumberToObject(root, "voice_upstream_packets", (double)st.upstream_packets); + cJSON_AddNumberToObject(root, "voice_downstream_packets", (double)st.downstream_packets); + cJSON_AddBoolToObject(root, "printer_busy", printer.busy); + cJSON_AddNumberToObject(root, "queue_depth", (double)printer.queue_depth); +} + +esp_err_t rest_server_voice_status_get(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + rest_server_add_voice_status(root); + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_voice_session_start_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + uint32_t timeout_ms = 0; + if (rest_server_parse_voice_timeout(req, &timeout_ms) != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + char start_err[128] = {0}; + esp_err_t rc = voice_interaction_start_with_timeout(timeout_ms, start_err, sizeof(start_err)); + if (rc != ESP_OK) { + if (rc == ESP_ERR_TIMEOUT) { + return rest_server_send_error(req, + "504 Gateway Timeout", + start_err[0] != '\0' ? start_err : "voice session start timeout"); + } + return rest_server_send_error(req, + "409 Conflict", + start_err[0] != '\0' ? start_err : "voice session start failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddStringToObject(root, "message", "voice session started"); + rest_server_add_voice_status(root); + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_voice_session_stop_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + uint32_t timeout_ms = 0; + if (rest_server_parse_voice_timeout(req, &timeout_ms) != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + + char stop_err[128] = {0}; + esp_err_t rc = voice_interaction_stop_with_timeout(timeout_ms, stop_err, sizeof(stop_err)); + if (rc != ESP_OK) { + if (rc == ESP_ERR_TIMEOUT) { + return rest_server_send_error(req, + "504 Gateway Timeout", + stop_err[0] != '\0' ? stop_err : "voice session stop timeout"); + } + return rest_server_send_error(req, + "409 Conflict", + stop_err[0] != '\0' ? stop_err : "voice session stop failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddStringToObject(root, "message", "voice session stopped"); + rest_server_add_voice_status(root); + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_voice_tap_start_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + if (req->content_len > 0) { + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + free(body); + } + + char tap_err[128] = {0}; + esp_err_t rc = voice_interaction_tap_start(tap_err, sizeof(tap_err)); + if (rc != ESP_OK) { + return rest_server_send_error(req, + "409 Conflict", + tap_err[0] != '\0' ? tap_err : "tap start failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddStringToObject(root, "message", "tap started"); + rest_server_add_voice_status(root); + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} + +esp_err_t rest_server_voice_tap_cancel_post(httpd_req_t *req) { + if (!rest_server_auth_ok(req)) { + return rest_server_send_error(req, "401 Unauthorized", "unauthorized"); + } + + if (req->content_len > 0) { + char *body = NULL; + esp_err_t body_err = rest_server_read_body(req, &body); + if (body_err != ESP_OK) { + return rest_server_send_error(req, "400 Bad Request", "invalid request body"); + } + free(body); + } + + char tap_err[128] = {0}; + esp_err_t rc = voice_interaction_tap_cancel(tap_err, sizeof(tap_err)); + if (rc != ESP_OK) { + return rest_server_send_error(req, + "409 Conflict", + tap_err[0] != '\0' ? tap_err : "tap cancel failed"); + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddBoolToObject(root, "ok", true); + cJSON_AddStringToObject(root, "message", "tap canceled"); + rest_server_add_voice_status(root); + + esp_err_t err = rest_server_send_json(req, "200 OK", root); + cJSON_Delete(root); + return err; +} diff --git a/components/domain/CMakeLists.txt b/components/domain/CMakeLists.txt new file mode 100644 index 0000000..e2eafac --- /dev/null +++ b/components/domain/CMakeLists.txt @@ -0,0 +1,37 @@ +idf_component_register( + SRCS + "src/printer_protocol.c" + "src/printer_protocol_jobs.c" + "src/printer_protocol_worker.c" + "src/printer_protocol_commands.c" + "src/raster_tools.c" + "src/raster_tools_image_qr.c" + "src/image_generation.c" + "src/screen_preview.c" + "src/system_runtime.c" + "src/domain_runtime_contracts.c" + "src/voice_interaction.c" + "src/voice_interaction_common.c" + "src/voice_interaction_ws.c" + "src/voice_interaction_ws_protocol.c" + "src/voice_interaction_ws_audio.c" + "src/voice_interaction_marker.c" + "src/voice_interaction_tasks.c" + "third_party/qrcodegen.c" + INCLUDE_DIRS + "include" + PRIV_INCLUDE_DIRS + "internal" + "third_party" + REQUIRES + platform + esp_http_client + esp_websocket_client + espressif__libpng + json + mbedtls + esp_coex + EMBED_FILES + "assets/fonts/cn16_index.bin" + "assets/fonts/cn16_glyphs.bin" +) diff --git a/components/domain/README.md b/components/domain/README.md new file mode 100644 index 0000000..e16ed16 --- /dev/null +++ b/components/domain/README.md @@ -0,0 +1,9 @@ +# Domain Layer + +Purpose: business capabilities with single responsibility per domain service. + +Rules: +- Expose stable domain APIs in `include/`. +- Keep implementation details in `internal/` and `src/`. +- No direct cross-domain internal state access. +- Cross-domain interactions must use events or Port interfaces. diff --git a/components/domain/assets/fonts/cn16_glyphs.bin b/components/domain/assets/fonts/cn16_glyphs.bin new file mode 100644 index 0000000..631b943 Binary files /dev/null and b/components/domain/assets/fonts/cn16_glyphs.bin differ diff --git a/components/domain/assets/fonts/cn16_index.bin b/components/domain/assets/fonts/cn16_index.bin new file mode 100644 index 0000000..2638f0d Binary files /dev/null and b/components/domain/assets/fonts/cn16_index.bin differ diff --git a/components/domain/include/.gitkeep b/components/domain/include/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/domain/include/domain.h b/components/domain/include/domain.h new file mode 100644 index 0000000..ed79e28 --- /dev/null +++ b/components/domain/include/domain.h @@ -0,0 +1,381 @@ +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ---------- system_runtime ---------- +esp_err_t system_runtime_start(void); +esp_err_t system_runtime_shutdown(void); +esp_err_t system_runtime_bootstrap(void); + +bool system_runtime_wifi_ready(void); +void system_runtime_get_ip(char *buf, size_t buf_len); + +// ---------- domain_policy ---------- +uint32_t domain_policy_lifecycle_start_retry_count(void); +uint32_t domain_policy_lifecycle_retry_backoff_ms(uint32_t attempt); +bool domain_policy_is_retryable_error(esp_err_t err); + +uint32_t domain_policy_printer_stop_timeout_ms(void); + +uint32_t domain_policy_rest_printer_connect_timeout_ms(void); +uint32_t domain_policy_rest_printer_connect_timeout_min_ms(void); +uint32_t domain_policy_rest_printer_connect_timeout_max_ms(void); + +uint32_t domain_policy_rest_label_timeout_ms(void); +uint32_t domain_policy_rest_label_timeout_min_ms(void); +uint32_t domain_policy_rest_label_timeout_max_ms(void); + +uint32_t domain_policy_rest_ota_timeout_ms(void); +uint32_t domain_policy_rest_ota_timeout_min_ms(void); +uint32_t domain_policy_rest_ota_timeout_max_ms(void); + +uint32_t domain_policy_image_generation_timeout_default_ms(void); +uint32_t domain_policy_image_generation_timeout_min_ms(void); +uint32_t domain_policy_image_generation_timeout_max_ms(void); + +uint32_t domain_policy_image_download_timeout_default_ms(void); +uint32_t domain_policy_image_download_timeout_min_ms(void); +uint32_t domain_policy_image_download_timeout_max_ms(void); + +// ---------- domain_diagnostics ---------- +typedef enum { + DOMAIN_DIAG_COUNTER_LIFECYCLE_START_ATTEMPT = 0, + DOMAIN_DIAG_COUNTER_LIFECYCLE_START_SUCCESS, + DOMAIN_DIAG_COUNTER_LIFECYCLE_START_FAILED, + DOMAIN_DIAG_COUNTER_LIFECYCLE_START_RETRY, + DOMAIN_DIAG_COUNTER_LIFECYCLE_STOP_ATTEMPT, + DOMAIN_DIAG_COUNTER_LIFECYCLE_STOP_SUCCESS, + DOMAIN_DIAG_COUNTER_LIFECYCLE_STOP_FAILED, + DOMAIN_DIAG_COUNTER_WIFI_CONNECT_SUCCESS, + DOMAIN_DIAG_COUNTER_WIFI_CONNECT_FAILED, + DOMAIN_DIAG_COUNTER_WIFI_CONNECT_TIMEOUT, + DOMAIN_DIAG_COUNTER_PRINTER_JOB_SUBMITTED, + DOMAIN_DIAG_COUNTER_PRINTER_JOB_SUCCESS, + DOMAIN_DIAG_COUNTER_PRINTER_JOB_FAILED, + DOMAIN_DIAG_COUNTER_PRINTER_JOB_CANCELED, + DOMAIN_DIAG_COUNTER_IMAGE_GENERATE_ATTEMPT, + DOMAIN_DIAG_COUNTER_IMAGE_GENERATE_SUCCESS, + DOMAIN_DIAG_COUNTER_IMAGE_GENERATE_FAILED, + DOMAIN_DIAG_COUNTER_IMAGE_GENERATE_TIMEOUT, + DOMAIN_DIAG_COUNTER_REST_RESPONSES_TOTAL, + DOMAIN_DIAG_COUNTER_REST_ERRORS_TOTAL, + DOMAIN_DIAG_COUNTER_MAX, +} domain_diag_counter_t; + +typedef enum { + DOMAIN_DIAG_GAUGE_LIFECYCLE_STATE = 0, + DOMAIN_DIAG_GAUGE_STATUS_POLL_PAUSE_DEPTH, + DOMAIN_DIAG_GAUGE_PRINTER_QUEUE_DEPTH, + DOMAIN_DIAG_GAUGE_MAX, +} domain_diag_gauge_t; + +typedef struct { + uint64_t counters[DOMAIN_DIAG_COUNTER_MAX]; + int32_t gauges[DOMAIN_DIAG_GAUGE_MAX]; + int64_t last_error_ms; + esp_err_t last_error_code; + char last_error_source[32]; + char last_error_message[96]; +} domain_diag_snapshot_t; + +void domain_diag_counter_add(domain_diag_counter_t counter, uint32_t delta); +void domain_diag_set_gauge(domain_diag_gauge_t gauge, int32_t value); +void domain_diag_record_error(const char *source, esp_err_t code, const char *message); +void domain_diag_get_snapshot(domain_diag_snapshot_t *out_snapshot); +const char *domain_diag_counter_name(domain_diag_counter_t counter); +const char *domain_diag_gauge_name(domain_diag_gauge_t gauge); + +// ---------- raster_tools ---------- +esp_err_t raster_tools_render_text_384(const char *text, + uint8_t scale, + uint8_t line_spacing, + uint16_t max_height, + uint16_t *out_width, + uint16_t *out_height, + uint8_t **out_raster, + size_t *out_len, + char *err, + size_t err_len); + +esp_err_t raster_tools_convert_gray8_to_raster_384(const uint8_t *gray, + uint16_t src_width, + uint16_t src_height, + bool scale_to_width, + uint8_t threshold, + bool invert, + uint16_t max_height, + uint16_t *out_width, + uint16_t *out_height, + uint8_t **out_raster, + size_t *out_len, + char *err, + size_t err_len); + +esp_err_t raster_tools_convert_png_to_raster_384(const uint8_t *png, + size_t png_len, + bool scale_to_width, + uint8_t threshold, + bool invert, + uint16_t max_height, + uint16_t *out_width, + uint16_t *out_height, + uint8_t **out_raster, + size_t *out_len, + char *err, + size_t err_len); + +esp_err_t raster_tools_render_qr_384(const char *text, + uint8_t module_scale, + uint8_t margin_modules, + uint8_t ecc_level, + uint16_t max_height, + uint16_t *out_width, + uint16_t *out_height, + uint8_t **out_raster, + size_t *out_len, + char *err, + size_t err_len); + +// ---------- image_generation ---------- +typedef struct { + const char *prompt; + const char *size; + bool prompt_extend; + bool has_seed; + uint32_t seed; + uint32_t generation_timeout_ms; + uint32_t download_timeout_ms; +} image_generation_request_t; + +typedef struct { + uint8_t *png; + size_t png_len; + char image_url[1024]; + char output_prompt[256]; + char request_id[80]; + uint16_t width; + uint16_t height; +} image_generation_result_t; + +void image_generation_result_reset(image_generation_result_t *result); +void image_generation_result_free(image_generation_result_t *result); +void image_generation_schedule_prewarm(void); + +esp_err_t image_generation_generate_png(const image_generation_request_t *req, + image_generation_result_t *out_result, + char *err, + size_t err_len); + +// ---------- screen_preview ---------- +esp_err_t screen_preview_show_raster(const uint8_t *raster, + size_t raster_len, + uint16_t width, + uint16_t height, + uint32_t timeout_ms, + char *err, + size_t err_len); +esp_err_t screen_preview_show_png(const uint8_t *png, + size_t png_len, + uint32_t timeout_ms, + char *err, + size_t err_len); + +// ---------- printer_protocol ---------- +typedef enum { + PRINT_JOB_STATE_NONE = 0, + PRINT_JOB_STATE_QUEUED, + PRINT_JOB_STATE_RUNNING, + PRINT_JOB_STATE_SUCCESS, + PRINT_JOB_STATE_FAILED, + PRINT_JOB_STATE_CANCELED, +} print_job_state_t; + +typedef struct { + uint32_t id; + print_job_state_t state; + uint8_t progress; + uint16_t width; + uint16_t height; + size_t data_len; + char density[16]; + char error[96]; + int64_t created_ms; + int64_t started_ms; + int64_t finished_ms; +} print_job_info_t; + +typedef struct { + uint8_t major; + uint8_t minor; + uint8_t patch; +} printer_ota_version_t; + +typedef enum { + PRINTER_BACKEND_BLE = 0, + PRINTER_BACKEND_DIRECT = 1, +} printer_backend_t; + +typedef struct { + printer_backend_t backend; + const char *name; + uint32_t timeout_ms; +} printer_connect_options_t; + +typedef struct { + // Direct backend only: bypass paper/temp/battery precheck for this print job. + bool direct_ignore_precheck; +} printer_print_options_t; + +typedef struct { + uint16_t shift_clock_high_us; + uint16_t shift_clock_low_us; + uint16_t latch_pulse_us; + bool strobe_active_high; + bool boost_active_high; + uint16_t override_strobe_on_us; + uint16_t override_strobe_interval_us; + uint16_t override_motor_step_us; + uint8_t override_steps_per_line; +} printer_direct_debug_config_t; + +typedef struct { + bool supports_connect; + bool supports_gap_move; + bool supports_label_offset; + bool supports_ota; +} printer_capabilities_t; + +typedef struct { + printer_backend_t backend; + bool connected; + bool transport_ready; + bool notify_ready; + bool busy; + bool has_paper; + int8_t paper_gpio_level; + uint8_t paper_present_level; + uint8_t battery_percent; + float temperature; + uint16_t mtu; + bool supports_gap_move; + bool supports_label_offset; + bool supports_ota; + uint32_t queue_depth; + int64_t last_status_ms; +} printer_runtime_status_t; + +typedef uint32_t printer_status_poll_pause_token_t; + +esp_err_t printer_protocol_init(void); +esp_err_t printer_protocol_stop(uint32_t timeout_ms); + +esp_err_t printer_protocol_set_backend(printer_backend_t backend, char *err, size_t err_len); +printer_backend_t printer_protocol_get_backend(void); + +esp_err_t printer_protocol_connect(const char *target_name, uint32_t timeout_ms); +esp_err_t printer_protocol_connect_ex(const printer_connect_options_t *opt, char *err, size_t err_len); +void printer_protocol_disconnect(void); +void printer_protocol_get_capabilities(printer_capabilities_t *out_capabilities); +esp_err_t printer_protocol_get_direct_debug_config(printer_direct_debug_config_t *out_config, + char *err, + size_t err_len); +esp_err_t printer_protocol_set_direct_debug_config(const printer_direct_debug_config_t *config, + bool reset_defaults, + char *err, + size_t err_len); + +void printer_protocol_get_runtime_status(printer_runtime_status_t *out_status); +printer_status_poll_pause_token_t printer_protocol_status_poll_pause_acquire(void); +void printer_protocol_status_poll_pause_release(printer_status_poll_pause_token_t *token); + +esp_err_t printer_protocol_submit_raster_job(const uint8_t *raster, + size_t raster_len, + uint16_t width, + uint16_t height, + const char *density, + uint32_t *out_job_id, + char *err, + size_t err_len); +esp_err_t printer_protocol_submit_raster_job_ex(const uint8_t *raster, + size_t raster_len, + uint16_t width, + uint16_t height, + const char *density, + const printer_print_options_t *options, + uint32_t *out_job_id, + char *err, + size_t err_len); + +bool printer_protocol_get_job(uint32_t job_id, print_job_info_t *out_info); +esp_err_t printer_protocol_list_jobs(print_job_info_t *out_jobs, size_t max_jobs, size_t *out_count); +esp_err_t printer_protocol_cancel_job(uint32_t job_id, char *err, size_t err_len); +size_t printer_protocol_cleanup_jobs(bool include_success, bool include_failed, bool include_canceled); + +esp_err_t printer_protocol_gap_move(uint32_t timeout_ms, char *err, size_t err_len); +esp_err_t printer_protocol_get_label_offset(uint8_t *out_offset, uint32_t timeout_ms, char *err, size_t err_len); +esp_err_t printer_protocol_set_label_offset(uint8_t offset, uint32_t timeout_ms, char *err, size_t err_len); + +esp_err_t printer_protocol_ota_jump_boot(uint32_t timeout_ms, char *err, size_t err_len); +esp_err_t printer_protocol_ota_jump_app(uint32_t timeout_ms, char *err, size_t err_len); +esp_err_t printer_protocol_ota_erase_page(uint16_t page_num, uint32_t timeout_ms, char *err, size_t err_len); +esp_err_t printer_protocol_ota_write_frame(uint16_t packet_num, + bool is_last_frame, + const uint8_t *data, + size_t data_len, + uint32_t timeout_ms, + char *err, + size_t err_len); +esp_err_t printer_protocol_ota_get_version(printer_ota_version_t *out_version, + uint32_t timeout_ms, + char *err, + size_t err_len); + +const char *printer_protocol_job_state_str(print_job_state_t state); + +// ---------- voice_interaction ---------- +typedef enum { + VOICE_DIALOG_STATE_IDLE = 0, + VOICE_DIALOG_STATE_LISTENING, + VOICE_DIALOG_STATE_THINKING, + VOICE_DIALOG_STATE_RESPONDING, +} voice_dialog_state_t; + +typedef struct { + bool ready; + bool session_active; + bool ws_connected; + bool started; + bool tap_active; + bool marker_image_gen_running; + voice_dialog_state_t dialog_state; + char task_id[40]; + char dialog_id[40]; + char last_error[128]; + int64_t last_event_ms; + uint32_t upstream_packets; + uint32_t downstream_packets; +} voice_interaction_status_t; + +esp_err_t voice_interaction_init(void); +esp_err_t voice_interaction_start_with_timeout(uint32_t timeout_ms, char *err, size_t err_len); +esp_err_t voice_interaction_start(char *err, size_t err_len); +esp_err_t voice_interaction_stop_with_timeout(uint32_t timeout_ms, char *err, size_t err_len); +esp_err_t voice_interaction_stop(char *err, size_t err_len); + +esp_err_t voice_interaction_tap_start(char *err, size_t err_len); +esp_err_t voice_interaction_tap_cancel(char *err, size_t err_len); + +void voice_interaction_get_status(voice_interaction_status_t *out_status); +const char *voice_interaction_dialog_state_str(voice_dialog_state_t state); + +#ifdef __cplusplus +} +#endif diff --git a/components/domain/internal/.gitkeep b/components/domain/internal/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/domain/internal/printer_protocol_internal.h b/components/domain/internal/printer_protocol_internal.h new file mode 100644 index 0000000..211d765 --- /dev/null +++ b/components/domain/internal/printer_protocol_internal.h @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include + +#include "domain.h" +#include "esp_err.h" +#include "platform.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +#define PROTO_ADDR 0x01 + +#define CMD_POWER 0x00 +#define CMD_GET_STATUS 0x01 +#define CMD_SET_DISTANCE 0x02 +#define CMD_SET_PARAM 0x03 +#define CMD_SEND_DATA 0x04 +#define CMD_GAP_MOVE 0x05 +#define CMD_GET_OFFSET 0x06 +#define CMD_SET_OFFSET 0x07 +#define CMD_BOOT_JUMP_BOOT 0xA0 +#define CMD_BOOT_ERASE_PAGE 0xA1 +#define CMD_BOOT_WRITE_DATA 0xA2 +#define CMD_BOOT_JUMP_APP 0xA3 +#define CMD_BOOT_GET_VERSION 0xA4 + +#define EVT_ACK BIT0 +#define EVT_SHUTDOWN BIT1 +#define EVT_WORKER_EXITED BIT2 +#define EVT_STATUS_POLL_EXITED BIT3 + +#define JOB_QUEUE_LEN 8 +#define JOB_SLOT_MAX 16 +#define MAX_RASTER_BYTES (384 * 3000 / 8) +#define STATUS_POLL_PAUSE_SLOT_MAX 8 +#define PRINTER_JOB_SENTINEL_STOP 0u + +#define OTA_MAX_DATA_PER_FRAME 236u + +typedef struct { + bool used; + bool cancel_requested; + bool direct_ignore_precheck; + uint32_t id; + print_job_state_t state; + uint8_t progress; + uint16_t width; + uint16_t height; + size_t data_len; + char density[16]; + char error[96]; + int64_t created_ms; + int64_t started_ms; + int64_t finished_ms; + uint8_t *data; +} job_slot_t; + +typedef struct { + bool has_paper; + int8_t paper_gpio_level; + uint8_t paper_present_level; + uint8_t battery; + float temperature; + int64_t updated_ms; +} parsed_status_t; + +extern SemaphoreHandle_t s_mutex; +extern QueueHandle_t s_job_queue; +extern EventGroupHandle_t s_evt; +extern uint32_t s_busy_refcnt; +extern parsed_status_t s_status; +extern uint8_t s_last_rsp_cmd; +extern uint16_t s_last_rsp_payload_len; +extern uint8_t s_last_rsp_payload[252]; +extern uint32_t s_status_poll_pause_next_token; +extern uint32_t s_status_poll_pause_tokens[STATUS_POLL_PAUSE_SLOT_MAX]; +extern uint32_t s_next_job_id; +extern job_slot_t s_jobs[JOB_SLOT_MAX]; +extern TaskHandle_t s_worker_task; +extern TaskHandle_t s_status_poll_task_handle; +extern bool s_protocol_initialized; +extern bool s_protocol_stopping; + +bool printer_protocol_acquire_control_lane(uint32_t timeout_ms, char *err, size_t err_len); +void printer_protocol_release_control_lane(void); +esp_err_t printer_protocol_send_cmd_wait_response(uint8_t cmd, + const uint8_t *payload, + uint16_t payload_len, + bool with_checksum, + uint32_t timeout_ms, + bool expect_ack, + bool reset_transport_on_timeout, + uint8_t *out_payload, + size_t out_payload_cap, + uint16_t *out_payload_len, + char *err, + size_t err_len); +esp_err_t printer_protocol_send_frame(uint8_t cmd, + const uint8_t *payload, + uint16_t payload_len, + bool with_checksum); +bool printer_protocol_wait_response(uint8_t cmd, + uint8_t *out_payload, + size_t out_payload_cap, + uint16_t *out_payload_len, + uint32_t timeout_ms); +bool printer_protocol_send_cmd_with_ack(uint8_t cmd, + const uint8_t *payload, + uint16_t payload_len, + bool with_checksum, + uint32_t timeout_ms, + bool reset_transport_on_timeout); + +int printer_protocol_find_job_idx_locked(uint32_t id); +int printer_protocol_alloc_job_slot_locked(void); +bool printer_protocol_is_terminal_state(print_job_state_t state); + +void printer_protocol_worker_task(void *arg); +bool printer_protocol_is_stopping(void); diff --git a/components/domain/internal/voice_interaction_internal.h b/components/domain/internal/voice_interaction_internal.h new file mode 100644 index 0000000..7a2b478 --- /dev/null +++ b/components/domain/internal/voice_interaction_internal.h @@ -0,0 +1,134 @@ +#pragma once + +#include +#include +#include + +#include "esp_opus_dec.h" +#include "esp_opus_enc.h" +#include "esp_websocket_client.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "domain.h" +#include "platform.h" + +#define VOICE_STREAMING_MODE "duplex" +#define VOICE_TASK_GROUP "aigc" +#define VOICE_TASK_NAME "multimodal-generation" +#define VOICE_FUNCTION_NAME "generation" +#define VOICE_MODEL_NAME "multimodal-dialog" + +#define VOICE_SAMPLE_BYTES 2 +#define VOICE_STATUS_LOCK_TIMEOUT_MS 1000 +#define VOICE_WS_SEND_TIMEOUT_MS 1000 +#define VOICE_WS_TASK_STACK 24576 +#define VOICE_UPLINK_TASK_STACK 32768 +#define VOICE_HEARTBEAT_TASK_STACK 4096 +#define VOICE_START_CONNECT_TIMEOUT_MS 8000 +#define VOICE_AUDIO_IO_TIMEOUT_MS 1200 +#define VOICE_AUDIO_DRAIN_MARGIN_MS 120 +#define VOICE_AUDIO_DRAIN_QUIET_MS 180 +#define VOICE_AUDIO_DRAIN_WAIT_MS 2200 +#define VOICE_AUDIO_DRAIN_WAIT_ON_STOP_MS 3500 + +typedef struct { + SemaphoreHandle_t lock; + esp_websocket_client_handle_t ws; + + TaskHandle_t uplink_task; + TaskHandle_t heartbeat_task; + bool uplink_task_with_caps; + bool heartbeat_task_with_caps; + + void *opus_enc; + void *opus_dec; + int opus_enc_in_size; + int opus_enc_out_size; + + uint8_t *opus_tx_buf; + int16_t *pcm_tx_buf; + + uint8_t *pcm_rx_buf; + size_t pcm_rx_buf_size; + + uint8_t *text_agg; + size_t text_agg_size; + + uint8_t *bin_agg; + size_t bin_agg_size; + + bool ready; + bool session_active; + bool ws_connected; + bool started; + bool tap_active; + bool stop_requested; + bool ws_low_stack_warned; + bool response_first_sentence_checked; + + voice_dialog_state_t dialog_state; + + char task_id[40]; + char dialog_id[40]; + char device_uuid[40]; + char last_error[128]; + + int64_t last_event_ms; + int64_t last_downstream_ms; + int64_t playback_deadline_ms; + uint32_t upstream_packets; + uint32_t downstream_packets; +} voice_context_t; + +extern voice_context_t s_voice; + +int64_t voice_now_ms(void); +void voice_fill_err(char *err, size_t err_len, const char *msg); +void voice_set_last_error_locked(const char *msg); +bool voice_lock(uint32_t timeout_ms); +void voice_unlock(void); +void voice_log_status_snapshot(const char *stage); +void voice_log_heap_snapshot(const char *stage); +void voice_wait_audio_playback_done(uint32_t timeout_ms, const char *stage); +void voice_close_audio_with_drain(uint32_t timeout_ms, const char *stage); + +void *voice_malloc_prefer_psram(size_t size); +void *voice_calloc_prefer_psram(size_t n, size_t size); +void *voice_realloc_prefer_psram(void *ptr, size_t size); + +BaseType_t voice_create_task_prefer_psram(TaskFunction_t task_fn, + const char *name, + uint32_t stack_size, + UBaseType_t priority, + TaskHandle_t *out_task, + bool *out_with_caps); +void voice_delete_task(TaskHandle_t task, bool with_caps); +void voice_delete_self_task(bool with_caps); + +void voice_make_uuid(char out[40]); +void voice_make_device_uuid(char out[40]); +const char *voice_safe_user_id(void); + +esp_err_t voice_setup_opus(char *err, size_t err_len); +esp_err_t voice_open_audio_for_dialog(char *err, size_t err_len); +void voice_release_runtime_resources(void); + +esp_err_t voice_send_directive(const char *action, + const char *directive, + bool with_dialog_id); + +void voice_ws_protocol_handle_text_message(const char *text, size_t len); +void voice_ws_audio_handle_downstream_packet(const uint8_t *data, size_t len); +void voice_marker_try_trigger_image_generation(const char *dialog_id, const char *final_text); +bool voice_marker_image_generation_is_running(void); + +void voice_websocket_event_handler(void *handler_args, + esp_event_base_t base, + int32_t event_id, + void *event_data); + +void voice_interaction_ws_init_cjson_hooks(void); + +void voice_uplink_task(void *arg); +void voice_heartbeat_task(void *arg); diff --git a/components/domain/src/.gitkeep b/components/domain/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/domain/src/domain_runtime_contracts.c b/components/domain/src/domain_runtime_contracts.c new file mode 100644 index 0000000..efe268e --- /dev/null +++ b/components/domain/src/domain_runtime_contracts.c @@ -0,0 +1,201 @@ +#include "domain.h" + +#include + +#include "platform.h" + +static runtime_diag_counter_t to_platform_counter(domain_diag_counter_t counter) { + switch (counter) { + case DOMAIN_DIAG_COUNTER_LIFECYCLE_START_ATTEMPT: + return RUNTIME_DIAG_COUNTER_LIFECYCLE_START_ATTEMPT; + case DOMAIN_DIAG_COUNTER_LIFECYCLE_START_SUCCESS: + return RUNTIME_DIAG_COUNTER_LIFECYCLE_START_SUCCESS; + case DOMAIN_DIAG_COUNTER_LIFECYCLE_START_FAILED: + return RUNTIME_DIAG_COUNTER_LIFECYCLE_START_FAILED; + case DOMAIN_DIAG_COUNTER_LIFECYCLE_START_RETRY: + return RUNTIME_DIAG_COUNTER_LIFECYCLE_START_RETRY; + case DOMAIN_DIAG_COUNTER_LIFECYCLE_STOP_ATTEMPT: + return RUNTIME_DIAG_COUNTER_LIFECYCLE_STOP_ATTEMPT; + case DOMAIN_DIAG_COUNTER_LIFECYCLE_STOP_SUCCESS: + return RUNTIME_DIAG_COUNTER_LIFECYCLE_STOP_SUCCESS; + case DOMAIN_DIAG_COUNTER_LIFECYCLE_STOP_FAILED: + return RUNTIME_DIAG_COUNTER_LIFECYCLE_STOP_FAILED; + case DOMAIN_DIAG_COUNTER_WIFI_CONNECT_SUCCESS: + return RUNTIME_DIAG_COUNTER_WIFI_CONNECT_SUCCESS; + case DOMAIN_DIAG_COUNTER_WIFI_CONNECT_FAILED: + return RUNTIME_DIAG_COUNTER_WIFI_CONNECT_FAILED; + case DOMAIN_DIAG_COUNTER_WIFI_CONNECT_TIMEOUT: + return RUNTIME_DIAG_COUNTER_WIFI_CONNECT_TIMEOUT; + case DOMAIN_DIAG_COUNTER_PRINTER_JOB_SUBMITTED: + return RUNTIME_DIAG_COUNTER_PRINTER_JOB_SUBMITTED; + case DOMAIN_DIAG_COUNTER_PRINTER_JOB_SUCCESS: + return RUNTIME_DIAG_COUNTER_PRINTER_JOB_SUCCESS; + case DOMAIN_DIAG_COUNTER_PRINTER_JOB_FAILED: + return RUNTIME_DIAG_COUNTER_PRINTER_JOB_FAILED; + case DOMAIN_DIAG_COUNTER_PRINTER_JOB_CANCELED: + return RUNTIME_DIAG_COUNTER_PRINTER_JOB_CANCELED; + case DOMAIN_DIAG_COUNTER_IMAGE_GENERATE_ATTEMPT: + return RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_ATTEMPT; + case DOMAIN_DIAG_COUNTER_IMAGE_GENERATE_SUCCESS: + return RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_SUCCESS; + case DOMAIN_DIAG_COUNTER_IMAGE_GENERATE_FAILED: + return RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_FAILED; + case DOMAIN_DIAG_COUNTER_IMAGE_GENERATE_TIMEOUT: + return RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_TIMEOUT; + case DOMAIN_DIAG_COUNTER_REST_RESPONSES_TOTAL: + return RUNTIME_DIAG_COUNTER_REST_RESPONSES_TOTAL; + case DOMAIN_DIAG_COUNTER_REST_ERRORS_TOTAL: + return RUNTIME_DIAG_COUNTER_REST_ERRORS_TOTAL; + default: + return RUNTIME_DIAG_COUNTER_MAX; + } +} + +static runtime_diag_gauge_t to_platform_gauge(domain_diag_gauge_t gauge) { + switch (gauge) { + case DOMAIN_DIAG_GAUGE_LIFECYCLE_STATE: + return RUNTIME_DIAG_GAUGE_LIFECYCLE_STATE; + case DOMAIN_DIAG_GAUGE_STATUS_POLL_PAUSE_DEPTH: + return RUNTIME_DIAG_GAUGE_STATUS_POLL_PAUSE_DEPTH; + case DOMAIN_DIAG_GAUGE_PRINTER_QUEUE_DEPTH: + return RUNTIME_DIAG_GAUGE_PRINTER_QUEUE_DEPTH; + default: + return RUNTIME_DIAG_GAUGE_MAX; + } +} + +uint32_t domain_policy_lifecycle_start_retry_count(void) { + return runtime_policy_lifecycle_start_retry_count(); +} + +uint32_t domain_policy_lifecycle_retry_backoff_ms(uint32_t attempt) { + return runtime_policy_lifecycle_retry_backoff_ms(attempt); +} + +bool domain_policy_is_retryable_error(esp_err_t err) { + return runtime_policy_is_retryable_error(err); +} + +uint32_t domain_policy_printer_stop_timeout_ms(void) { + return runtime_policy_printer_stop_timeout_ms(); +} + +uint32_t domain_policy_rest_printer_connect_timeout_ms(void) { + return runtime_policy_rest_printer_connect_timeout_ms(); +} + +uint32_t domain_policy_rest_printer_connect_timeout_min_ms(void) { + return 1000; +} + +uint32_t domain_policy_rest_printer_connect_timeout_max_ms(void) { + return 60000; +} + +uint32_t domain_policy_rest_label_timeout_ms(void) { + return runtime_policy_rest_label_timeout_ms(); +} + +uint32_t domain_policy_rest_label_timeout_min_ms(void) { + return 500; +} + +uint32_t domain_policy_rest_label_timeout_max_ms(void) { + return 30000; +} + +uint32_t domain_policy_rest_ota_timeout_ms(void) { + return runtime_policy_rest_ota_timeout_ms(); +} + +uint32_t domain_policy_rest_ota_timeout_min_ms(void) { + return 500; +} + +uint32_t domain_policy_rest_ota_timeout_max_ms(void) { + return 60000; +} + +uint32_t domain_policy_image_generation_timeout_default_ms(void) { + return runtime_policy_image_generation_timeout_default_ms(); +} + +uint32_t domain_policy_image_generation_timeout_min_ms(void) { + return runtime_policy_image_generation_timeout_min_ms(); +} + +uint32_t domain_policy_image_generation_timeout_max_ms(void) { + return runtime_policy_image_generation_timeout_max_ms(); +} + +uint32_t domain_policy_image_download_timeout_default_ms(void) { + return runtime_policy_image_download_timeout_default_ms(); +} + +uint32_t domain_policy_image_download_timeout_min_ms(void) { + return runtime_policy_image_download_timeout_min_ms(); +} + +uint32_t domain_policy_image_download_timeout_max_ms(void) { + return runtime_policy_image_download_timeout_max_ms(); +} + +void domain_diag_counter_add(domain_diag_counter_t counter, uint32_t delta) { + runtime_diag_counter_t mapped = to_platform_counter(counter); + if (mapped >= RUNTIME_DIAG_COUNTER_MAX) { + return; + } + runtime_diag_counter_add(mapped, delta); +} + +void domain_diag_set_gauge(domain_diag_gauge_t gauge, int32_t value) { + runtime_diag_gauge_t mapped = to_platform_gauge(gauge); + if (mapped >= RUNTIME_DIAG_GAUGE_MAX) { + return; + } + runtime_diag_set_gauge(mapped, value); +} + +void domain_diag_record_error(const char *source, esp_err_t code, const char *message) { + runtime_diag_record_error(source, code, message); +} + +void domain_diag_get_snapshot(domain_diag_snapshot_t *out_snapshot) { + if (out_snapshot == NULL) { + return; + } + + runtime_diag_snapshot_t platform_snapshot = {0}; + runtime_diag_get_snapshot(&platform_snapshot); + + for (int i = 0; i < DOMAIN_DIAG_COUNTER_MAX && i < RUNTIME_DIAG_COUNTER_MAX; ++i) { + out_snapshot->counters[i] = platform_snapshot.counters[i]; + } + for (int i = 0; i < DOMAIN_DIAG_GAUGE_MAX && i < RUNTIME_DIAG_GAUGE_MAX; ++i) { + out_snapshot->gauges[i] = platform_snapshot.gauges[i]; + } + out_snapshot->last_error_ms = platform_snapshot.last_error_ms; + out_snapshot->last_error_code = platform_snapshot.last_error_code; + strlcpy(out_snapshot->last_error_source, + platform_snapshot.last_error_source, + sizeof(out_snapshot->last_error_source)); + strlcpy(out_snapshot->last_error_message, + platform_snapshot.last_error_message, + sizeof(out_snapshot->last_error_message)); +} + +const char *domain_diag_counter_name(domain_diag_counter_t counter) { + runtime_diag_counter_t mapped = to_platform_counter(counter); + if (mapped >= RUNTIME_DIAG_COUNTER_MAX) { + return "unknown"; + } + return runtime_diag_counter_name(mapped); +} + +const char *domain_diag_gauge_name(domain_diag_gauge_t gauge) { + runtime_diag_gauge_t mapped = to_platform_gauge(gauge); + if (mapped >= RUNTIME_DIAG_GAUGE_MAX) { + return "unknown"; + } + return runtime_diag_gauge_name(mapped); +} diff --git a/components/domain/src/image_generation.c b/components/domain/src/image_generation.c new file mode 100644 index 0000000..449faaf --- /dev/null +++ b/components/domain/src/image_generation.c @@ -0,0 +1,962 @@ +#include "domain.h" +#include "platform.h" + +#include +#include +#include + +#include "cJSON.h" +#include "esp_coexist.h" +#include "esp_crt_bundle.h" +#include "esp_heap_caps.h" +#include "esp_http_client.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +#ifndef CONFIG_TQ_Z_IMAGE_API_ENDPOINT +#define CONFIG_TQ_Z_IMAGE_API_ENDPOINT "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" +#endif + +#ifndef CONFIG_TQ_Z_IMAGE_MODEL +#define CONFIG_TQ_Z_IMAGE_MODEL "z-image-turbo" +#endif + +#ifndef CONFIG_TQ_Z_IMAGE_DEFAULT_SIZE +#define CONFIG_TQ_Z_IMAGE_DEFAULT_SIZE "512*768" +#endif + +#ifndef CONFIG_TQ_Z_IMAGE_TIMEOUT_MS +#define CONFIG_TQ_Z_IMAGE_TIMEOUT_MS 45000 +#endif + +#ifndef CONFIG_TQ_Z_IMAGE_DOWNLOAD_TIMEOUT_MS +#define CONFIG_TQ_Z_IMAGE_DOWNLOAD_TIMEOUT_MS 15000 +#endif + +#ifndef CONFIG_TQ_Z_IMAGE_MAX_RESPONSE_BYTES +#define CONFIG_TQ_Z_IMAGE_MAX_RESPONSE_BYTES 65536 +#endif + +#ifndef CONFIG_TQ_Z_IMAGE_MAX_PNG_BYTES +#define CONFIG_TQ_Z_IMAGE_MAX_PNG_BYTES (4 * 1024 * 1024) +#endif + +#ifndef CONFIG_TQ_Z_IMAGE_HTTP_READ_CHUNK_BYTES +#define CONFIG_TQ_Z_IMAGE_HTTP_READ_CHUNK_BYTES 16384 +#endif + +#ifndef CONFIG_TQ_Z_IMAGE_HTTP_BUFFER_BYTES +#define CONFIG_TQ_Z_IMAGE_HTTP_BUFFER_BYTES 16384 +#endif + +#define IMAGE_HTTP_READ_CHUNK_BYTES ((size_t)CONFIG_TQ_Z_IMAGE_HTTP_READ_CHUNK_BYTES) +#define IMAGE_HTTP_BUFFER_BYTES CONFIG_TQ_Z_IMAGE_HTTP_BUFFER_BYTES +#define IMAGE_PREWARM_TIMEOUT_MS 2500 +#define IMAGE_PREWARM_STACK 5120 + +#if CONFIG_FREERTOS_UNICORE +#define IMAGE_WORKER_CORE_ID 0 +#else +#define IMAGE_WORKER_CORE_ID 1 +#endif + +static const char *TAG = "image_generation"; + +typedef struct { + char oss_origin[160]; +} image_prewarm_task_arg_t; + +typedef struct { + uint8_t *data; + size_t len; + size_t cap; + size_t max_len; +} bytes_buffer_t; + +static SemaphoreHandle_t s_prewarm_lock; +static bool s_prewarm_running; +static char s_last_oss_origin[160] = "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/"; + +static bool image_generation_boost_coex_for_download(void) { +#if CONFIG_ESP_COEX_SW_COEXIST_ENABLE && CONFIG_TQ_Z_IMAGE_COEX_PREFER_WIFI_DURING_DOWNLOAD + esp_err_t rc = esp_coex_preference_set(ESP_COEX_PREFER_WIFI); + if (rc != ESP_OK) { + ESP_LOGW(TAG, "failed to prefer Wi-Fi coexist mode, rc=0x%x", (unsigned)rc); + return false; + } + ESP_LOGI(TAG, "coexist prefer Wi-Fi enabled for image download"); + return true; +#else + return false; +#endif +} + +static void image_generation_restore_coex_after_download(bool boosted) { +#if CONFIG_ESP_COEX_SW_COEXIST_ENABLE && CONFIG_TQ_Z_IMAGE_COEX_PREFER_WIFI_DURING_DOWNLOAD + if (!boosted) { + return; + } + esp_err_t rc = esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); + if (rc != ESP_OK) { + ESP_LOGW(TAG, "failed to restore coexist balance mode, rc=0x%x", (unsigned)rc); + return; + } + ESP_LOGI(TAG, "coexist preference restored to balance"); +#else + (void)boosted; +#endif +} + +static void image_generation_fill_err(char *err, size_t err_len, const char *msg) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "%s", msg != NULL ? msg : "unknown error"); + } +} + +static void *malloc_prefer_psram(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 *realloc_prefer_psram(void *old, size_t size) { + if (old == NULL) { + return malloc_prefer_psram(size); + } + return realloc(old, size); +} + +static esp_err_t bytes_buffer_reserve(bytes_buffer_t *buf, size_t need_extra) { + if (buf == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (need_extra > SIZE_MAX - buf->len) { + return ESP_ERR_INVALID_SIZE; + } + size_t required = buf->len + need_extra; + if (required > buf->max_len) { + return ESP_ERR_INVALID_SIZE; + } + if (required <= buf->cap) { + return ESP_OK; + } + + size_t new_cap = buf->cap == 0 ? 1024 : buf->cap; + while (new_cap < required) { + if (new_cap > (SIZE_MAX / 2)) { + new_cap = required; + break; + } + new_cap *= 2; + } + + uint8_t *new_data = (uint8_t *)realloc_prefer_psram(buf->data, new_cap); + if (new_data == NULL) { + return ESP_ERR_NO_MEM; + } + buf->data = new_data; + buf->cap = new_cap; + return ESP_OK; +} + +static esp_err_t bytes_buffer_append(bytes_buffer_t *buf, const void *data, size_t len) { + if (buf == NULL || data == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (len == 0) { + return ESP_OK; + } + esp_err_t rc = bytes_buffer_reserve(buf, len); + if (rc != ESP_OK) { + return rc; + } + memcpy(buf->data + buf->len, data, len); + buf->len += len; + return ESP_OK; +} + +static void bytes_buffer_free(bytes_buffer_t *buf) { + if (buf == NULL) { + return; + } + free(buf->data); + buf->data = NULL; + buf->len = 0; + buf->cap = 0; +} + +static esp_err_t bytes_buffer_terminate(bytes_buffer_t *buf) { + if (buf == NULL) { + return ESP_ERR_INVALID_ARG; + } + esp_err_t rc = bytes_buffer_reserve(buf, 1); + if (rc != ESP_OK) { + return rc; + } + buf->data[buf->len] = '\0'; + return ESP_OK; +} + +static const char *image_generation_api_key(void) { + if (strlen(CONFIG_TQ_Z_IMAGE_API_KEY) > 0) { + return CONFIG_TQ_Z_IMAGE_API_KEY; + } + if (strlen(CONFIG_TQ_VOICE_API_KEY) > 0) { + return CONFIG_TQ_VOICE_API_KEY; + } + return NULL; +} + +static void prewarm_lock_init_if_needed(void) { + if (s_prewarm_lock != NULL) { + return; + } + s_prewarm_lock = xSemaphoreCreateMutex(); +} + +static bool url_extract_origin(const char *url, char *out_origin, size_t out_origin_len) { + if (url == NULL || out_origin == NULL || out_origin_len == 0) { + return false; + } + + const char *scheme = strstr(url, "://"); + if (scheme == NULL || scheme == url) { + return false; + } + + const char *host = scheme + 3; + if (*host == '\0') { + return false; + } + + const char *path = strchr(host, '/'); + size_t host_len = path != NULL ? (size_t)(path - host) : strlen(host); + if (host_len == 0) { + return false; + } + + int n = snprintf(out_origin, + out_origin_len, + "%.*s://%.*s/", + (int)(scheme - url), + url, + (int)host_len, + host); + return n > 0 && (size_t)n < out_origin_len; +} + +static void image_generation_remember_oss_origin(const char *image_url) { + char origin[160] = {0}; + if (!url_extract_origin(image_url, origin, sizeof(origin))) { + return; + } + + prewarm_lock_init_if_needed(); + if (s_prewarm_lock == NULL) { + return; + } + + if (xSemaphoreTake(s_prewarm_lock, pdMS_TO_TICKS(100)) != pdTRUE) { + return; + } + strlcpy(s_last_oss_origin, origin, sizeof(s_last_oss_origin)); + xSemaphoreGive(s_prewarm_lock); +} + +static void image_generation_copy_oss_origin(char *out_origin, size_t out_origin_len) { + if (out_origin == NULL || out_origin_len == 0) { + return; + } + out_origin[0] = '\0'; + + prewarm_lock_init_if_needed(); + if (s_prewarm_lock == NULL) { + strlcpy(out_origin, s_last_oss_origin, out_origin_len); + return; + } + + if (xSemaphoreTake(s_prewarm_lock, pdMS_TO_TICKS(100)) != pdTRUE) { + strlcpy(out_origin, s_last_oss_origin, out_origin_len); + return; + } + strlcpy(out_origin, s_last_oss_origin, out_origin_len); + xSemaphoreGive(s_prewarm_lock); +} + +static esp_err_t image_generation_http_prewarm_head(const char *url, + const char *auth_header, + uint32_t timeout_ms) { + if (url == NULL || url[0] == '\0') { + return ESP_ERR_INVALID_ARG; + } + + esp_http_client_config_t cfg = { + .url = url, + .method = HTTP_METHOD_HEAD, + .timeout_ms = (int)timeout_ms, + .keep_alive_enable = true, + .buffer_size = 1024, + .buffer_size_tx = 1024, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + + esp_http_client_handle_t client = esp_http_client_init(&cfg); + if (client == NULL) { + return ESP_FAIL; + } + + if (auth_header != NULL && auth_header[0] != '\0') { + esp_http_client_set_header(client, "Authorization", auth_header); + } + + esp_err_t rc = esp_http_client_open(client, 0); + if (rc == ESP_OK) { + (void)esp_http_client_fetch_headers(client); + esp_http_client_close(client); + } + esp_http_client_cleanup(client); + return rc; +} + +static void image_generation_prewarm_task(void *arg) { + image_prewarm_task_arg_t *task_arg = (image_prewarm_task_arg_t *)arg; + char oss_origin[160] = {0}; + if (task_arg != NULL) { + strlcpy(oss_origin, task_arg->oss_origin, sizeof(oss_origin)); + free(task_arg); + } + + int64_t start_ms = esp_timer_get_time() / 1000; + const char *api_key = image_generation_api_key(); + char auth_header[192] = {0}; + const char *auth = NULL; + if (api_key != NULL) { + snprintf(auth_header, sizeof(auth_header), "Bearer %s", api_key); + auth = auth_header; + } + + esp_err_t gen_rc = image_generation_http_prewarm_head(CONFIG_TQ_Z_IMAGE_API_ENDPOINT, + auth, + IMAGE_PREWARM_TIMEOUT_MS); + esp_err_t oss_rc = image_generation_http_prewarm_head(oss_origin, + NULL, + IMAGE_PREWARM_TIMEOUT_MS); + int64_t done_ms = esp_timer_get_time() / 1000; + + ESP_LOGI(TAG, + "https prewarm done, gen_rc=0x%x, oss_rc=0x%x, cost_ms=%lld", + (unsigned)gen_rc, + (unsigned)oss_rc, + (long long)(done_ms - start_ms)); + + prewarm_lock_init_if_needed(); + if (s_prewarm_lock != NULL && xSemaphoreTake(s_prewarm_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + s_prewarm_running = false; + xSemaphoreGive(s_prewarm_lock); + } else { + s_prewarm_running = false; + } + + vTaskDelete(NULL); +} + +void image_generation_schedule_prewarm(void) { + prewarm_lock_init_if_needed(); + if (s_prewarm_lock == NULL) { + return; + } + + if (xSemaphoreTake(s_prewarm_lock, pdMS_TO_TICKS(100)) != pdTRUE) { + return; + } + if (s_prewarm_running) { + xSemaphoreGive(s_prewarm_lock); + return; + } + s_prewarm_running = true; + xSemaphoreGive(s_prewarm_lock); + + image_prewarm_task_arg_t *arg = (image_prewarm_task_arg_t *)malloc_prefer_psram(sizeof(*arg)); + if (arg == NULL) { + if (xSemaphoreTake(s_prewarm_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + s_prewarm_running = false; + xSemaphoreGive(s_prewarm_lock); + } else { + s_prewarm_running = false; + } + return; + } + memset(arg, 0, sizeof(*arg)); + image_generation_copy_oss_origin(arg->oss_origin, sizeof(arg->oss_origin)); + + BaseType_t ok = xTaskCreatePinnedToCore(image_generation_prewarm_task, + "img_prewarm", + IMAGE_PREWARM_STACK, + arg, + 4, + NULL, + IMAGE_WORKER_CORE_ID); + if (ok != pdPASS) { + free(arg); + if (xSemaphoreTake(s_prewarm_lock, pdMS_TO_TICKS(100)) == pdTRUE) { + s_prewarm_running = false; + xSemaphoreGive(s_prewarm_lock); + } else { + s_prewarm_running = false; + } + ESP_LOGW(TAG, "failed to create prewarm task"); + } +} + +static esp_err_t http_read_full_response(esp_http_client_handle_t client, + bytes_buffer_t *out_body, + char *err, + size_t err_len) { + if (client == NULL || out_body == NULL) { + image_generation_fill_err(err, err_len, "invalid http read args"); + return ESP_ERR_INVALID_ARG; + } + + char *tmp = (char *)malloc_prefer_psram(IMAGE_HTTP_READ_CHUNK_BYTES); + if (tmp == NULL) { + image_generation_fill_err(err, err_len, "no memory for http read buffer"); + return ESP_ERR_NO_MEM; + } + + while (true) { + int n = esp_http_client_read(client, tmp, IMAGE_HTTP_READ_CHUNK_BYTES); + if (n < 0) { + free(tmp); + image_generation_fill_err(err, err_len, "http read failed"); + return ESP_FAIL; + } + if (n == 0) { + free(tmp); + if (esp_http_client_is_complete_data_received(client)) { + return ESP_OK; + } + image_generation_fill_err(err, err_len, "http response incomplete"); + return ESP_FAIL; + } + + esp_err_t append_rc = bytes_buffer_append(out_body, tmp, (size_t)n); + if (append_rc != ESP_OK) { + free(tmp); + image_generation_fill_err(err, err_len, "http response too large"); + return append_rc; + } + } +} + +static esp_err_t http_request_collect(const esp_http_client_config_t *config, + const char *auth_header, + const char *body, + int *out_status_code, + bytes_buffer_t *out_body, + char *err, + size_t err_len) { + if (config == NULL || out_body == NULL) { + image_generation_fill_err(err, err_len, "invalid http request args"); + return ESP_ERR_INVALID_ARG; + } + + esp_http_client_handle_t client = esp_http_client_init(config); + if (client == NULL) { + image_generation_fill_err(err, err_len, "http client init failed"); + return ESP_FAIL; + } + + if (auth_header != NULL) { + esp_http_client_set_header(client, "Authorization", auth_header); + } + if (body != NULL) { + esp_http_client_set_header(client, "Content-Type", "application/json"); + } + + size_t body_len = body != NULL ? strlen(body) : 0; + esp_err_t open_rc = esp_http_client_open(client, (int)body_len); + if (open_rc != ESP_OK) { + esp_http_client_cleanup(client); + image_generation_fill_err(err, err_len, "http open failed"); + return open_rc; + } + + if (body != NULL && body_len > 0) { + int written = esp_http_client_write(client, body, (int)body_len); + if (written < 0 || (size_t)written != body_len) { + esp_http_client_close(client); + esp_http_client_cleanup(client); + image_generation_fill_err(err, err_len, "http request write failed"); + return ESP_FAIL; + } + } + + int64_t content_len = esp_http_client_fetch_headers(client); + if (content_len > 0 && (size_t)content_len > out_body->max_len) { + esp_http_client_close(client); + esp_http_client_cleanup(client); + image_generation_fill_err(err, err_len, "http response too large"); + return ESP_ERR_INVALID_SIZE; + } + + if (content_len > 0) { + esp_err_t reserve_rc = bytes_buffer_reserve(out_body, (size_t)content_len); + if (reserve_rc != ESP_OK) { + esp_http_client_close(client); + esp_http_client_cleanup(client); + image_generation_fill_err(err, err_len, "no memory for http response"); + return reserve_rc; + } + } + if (out_status_code != NULL) { + *out_status_code = esp_http_client_get_status_code(client); + } + + esp_err_t read_rc = http_read_full_response(client, out_body, err, err_len); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return read_rc; +} + +static const char *json_get_string(cJSON *obj, const char *name) { + if (obj == NULL || name == NULL) { + return NULL; + } + cJSON *item = cJSON_GetObjectItemCaseSensitive(obj, name); + if (cJSON_IsString(item) && item->valuestring != NULL) { + return item->valuestring; + } + return NULL; +} + +static bool json_extract_image_url(cJSON *root, + char *out_url, + size_t out_url_len, + char *out_prompt, + size_t out_prompt_len) { + if (root == NULL || out_url == NULL || out_url_len == 0) { + return false; + } + + cJSON *output = cJSON_GetObjectItemCaseSensitive(root, "output"); + cJSON *choices = cJSON_GetObjectItemCaseSensitive(output, "choices"); + if (!cJSON_IsArray(choices)) { + return false; + } + + cJSON *choice = cJSON_GetArrayItem(choices, 0); + if (!cJSON_IsObject(choice)) { + return false; + } + + cJSON *message = cJSON_GetObjectItemCaseSensitive(choice, "message"); + cJSON *content = cJSON_GetObjectItemCaseSensitive(message, "content"); + if (!cJSON_IsArray(content)) { + return false; + } + + out_url[0] = '\0'; + if (out_prompt != NULL && out_prompt_len > 0) { + out_prompt[0] = '\0'; + } + + int count = cJSON_GetArraySize(content); + for (int i = 0; i < count; ++i) { + cJSON *item = cJSON_GetArrayItem(content, i); + if (!cJSON_IsObject(item)) { + continue; + } + const char *image = json_get_string(item, "image"); + if (image != NULL && out_url[0] == '\0') { + strlcpy(out_url, image, out_url_len); + } + + if (out_prompt != NULL && out_prompt_len > 0 && out_prompt[0] == '\0') { + const char *text = json_get_string(item, "text"); + if (text != NULL) { + strlcpy(out_prompt, text, out_prompt_len); + } + } + } + + return out_url[0] != '\0'; +} + +static void extract_remote_error_message(const char *json_text, + int status_code, + char *err, + size_t err_len) { + if (json_text == NULL) { + image_generation_fill_err(err, err_len, "upstream request failed"); + return; + } + + cJSON *root = cJSON_Parse(json_text); + if (root == NULL) { + if (status_code > 0) { + snprintf(err, err_len, "upstream request failed, http_status=%d", status_code); + } else { + image_generation_fill_err(err, err_len, "upstream request failed"); + } + return; + } + + const char *code = json_get_string(root, "code"); + const char *message = json_get_string(root, "message"); + + if (code != NULL && message != NULL) { + snprintf(err, err_len, "%s: %s", code, message); + } else if (message != NULL) { + image_generation_fill_err(err, err_len, message); + } else if (status_code > 0) { + snprintf(err, err_len, "upstream request failed, http_status=%d", status_code); + } else { + image_generation_fill_err(err, err_len, "upstream request failed"); + } + + cJSON_Delete(root); +} + +static char *build_generation_request_json(const image_generation_request_t *req) { + cJSON *root = cJSON_CreateObject(); + if (root == NULL) { + return NULL; + } + + cJSON_AddStringToObject(root, "model", CONFIG_TQ_Z_IMAGE_MODEL); + + cJSON *input = cJSON_AddObjectToObject(root, "input"); + cJSON *messages = cJSON_AddArrayToObject(input, "messages"); + cJSON *message = cJSON_CreateObject(); + cJSON_AddItemToArray(messages, message); + cJSON_AddStringToObject(message, "role", "user"); + cJSON *content = cJSON_AddArrayToObject(message, "content"); + cJSON *content_item = cJSON_CreateObject(); + cJSON_AddItemToArray(content, content_item); + cJSON_AddStringToObject(content_item, "text", req->prompt); + + cJSON *parameters = cJSON_AddObjectToObject(root, "parameters"); + cJSON_AddBoolToObject(parameters, "prompt_extend", req->prompt_extend); + + const char *size = (req->size != NULL && req->size[0] != '\0') + ? req->size + : CONFIG_TQ_Z_IMAGE_DEFAULT_SIZE; + cJSON_AddStringToObject(parameters, "size", size); + if (req->has_seed) { + cJSON_AddNumberToObject(parameters, "seed", (double)req->seed); + } + + char *body = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + return body; +} + +static esp_err_t image_generation_invoke_model(const image_generation_request_t *req, + image_generation_result_t *out_result, + char *err, + size_t err_len) { + char *req_body = build_generation_request_json(req); + if (req_body == NULL) { + image_generation_fill_err(err, err_len, "build request json failed"); + return ESP_ERR_NO_MEM; + } + + const char *api_key = image_generation_api_key(); + if (api_key == NULL) { + cJSON_free(req_body); + image_generation_fill_err(err, err_len, "z-image api key not configured"); + return ESP_ERR_INVALID_STATE; + } + + char auth_header[192] = {0}; + snprintf(auth_header, sizeof(auth_header), "Bearer %s", api_key); + + ESP_LOGI(TAG, + "z-image request start, timeout_ms=%u", + (unsigned)req->generation_timeout_ms); + + esp_http_client_config_t config = { + .url = CONFIG_TQ_Z_IMAGE_API_ENDPOINT, + .method = HTTP_METHOD_POST, + .timeout_ms = (int)req->generation_timeout_ms, + .keep_alive_enable = true, + .buffer_size = IMAGE_HTTP_BUFFER_BYTES, + .buffer_size_tx = IMAGE_HTTP_BUFFER_BYTES, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + + bytes_buffer_t resp = { + .data = NULL, + .len = 0, + .cap = 0, + .max_len = CONFIG_TQ_Z_IMAGE_MAX_RESPONSE_BYTES, + }; + int http_status = 0; + esp_err_t rc = http_request_collect(&config, + auth_header, + req_body, + &http_status, + &resp, + err, + err_len); + cJSON_free(req_body); + if (rc != ESP_OK) { + ESP_LOGW(TAG, + "z-image request failed, rc=0x%x, msg=%s", + (unsigned)rc, + (err != NULL && err[0] != '\0') ? err : "http request failed"); + bytes_buffer_free(&resp); + return rc; + } + + rc = bytes_buffer_terminate(&resp); + if (rc != ESP_OK) { + bytes_buffer_free(&resp); + image_generation_fill_err(err, err_len, "generation response too large"); + return rc; + } + + if (http_status < 200 || http_status >= 300) { + extract_remote_error_message((const char *)resp.data, http_status, err, err_len); + ESP_LOGW(TAG, + "z-image http status=%d, msg=%s", + http_status, + (err != NULL && err[0] != '\0') ? err : "upstream request failed"); + bytes_buffer_free(&resp); + return ESP_FAIL; + } + + cJSON *root = cJSON_Parse((const char *)resp.data); + bytes_buffer_free(&resp); + if (root == NULL) { + image_generation_fill_err(err, err_len, "invalid generation response"); + return ESP_FAIL; + } + + const char *request_id = json_get_string(root, "request_id"); + if (request_id != NULL) { + strlcpy(out_result->request_id, request_id, sizeof(out_result->request_id)); + } + + cJSON *usage = cJSON_GetObjectItemCaseSensitive(root, "usage"); + cJSON *usage_w = cJSON_GetObjectItemCaseSensitive(usage, "width"); + cJSON *usage_h = cJSON_GetObjectItemCaseSensitive(usage, "height"); + if (cJSON_IsNumber(usage_w) && usage_w->valuedouble >= 1 && usage_w->valuedouble <= UINT16_MAX) { + out_result->width = (uint16_t)usage_w->valuedouble; + } + if (cJSON_IsNumber(usage_h) && usage_h->valuedouble >= 1 && usage_h->valuedouble <= UINT16_MAX) { + out_result->height = (uint16_t)usage_h->valuedouble; + } + + bool has_url = json_extract_image_url(root, + out_result->image_url, + sizeof(out_result->image_url), + out_result->output_prompt, + sizeof(out_result->output_prompt)); + if (!has_url) { + const char *code = json_get_string(root, "code"); + const char *message = json_get_string(root, "message"); + if (code != NULL && message != NULL) { + snprintf(err, err_len, "%s: %s", code, message); + } else { + image_generation_fill_err(err, err_len, "generation response has no image url"); + } + ESP_LOGW(TAG, + "z-image response parse failed, msg=%s", + (err != NULL && err[0] != '\0') ? err : "generation response has no image url"); + cJSON_Delete(root); + return ESP_FAIL; + } + + image_generation_remember_oss_origin(out_result->image_url); + + ESP_LOGI(TAG, + "z-image response ready, request_id=%s, model_size=%ux%u, image_url_len=%u", + out_result->request_id[0] != '\0' ? out_result->request_id : "-", + (unsigned)out_result->width, + (unsigned)out_result->height, + (unsigned)strlen(out_result->image_url)); + + cJSON_Delete(root); + return ESP_OK; +} + +static esp_err_t image_generation_download_png(image_generation_result_t *out_result, + uint32_t timeout_ms, + char *err, + size_t err_len) { + ESP_LOGI(TAG, "generated image download start, timeout_ms=%u", (unsigned)timeout_ms); + bool coex_boosted = image_generation_boost_coex_for_download(); + + esp_http_client_config_t config = { + .url = out_result->image_url, + .method = HTTP_METHOD_GET, + .timeout_ms = (int)timeout_ms, + .keep_alive_enable = true, + .buffer_size = IMAGE_HTTP_BUFFER_BYTES, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + + bytes_buffer_t resp = { + .data = NULL, + .len = 0, + .cap = 0, + .max_len = CONFIG_TQ_Z_IMAGE_MAX_PNG_BYTES, + }; + int http_status = 0; + esp_err_t rc = http_request_collect(&config, + NULL, + NULL, + &http_status, + &resp, + err, + err_len); + if (rc != ESP_OK) { + ESP_LOGW(TAG, + "generated image download failed, rc=0x%x, msg=%s", + (unsigned)rc, + (err != NULL && err[0] != '\0') ? err : "download failed"); + bytes_buffer_free(&resp); + image_generation_restore_coex_after_download(coex_boosted); + return rc; + } + + if (http_status < 200 || http_status >= 300) { + rc = bytes_buffer_terminate(&resp); + if (rc != ESP_OK) { + bytes_buffer_free(&resp); + image_generation_fill_err(err, err_len, "image download failed"); + image_generation_restore_coex_after_download(coex_boosted); + return ESP_FAIL; + } + extract_remote_error_message((const char *)resp.data, http_status, err, err_len); + ESP_LOGW(TAG, + "generated image download http status=%d, msg=%s", + http_status, + (err != NULL && err[0] != '\0') ? err : "image download failed"); + bytes_buffer_free(&resp); + image_generation_restore_coex_after_download(coex_boosted); + return ESP_FAIL; + } + + static const uint8_t png_header[8] = { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + }; + if (resp.len < sizeof(png_header) || memcmp(resp.data, png_header, sizeof(png_header)) != 0) { + bytes_buffer_free(&resp); + image_generation_fill_err(err, err_len, "downloaded image is not png"); + ESP_LOGW(TAG, "generated image download invalid png signature"); + image_generation_restore_coex_after_download(coex_boosted); + return ESP_FAIL; + } + + out_result->png = resp.data; + out_result->png_len = resp.len; + ESP_LOGI(TAG, "generated image download done, png_bytes=%u", (unsigned)out_result->png_len); + image_generation_restore_coex_after_download(coex_boosted); + return ESP_OK; +} + +void image_generation_result_reset(image_generation_result_t *result) { + if (result == NULL) { + return; + } + memset(result, 0, sizeof(*result)); +} + +void image_generation_result_free(image_generation_result_t *result) { + if (result == NULL) { + return; + } + free(result->png); + result->png = NULL; + result->png_len = 0; +} + +esp_err_t image_generation_generate_png(const image_generation_request_t *req, + image_generation_result_t *out_result, + char *err, + size_t err_len) { + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_ATTEMPT, 1); + + if (req == NULL || out_result == NULL || req->prompt == NULL || req->prompt[0] == '\0') { + image_generation_fill_err(err, err_len, "prompt is required"); + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_FAILED, 1); + return ESP_ERR_INVALID_ARG; + } + + image_generation_result_reset(out_result); + + image_generation_request_t actual = *req; + if (actual.generation_timeout_ms == 0) { + actual.generation_timeout_ms = runtime_policy_image_generation_timeout_default_ms(); + } + if (actual.download_timeout_ms == 0) { + actual.download_timeout_ms = runtime_policy_image_download_timeout_default_ms(); + } + + ESP_LOGI(TAG, + "image generate start, prompt_len=%u, size=%s, prompt_extend=%d, seed=%s, gen_timeout_ms=%u, fetch_timeout_ms=%u", + (unsigned)strlen(actual.prompt), + (actual.size != NULL && actual.size[0] != '\0') ? actual.size : "default", + actual.prompt_extend, + actual.has_seed ? "set" : "auto", + (unsigned)actual.generation_timeout_ms, + (unsigned)actual.download_timeout_ms); + + int64_t t0_ms = esp_timer_get_time() / 1000; + esp_err_t gen_rc = image_generation_invoke_model(&actual, out_result, err, err_len); + if (gen_rc != ESP_OK) { + ESP_LOGW(TAG, + "image generate stage failed, rc=0x%x, msg=%s", + (unsigned)gen_rc, + (err != NULL && err[0] != '\0') ? err : "model invoke failed"); + if (gen_rc == ESP_ERR_TIMEOUT) { + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_TIMEOUT, 1); + } else { + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_FAILED, 1); + } + runtime_diag_record_error("image_generate", gen_rc, err != NULL ? err : "model invoke failed"); + image_generation_result_free(out_result); + return gen_rc; + } + + int64_t t_gen_done_ms = esp_timer_get_time() / 1000; + ESP_LOGI(TAG, + "image generate stage done, request_id=%s, gen_cost_ms=%lld", + out_result->request_id[0] != '\0' ? out_result->request_id : "-", + (long long)(t_gen_done_ms - t0_ms)); + esp_err_t dl_rc = image_generation_download_png(out_result, actual.download_timeout_ms, err, err_len); + if (dl_rc != ESP_OK) { + ESP_LOGW(TAG, + "image download stage failed, rc=0x%x, msg=%s", + (unsigned)dl_rc, + (err != NULL && err[0] != '\0') ? err : "download failed"); + if (dl_rc == ESP_ERR_TIMEOUT) { + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_TIMEOUT, 1); + } else { + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_FAILED, 1); + } + runtime_diag_record_error("image_download", dl_rc, err != NULL ? err : "download failed"); + image_generation_result_free(out_result); + return dl_rc; + } + + int64_t t_dl_done_ms = esp_timer_get_time() / 1000; + + ESP_LOGI(TAG, + "image generation pipeline done, request_id=%s, png_bytes=%u, gen_ms=%lld, download_ms=%lld, total_ms=%lld", + out_result->request_id[0] != '\0' ? out_result->request_id : "-", + (unsigned)out_result->png_len, + (long long)(t_gen_done_ms - t0_ms), + (long long)(t_dl_done_ms - t_gen_done_ms), + (long long)(t_dl_done_ms - t0_ms)); + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_SUCCESS, 1); + + return ESP_OK; +} diff --git a/components/domain/src/printer_protocol.c b/components/domain/src/printer_protocol.c new file mode 100644 index 0000000..0bc499d --- /dev/null +++ b/components/domain/src/printer_protocol.c @@ -0,0 +1,1024 @@ +#include "domain.h" +#include "printer_protocol_internal.h" + +#include +#include +#include + +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +SemaphoreHandle_t s_mutex; +QueueHandle_t s_job_queue; +EventGroupHandle_t s_evt; + +uint32_t s_busy_refcnt; +uint32_t s_status_poll_pause_next_token; +uint32_t s_status_poll_pause_tokens[STATUS_POLL_PAUSE_SLOT_MAX]; + +parsed_status_t s_status = { + .has_paper = true, + .paper_gpio_level = -1, + .paper_present_level = 0, + .battery = 100, + .temperature = 25.0f, + .updated_ms = 0, +}; + +uint8_t s_last_rsp_cmd; +uint16_t s_last_rsp_payload_len; +uint8_t s_last_rsp_payload[252]; + +uint32_t s_next_job_id = 1; +job_slot_t s_jobs[JOB_SLOT_MAX]; +TaskHandle_t s_worker_task; +TaskHandle_t s_status_poll_task_handle; +bool s_protocol_initialized; +bool s_protocol_stopping; +printer_backend_t s_backend = PRINTER_BACKEND_BLE; +bool s_ble_client_initialized; + +static const char *TAG = "printer_protocol"; + +#if CONFIG_FREERTOS_UNICORE +#define PRINT_WORKER_CORE_ID 0 +#else +#define PRINT_WORKER_CORE_ID 1 +#endif + +static void on_rx_frame(const uint8_t *data, size_t len); + +static printer_backend_t default_backend_on_startup(void) { + // This hardware has a first-class direct path; BLE remains opt-in via API. + if (runtime_policy_direct_printer_enabled()) { + return PRINTER_BACKEND_DIRECT; + } + return PRINTER_BACKEND_BLE; +} + +static void backend_capabilities_for(printer_backend_t backend, printer_capabilities_t *out_caps) { + if (out_caps == NULL) { + return; + } + memset(out_caps, 0, sizeof(*out_caps)); + out_caps->supports_connect = true; + out_caps->supports_gap_move = true; + if (backend == PRINTER_BACKEND_BLE) { + out_caps->supports_label_offset = true; + out_caps->supports_ota = true; + } +} + +static esp_err_t ensure_ble_client_ready(void) { + if (s_ble_client_initialized) { + return ESP_OK; + } + esp_err_t err = ble_printer_client_init(on_rx_frame); + if (err == ESP_OK) { + s_ble_client_initialized = true; + } + return err; +} + +static esp_err_t ensure_backend_ready(printer_backend_t backend) { + if (backend == PRINTER_BACKEND_BLE) { + return ensure_ble_client_ready(); + } + if (backend == PRINTER_BACKEND_DIRECT) { + if (!runtime_policy_direct_printer_enabled()) { + return ESP_ERR_NOT_SUPPORTED; + } + return platform_direct_printer_init(); + } + return ESP_ERR_NOT_SUPPORTED; +} + +static void refresh_direct_status_locked(void) { + if (s_backend != PRINTER_BACKEND_DIRECT) { + return; + } + + platform_printer_sensors_t sensors = {0}; + if (platform_direct_printer_get_sensors(&sensors) == ESP_OK) { + 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 = sensors.battery_percent; + s_status.temperature = sensors.temperature_c; + s_status.updated_ms = sensors.updated_ms; + } +} + +static size_t status_poll_pause_depth_locked(void) { + size_t depth = 0; + for (size_t i = 0; i < STATUS_POLL_PAUSE_SLOT_MAX; ++i) { + if (s_status_poll_pause_tokens[i] != 0) { + ++depth; + } + } + return depth; +} + +static void status_poll_pause_slots_clear_locked(void) { + memset(s_status_poll_pause_tokens, 0, sizeof(s_status_poll_pause_tokens)); +} + +static void reset_jobs_locked(void) { + for (int i = 0; i < JOB_SLOT_MAX; ++i) { + free(s_jobs[i].data); + memset(&s_jobs[i], 0, sizeof(s_jobs[i])); + } + s_next_job_id = 1; +} + +static void reset_runtime_state_locked(void) { + s_busy_refcnt = 0; + s_last_rsp_cmd = 0; + s_last_rsp_payload_len = 0; + memset(s_last_rsp_payload, 0, sizeof(s_last_rsp_payload)); + + status_poll_pause_slots_clear_locked(); + s_status_poll_pause_next_token = 1; + + s_status.has_paper = true; + s_status.paper_gpio_level = -1; + s_status.paper_present_level = 0; + s_status.battery = 100; + s_status.temperature = 25.0f; + s_status.updated_ms = 0; +} + +bool printer_protocol_is_stopping(void) { + if (!s_protocol_initialized) { + return false; + } + if (s_mutex == NULL) { + return true; + } + + bool stopping = true; + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) { + stopping = s_protocol_stopping; + xSemaphoreGive(s_mutex); + } + return stopping; +} + +static BaseType_t printer_create_task_prefer_psram(TaskFunction_t task_fn, + const char *name, + uint32_t stack_size, + UBaseType_t priority, + TaskHandle_t *out_task) { +#if defined(CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY) && \ + defined(CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM) && \ + (configSUPPORT_STATIC_ALLOCATION == 1) + BaseType_t rc = xTaskCreatePinnedToCoreWithCaps(task_fn, + name, + (configSTACK_DEPTH_TYPE)stack_size, + NULL, + priority, + out_task, + PRINT_WORKER_CORE_ID, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (rc == pdPASS) { + return pdPASS; + } +#endif + return xTaskCreatePinnedToCore(task_fn, + name, + (configSTACK_DEPTH_TYPE)stack_size, + NULL, + priority, + out_task, + PRINT_WORKER_CORE_ID); +} + +printer_status_poll_pause_token_t printer_protocol_status_poll_pause_acquire(void) { + if (!s_protocol_initialized || s_mutex == NULL) { + return 0; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) != pdTRUE) { + return 0; + } + + if (s_protocol_stopping) { + xSemaphoreGive(s_mutex); + return 0; + } + + int slot = -1; + for (int i = 0; i < STATUS_POLL_PAUSE_SLOT_MAX; ++i) { + if (s_status_poll_pause_tokens[i] == 0) { + slot = i; + break; + } + } + + if (slot < 0) { + xSemaphoreGive(s_mutex); + ESP_LOGW(TAG, "status poll pause acquire failed: no free slot"); + return 0; + } + + uint32_t token = s_status_poll_pause_next_token++; + if (token == 0) { + token = s_status_poll_pause_next_token++; + } + + s_status_poll_pause_tokens[slot] = token; + runtime_diag_set_gauge(RUNTIME_DIAG_GAUGE_STATUS_POLL_PAUSE_DEPTH, + (int32_t)status_poll_pause_depth_locked()); + xSemaphoreGive(s_mutex); + return token; +} + +void printer_protocol_status_poll_pause_release(printer_status_poll_pause_token_t *token) { + if (token == NULL || *token == 0) { + return; + } + + if (!s_protocol_initialized || s_mutex == NULL) { + *token = 0; + return; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) != pdTRUE) { + return; + } + + for (int i = 0; i < STATUS_POLL_PAUSE_SLOT_MAX; ++i) { + if (s_status_poll_pause_tokens[i] == *token) { + s_status_poll_pause_tokens[i] = 0; + break; + } + } + + runtime_diag_set_gauge(RUNTIME_DIAG_GAUGE_STATUS_POLL_PAUSE_DEPTH, + (int32_t)status_poll_pause_depth_locked()); + xSemaphoreGive(s_mutex); + *token = 0; +} + +bool printer_protocol_acquire_control_lane(uint32_t timeout_ms, char *err, size_t err_len) { + if (!s_protocol_initialized || s_mutex == NULL) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer protocol not initialized"); + } + return false; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(timeout_ms)) != pdTRUE) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "lock timeout"); + } + return false; + } + + if (s_protocol_stopping) { + xSemaphoreGive(s_mutex); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer protocol stopping"); + } + return false; + } + + if (s_busy_refcnt != 0) { + xSemaphoreGive(s_mutex); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer busy"); + } + return false; + } + + s_busy_refcnt = 1; + xSemaphoreGive(s_mutex); + return true; +} + +void printer_protocol_release_control_lane(void) { + if (s_mutex == NULL) { + return; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + return; + } + + if (s_busy_refcnt > 0) { + --s_busy_refcnt; + } + xSemaphoreGive(s_mutex); +} + +static uint8_t checksum8(const uint8_t *data, size_t len) { + uint32_t sum = 0; + for (size_t i = 0; i < len; ++i) { + sum += data[i]; + } + return (uint8_t)(sum & 0xFF); +} + +esp_err_t printer_protocol_send_frame(uint8_t cmd, + const uint8_t *payload, + uint16_t payload_len, + bool with_checksum) { + if (!s_protocol_initialized || printer_protocol_is_stopping()) { + return ESP_ERR_INVALID_STATE; + } + if (s_backend != PRINTER_BACKEND_BLE) { + return ESP_ERR_NOT_SUPPORTED; + } + + uint8_t frame[260]; + size_t total = 4 + payload_len + (with_checksum ? 1 : 0); + if (total > sizeof(frame)) { + return ESP_ERR_INVALID_SIZE; + } + + frame[0] = PROTO_ADDR; + frame[1] = cmd; + frame[2] = (uint8_t)((payload_len >> 8) & 0xFF); + frame[3] = (uint8_t)(payload_len & 0xFF); + + if (payload_len > 0 && payload != NULL) { + memcpy(&frame[4], payload, payload_len); + } + + if (with_checksum) { + frame[4 + payload_len] = checksum8(frame, 4 + payload_len); + } + + return ble_printer_client_write(frame, total); +} + +static void clear_ack_signal(void) { + if (s_evt != NULL) { + xEventGroupClearBits(s_evt, EVT_ACK); + } +} + +bool printer_protocol_wait_response(uint8_t cmd, + uint8_t *out_payload, + size_t out_payload_cap, + uint16_t *out_payload_len, + uint32_t timeout_ms) { + if (timeout_ms == 0 || s_evt == NULL) { + return false; + } + + int64_t deadline = esp_timer_get_time() / 1000 + timeout_ms; + + while (true) { + if (printer_protocol_is_stopping()) { + return false; + } + + int64_t now = esp_timer_get_time() / 1000; + if (now >= deadline) { + return false; + } + + uint32_t wait_ms = (uint32_t)(deadline - now); + if (wait_ms > 100) { + wait_ms = 100; + } + + EventBits_t bits = xEventGroupWaitBits(s_evt, + EVT_ACK | EVT_SHUTDOWN, + pdFALSE, + pdFALSE, + pdMS_TO_TICKS(wait_ms)); + if (bits & EVT_SHUTDOWN) { + return false; + } + + if (!(bits & EVT_ACK)) { + continue; + } + xEventGroupClearBits(s_evt, EVT_ACK); + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) != pdTRUE) { + continue; + } + + uint8_t rsp_cmd = s_last_rsp_cmd; + uint16_t rsp_len = s_last_rsp_payload_len; + uint8_t rsp_copy[sizeof(s_last_rsp_payload)]; + if (rsp_len > sizeof(rsp_copy)) { + rsp_len = sizeof(rsp_copy); + } + if (rsp_len > 0) { + memcpy(rsp_copy, s_last_rsp_payload, rsp_len); + } + xSemaphoreGive(s_mutex); + + if (rsp_cmd == cmd) { + if (out_payload != NULL && out_payload_cap > 0 && rsp_len > 0) { + size_t copy_len = rsp_len; + if (copy_len > out_payload_cap) { + copy_len = out_payload_cap; + } + memcpy(out_payload, rsp_copy, copy_len); + } + if (out_payload_len != NULL) { + *out_payload_len = rsp_len; + } + return true; + } + } +} + +static bool wait_ack(uint8_t cmd, uint32_t timeout_ms) { + uint8_t payload[252]; + uint16_t payload_len = 0; + if (!printer_protocol_wait_response(cmd, payload, sizeof(payload), &payload_len, timeout_ms)) { + return false; + } + return payload_len >= 1 && payload[0] == 0x01; +} + +static void reset_transport_after_timeout(void) { + if (printer_protocol_is_stopping()) { + return; + } + if (printer_protocol_get_backend() == PRINTER_BACKEND_BLE) { + ble_printer_client_disconnect(); + } +} + +bool printer_protocol_send_cmd_with_ack(uint8_t cmd, + const uint8_t *payload, + uint16_t payload_len, + bool with_checksum, + uint32_t timeout_ms, + bool reset_transport_on_timeout) { + clear_ack_signal(); + if (printer_protocol_send_frame(cmd, payload, payload_len, with_checksum) != ESP_OK) { + return false; + } + bool ok = wait_ack(cmd, timeout_ms); + if (!ok && reset_transport_on_timeout) { + reset_transport_after_timeout(); + } + return ok; +} + +esp_err_t printer_protocol_send_cmd_wait_response(uint8_t cmd, + const uint8_t *payload, + uint16_t payload_len, + bool with_checksum, + uint32_t timeout_ms, + bool expect_ack, + bool reset_transport_on_timeout, + uint8_t *out_payload, + size_t out_payload_cap, + uint16_t *out_payload_len, + char *err, + size_t err_len) { + clear_ack_signal(); + if (printer_protocol_send_frame(cmd, payload, payload_len, with_checksum) != ESP_OK) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "send frame failed"); + } + return ESP_FAIL; + } + + uint8_t rsp[252]; + uint16_t rsp_len = 0; + if (!printer_protocol_wait_response(cmd, rsp, sizeof(rsp), &rsp_len, timeout_ms)) { + bool stopping = printer_protocol_is_stopping(); + if (!stopping && reset_transport_on_timeout) { + reset_transport_after_timeout(); + } + if (err != NULL && err_len > 0) { + if (stopping) { + snprintf(err, err_len, "protocol stopping"); + } else { + snprintf(err, + err_len, + reset_transport_on_timeout ? "response timeout, transport reset" + : "response timeout"); + } + } + return stopping ? ESP_ERR_INVALID_STATE : ESP_ERR_TIMEOUT; + } + + if (expect_ack && (rsp_len < 1 || rsp[0] != 0x01)) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "cmd 0x%02X rejected", cmd); + } + return ESP_FAIL; + } + + if (out_payload != NULL && out_payload_cap > 0 && rsp_len > 0) { + size_t copy_len = rsp_len; + if (copy_len > out_payload_cap) { + copy_len = out_payload_cap; + } + memcpy(out_payload, rsp, copy_len); + } + if (out_payload_len != NULL) { + *out_payload_len = rsp_len; + } + + return ESP_OK; +} + +static void status_poll_task(void *arg) { + (void)arg; + + while (true) { + bool should_stop = false; + bool can_poll = false; + printer_backend_t backend = PRINTER_BACKEND_BLE; + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) { + should_stop = s_protocol_stopping; + can_poll = (s_busy_refcnt == 0) && (status_poll_pause_depth_locked() == 0); + backend = s_backend; + xSemaphoreGive(s_mutex); + } + + if (should_stop) { + break; + } + + if (can_poll) { + if (backend == PRINTER_BACKEND_BLE) { + if (ble_printer_client_is_connected()) { + uint8_t dummy = 0x00; + (void)printer_protocol_send_frame(CMD_GET_STATUS, &dummy, 0, true); + } + } else if (backend == PRINTER_BACKEND_DIRECT) { + if (platform_direct_printer_is_connected() && + xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { + refresh_direct_status_locked(); + xSemaphoreGive(s_mutex); + } + } + } + vTaskDelay(pdMS_TO_TICKS(runtime_policy_printer_status_poll_interval_ms())); + } + + if (s_evt != NULL) { + xEventGroupSetBits(s_evt, EVT_STATUS_POLL_EXITED); + } + s_status_poll_task_handle = NULL; + vTaskDelete(NULL); +} + +static void on_rx_frame(const uint8_t *data, size_t len) { + if (data == NULL || len < 4 || s_mutex == NULL) { + return; + } + + if (data[0] != PROTO_ADDR) { + return; + } + + uint8_t cmd = data[1]; + uint16_t payload_len = ((uint16_t)data[2] << 8) | data[3]; + size_t required = (size_t)payload_len + 4; + if (len < required) { + return; + } + + const uint8_t *payload = &data[4]; + + if (cmd == CMD_GET_STATUS && payload_len >= 5) { + bool has_paper = payload[0] != 0; + uint8_t battery = payload[1]; + int sign = (payload[2] == 0x2D) ? -1 : 1; + int temp_x10 = ((int)payload[3] << 8) | payload[4]; + float temperature = (float)(sign * temp_x10) / 10.0f; + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { + s_status.has_paper = has_paper; + s_status.paper_gpio_level = -1; + s_status.paper_present_level = 0; + s_status.battery = battery; + s_status.temperature = temperature; + s_status.updated_ms = esp_timer_get_time() / 1000; + xSemaphoreGive(s_mutex); + } + return; + } + + if (payload_len >= 1) { + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { + s_last_rsp_cmd = cmd; + s_last_rsp_payload_len = payload_len; + if (s_last_rsp_payload_len > sizeof(s_last_rsp_payload)) { + s_last_rsp_payload_len = sizeof(s_last_rsp_payload); + } + memcpy(s_last_rsp_payload, payload, s_last_rsp_payload_len); + xSemaphoreGive(s_mutex); + } + if (s_evt != NULL) { + xEventGroupSetBits(s_evt, EVT_ACK); + } + } +} + +static void destroy_runtime_objects(void) { + if (s_job_queue != NULL) { + vQueueDelete(s_job_queue); + s_job_queue = NULL; + } + if (s_evt != NULL) { + vEventGroupDelete(s_evt); + s_evt = NULL; + } + if (s_mutex != NULL) { + vSemaphoreDelete(s_mutex); + s_mutex = NULL; + } +} + +esp_err_t printer_protocol_init(void) { + if (s_protocol_initialized) { + return ESP_OK; + } + + s_mutex = xSemaphoreCreateMutex(); + if (s_mutex == NULL) { + return ESP_ERR_NO_MEM; + } + + s_evt = xEventGroupCreate(); + if (s_evt == NULL) { + destroy_runtime_objects(); + return ESP_ERR_NO_MEM; + } + + s_job_queue = xQueueCreate(JOB_QUEUE_LEN, sizeof(uint32_t)); + if (s_job_queue == NULL) { + destroy_runtime_objects(); + return ESP_ERR_NO_MEM; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) == pdTRUE) { + reset_jobs_locked(); + reset_runtime_state_locked(); + s_protocol_stopping = false; + xSemaphoreGive(s_mutex); + } + + runtime_diag_set_gauge(RUNTIME_DIAG_GAUGE_STATUS_POLL_PAUSE_DEPTH, 0); + runtime_diag_set_gauge(RUNTIME_DIAG_GAUGE_PRINTER_QUEUE_DEPTH, 0); + s_protocol_initialized = true; + s_protocol_stopping = false; + s_ble_client_initialized = false; + s_backend = default_backend_on_startup(); + + esp_err_t err = ensure_backend_ready(s_backend); + if (err != ESP_OK) { + s_protocol_initialized = false; + destroy_runtime_objects(); + return err; + } + + if (s_backend == PRINTER_BACKEND_DIRECT) { + uint32_t connect_timeout_ms = domain_policy_rest_printer_connect_timeout_ms(); + err = platform_direct_printer_connect(connect_timeout_ms); + if (err != ESP_OK) { + s_protocol_initialized = false; + platform_direct_printer_disconnect(); + platform_direct_printer_deinit(); + destroy_runtime_objects(); + return err; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) == pdTRUE) { + refresh_direct_status_locked(); + xSemaphoreGive(s_mutex); + } + ESP_LOGI(TAG, "direct printer auto connected at startup"); + } + + BaseType_t ok = printer_create_task_prefer_psram(printer_protocol_worker_task, + "print_worker", + 6144, + 6, + &s_worker_task); + if (ok != pdPASS) { + s_protocol_initialized = false; + destroy_runtime_objects(); + return ESP_ERR_NO_MEM; + } + + ok = printer_create_task_prefer_psram(status_poll_task, + "status_poll", + 4096, + 4, + &s_status_poll_task_handle); + if (ok != pdPASS) { + if (s_worker_task != NULL) { + vTaskDelete(s_worker_task); + s_worker_task = NULL; + } + s_protocol_initialized = false; + destroy_runtime_objects(); + return ESP_ERR_NO_MEM; + } + + ESP_LOGI(TAG, "printer protocol initialized"); + return ESP_OK; +} + +esp_err_t printer_protocol_stop(uint32_t timeout_ms) { + if (!s_protocol_initialized) { + return ESP_OK; + } + + if (timeout_ms == 0) { + timeout_ms = runtime_policy_printer_stop_timeout_ms(); + } + + if (s_mutex != NULL && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + s_protocol_stopping = true; + s_busy_refcnt = 0; + status_poll_pause_slots_clear_locked(); + xSemaphoreGive(s_mutex); + } + + runtime_diag_set_gauge(RUNTIME_DIAG_GAUGE_STATUS_POLL_PAUSE_DEPTH, 0); + + if (s_evt != NULL) { + xEventGroupSetBits(s_evt, EVT_SHUTDOWN); + } + + if (s_job_queue != NULL) { + const uint32_t sentinel = PRINTER_JOB_SENTINEL_STOP; + (void)xQueueSend(s_job_queue, &sentinel, 0); + } + + ble_printer_client_disconnect(); + platform_direct_printer_disconnect(); + + bool worker_done = (s_worker_task == NULL); + bool poll_done = (s_status_poll_task_handle == NULL); + + if (s_evt != NULL && (!worker_done || !poll_done)) { + EventBits_t bits = xEventGroupWaitBits(s_evt, + EVT_WORKER_EXITED | EVT_STATUS_POLL_EXITED, + pdFALSE, + pdTRUE, + pdMS_TO_TICKS(timeout_ms)); + worker_done = worker_done || ((bits & EVT_WORKER_EXITED) != 0) || s_worker_task == NULL; + poll_done = poll_done || ((bits & EVT_STATUS_POLL_EXITED) != 0) || s_status_poll_task_handle == NULL; + } + + if (!worker_done && s_worker_task != NULL) { + ESP_LOGW(TAG, "worker task stop timeout, force delete"); + vTaskDelete(s_worker_task); + s_worker_task = NULL; + } + if (!poll_done && s_status_poll_task_handle != NULL) { + ESP_LOGW(TAG, "status poll task stop timeout, force delete"); + vTaskDelete(s_status_poll_task_handle); + s_status_poll_task_handle = NULL; + } + + if (s_mutex != NULL && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(500)) == pdTRUE) { + reset_jobs_locked(); + reset_runtime_state_locked(); + xSemaphoreGive(s_mutex); + } + + destroy_runtime_objects(); + + s_busy_refcnt = 0; + s_status_poll_pause_next_token = 1; + memset(s_status_poll_pause_tokens, 0, sizeof(s_status_poll_pause_tokens)); + memset(s_last_rsp_payload, 0, sizeof(s_last_rsp_payload)); + s_last_rsp_cmd = 0; + s_last_rsp_payload_len = 0; + s_worker_task = NULL; + s_status_poll_task_handle = NULL; + s_protocol_initialized = false; + s_protocol_stopping = false; + s_ble_client_initialized = false; + s_backend = default_backend_on_startup(); + + runtime_diag_set_gauge(RUNTIME_DIAG_GAUGE_STATUS_POLL_PAUSE_DEPTH, 0); + runtime_diag_set_gauge(RUNTIME_DIAG_GAUGE_PRINTER_QUEUE_DEPTH, 0); + + platform_direct_printer_deinit(); + + ESP_LOGI(TAG, "printer protocol stopped"); + return (worker_done && poll_done) ? ESP_OK : ESP_ERR_TIMEOUT; +} + +esp_err_t printer_protocol_set_backend(printer_backend_t backend, char *err, size_t err_len) { + if (backend != PRINTER_BACKEND_BLE && backend != PRINTER_BACKEND_DIRECT) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid backend"); + } + return ESP_ERR_INVALID_ARG; + } + if (backend == PRINTER_BACKEND_DIRECT && !runtime_policy_direct_printer_enabled()) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "direct backend disabled"); + } + return ESP_ERR_NOT_SUPPORTED; + } + if (!s_protocol_initialized || s_protocol_stopping || s_mutex == NULL) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer protocol unavailable"); + } + return ESP_ERR_INVALID_STATE; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(runtime_policy_printer_control_lock_timeout_ms())) != pdTRUE) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "lock timeout"); + } + return ESP_ERR_TIMEOUT; + } + + printer_backend_t old_backend = s_backend; + if (old_backend == backend) { + xSemaphoreGive(s_mutex); + return ESP_OK; + } + + if (s_busy_refcnt != 0 || (s_job_queue != NULL && uxQueueMessagesWaiting(s_job_queue) > 0)) { + xSemaphoreGive(s_mutex); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer busy"); + } + return ESP_ERR_INVALID_STATE; + } + + s_backend = backend; + xSemaphoreGive(s_mutex); + + ble_printer_client_disconnect(); + platform_direct_printer_disconnect(); + + esp_err_t rc = ensure_backend_ready(backend); + if (rc != ESP_OK) { + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) == pdTRUE) { + s_backend = old_backend; + xSemaphoreGive(s_mutex); + } + (void)ensure_backend_ready(old_backend); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "backend init failed"); + } + return rc; + } + + if (backend == PRINTER_BACKEND_DIRECT && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) == pdTRUE) { + refresh_direct_status_locked(); + xSemaphoreGive(s_mutex); + } + return ESP_OK; +} + +printer_backend_t printer_protocol_get_backend(void) { + if (!s_protocol_initialized || s_mutex == NULL) { + return s_backend; + } + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) != pdTRUE) { + return s_backend; + } + printer_backend_t backend = s_backend; + xSemaphoreGive(s_mutex); + return backend; +} + +esp_err_t printer_protocol_connect(const char *target_name, uint32_t timeout_ms) { + printer_connect_options_t opt = { + .backend = printer_protocol_get_backend(), + .name = target_name, + .timeout_ms = timeout_ms, + }; + return printer_protocol_connect_ex(&opt, NULL, 0); +} + +esp_err_t printer_protocol_connect_ex(const printer_connect_options_t *opt, char *err, size_t err_len) { + if (!s_protocol_initialized || s_protocol_stopping) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer protocol unavailable"); + } + return ESP_ERR_INVALID_STATE; + } + + printer_backend_t backend = printer_protocol_get_backend(); + const char *name = NULL; + uint32_t timeout_ms = 0; + if (opt != NULL) { + backend = opt->backend; + name = opt->name; + timeout_ms = opt->timeout_ms; + } + + esp_err_t rc = printer_protocol_set_backend(backend, err, err_len); + if (rc != ESP_OK) { + return rc; + } + + if (backend == PRINTER_BACKEND_BLE) { + rc = ensure_backend_ready(PRINTER_BACKEND_BLE); + if (rc != ESP_OK) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "ble backend unavailable"); + } + return rc; + } + return ble_printer_client_connect(name, timeout_ms); + } + + if (backend == PRINTER_BACKEND_DIRECT) { + rc = ensure_backend_ready(PRINTER_BACKEND_DIRECT); + if (rc != ESP_OK) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "direct backend unavailable"); + } + return rc; + } + return platform_direct_printer_connect(timeout_ms); + } + + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "unsupported backend"); + } + return ESP_ERR_NOT_SUPPORTED; +} + +void printer_protocol_disconnect(void) { + ble_printer_client_disconnect(); + platform_direct_printer_disconnect(); +} + +void printer_protocol_get_capabilities(printer_capabilities_t *out_capabilities) { + if (out_capabilities == NULL) { + return; + } + backend_capabilities_for(printer_protocol_get_backend(), out_capabilities); +} + +void printer_protocol_get_runtime_status(printer_runtime_status_t *out_status) { + if (out_status == NULL) { + return; + } + + memset(out_status, 0, sizeof(*out_status)); + out_status->paper_gpio_level = -1; + out_status->backend = printer_protocol_get_backend(); + + printer_capabilities_t caps = {0}; + backend_capabilities_for(out_status->backend, &caps); + out_status->supports_gap_move = caps.supports_gap_move; + out_status->supports_label_offset = caps.supports_label_offset; + out_status->supports_ota = caps.supports_ota; + + if (out_status->backend == PRINTER_BACKEND_BLE) { + ble_link_state_t link = {0}; + ble_printer_client_get_link_state(&link); + out_status->connected = link.connected; + out_status->notify_ready = link.notify_ready; + out_status->transport_ready = link.connected && link.notify_ready; + out_status->mtu = link.mtu; + } else if (out_status->backend == PRINTER_BACKEND_DIRECT) { + out_status->connected = platform_direct_printer_is_connected(); + out_status->transport_ready = out_status->connected; + out_status->notify_ready = false; + out_status->mtu = 0; + + platform_printer_sensors_t sensors = {0}; + if (platform_direct_printer_get_sensors(&sensors) == ESP_OK && + s_mutex != NULL && + xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { + 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 = sensors.battery_percent; + s_status.temperature = sensors.temperature_c; + s_status.updated_ms = sensors.updated_ms; + xSemaphoreGive(s_mutex); + } + } + + if (s_mutex != NULL && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) == pdTRUE) { + out_status->busy = s_busy_refcnt > 0; + 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_percent = s_status.battery; + out_status->temperature = s_status.temperature; + out_status->last_status_ms = s_status.updated_ms; + out_status->queue_depth = (s_job_queue != NULL) + ? (uint32_t)uxQueueMessagesWaiting(s_job_queue) + : 0; + xSemaphoreGive(s_mutex); + } + + runtime_diag_set_gauge(RUNTIME_DIAG_GAUGE_PRINTER_QUEUE_DEPTH, (int32_t)out_status->queue_depth); +} diff --git a/components/domain/src/printer_protocol_commands.c b/components/domain/src/printer_protocol_commands.c new file mode 100644 index 0000000..924210c --- /dev/null +++ b/components/domain/src/printer_protocol_commands.c @@ -0,0 +1,433 @@ +#include "domain.h" +#include "printer_protocol_internal.h" + +#include +#include + +static bool is_direct_backend(void) { + return printer_protocol_get_backend() == PRINTER_BACKEND_DIRECT; +} + +static esp_err_t unsupported_for_direct(char *err, size_t err_len, const char *op) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "%s not supported on direct backend", op); + } + return ESP_ERR_NOT_SUPPORTED; +} + +static void write_err(char *err, size_t err_len, const char *msg) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "%s", msg); + } +} + +esp_err_t printer_protocol_gap_move(uint32_t timeout_ms, char *err, size_t err_len) { + if (is_direct_backend()) { + return platform_direct_printer_gap_move(timeout_ms, err, err_len); + } + + if (!ble_printer_client_is_connected()) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer not connected"); + } + return ESP_ERR_INVALID_STATE; + } + + if (!printer_protocol_acquire_control_lane(runtime_policy_printer_control_lock_timeout_ms(), err, err_len)) { + return ESP_ERR_INVALID_STATE; + } + + uint8_t payload = 0x01; + esp_err_t rc = printer_protocol_send_cmd_wait_response(CMD_GAP_MOVE, + &payload, + 1, + true, + timeout_ms, + true, + true, + NULL, + 0, + NULL, + err, + err_len); + printer_protocol_release_control_lane(); + return rc; +} + +esp_err_t printer_protocol_get_label_offset(uint8_t *out_offset, uint32_t timeout_ms, char *err, size_t err_len) { + if (is_direct_backend()) { + return unsupported_for_direct(err, err_len, "label offset"); + } + + if (out_offset == NULL) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid args"); + } + return ESP_ERR_INVALID_ARG; + } + + if (!ble_printer_client_is_connected()) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer not connected"); + } + return ESP_ERR_INVALID_STATE; + } + + if (!printer_protocol_acquire_control_lane(runtime_policy_printer_control_lock_timeout_ms(), err, err_len)) { + return ESP_ERR_INVALID_STATE; + } + + uint8_t req = 0x01; + uint8_t rsp[16]; + uint16_t rsp_len = 0; + esp_err_t rc = printer_protocol_send_cmd_wait_response(CMD_GET_OFFSET, + &req, + 1, + true, + timeout_ms, + false, + false, + rsp, + sizeof(rsp), + &rsp_len, + err, + err_len); + printer_protocol_release_control_lane(); + if (rc != ESP_OK) { + return rc; + } + if (rsp_len < 1) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid offset response"); + } + return ESP_FAIL; + } + if (rsp[0] == 0xFF) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "offset unavailable"); + } + return ESP_FAIL; + } + + *out_offset = rsp[0]; + return ESP_OK; +} + +esp_err_t printer_protocol_set_label_offset(uint8_t offset, uint32_t timeout_ms, char *err, size_t err_len) { + if (is_direct_backend()) { + return unsupported_for_direct(err, err_len, "label offset"); + } + + if (!ble_printer_client_is_connected()) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer not connected"); + } + return ESP_ERR_INVALID_STATE; + } + + if (!printer_protocol_acquire_control_lane(runtime_policy_printer_control_lock_timeout_ms(), err, err_len)) { + return ESP_ERR_INVALID_STATE; + } + + esp_err_t rc = printer_protocol_send_cmd_wait_response(CMD_SET_OFFSET, + &offset, + 1, + true, + timeout_ms, + true, + true, + NULL, + 0, + NULL, + err, + err_len); + printer_protocol_release_control_lane(); + return rc; +} + +esp_err_t printer_protocol_ota_jump_boot(uint32_t timeout_ms, char *err, size_t err_len) { + if (is_direct_backend()) { + return unsupported_for_direct(err, err_len, "ota jump boot"); + } + + if (!ble_printer_client_is_connected()) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer not connected"); + } + return ESP_ERR_INVALID_STATE; + } + + if (!printer_protocol_acquire_control_lane(runtime_policy_printer_control_lock_timeout_ms(), err, err_len)) { + return ESP_ERR_INVALID_STATE; + } + + esp_err_t rc = printer_protocol_send_cmd_wait_response(CMD_BOOT_JUMP_BOOT, + NULL, + 0, + true, + timeout_ms, + true, + true, + NULL, + 0, + NULL, + err, + err_len); + printer_protocol_release_control_lane(); + return rc; +} + +esp_err_t printer_protocol_ota_jump_app(uint32_t timeout_ms, char *err, size_t err_len) { + if (is_direct_backend()) { + return unsupported_for_direct(err, err_len, "ota jump app"); + } + + if (!ble_printer_client_is_connected()) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer not connected"); + } + return ESP_ERR_INVALID_STATE; + } + + if (!printer_protocol_acquire_control_lane(runtime_policy_printer_control_lock_timeout_ms(), err, err_len)) { + return ESP_ERR_INVALID_STATE; + } + + esp_err_t rc = printer_protocol_send_cmd_wait_response(CMD_BOOT_JUMP_APP, + NULL, + 0, + true, + timeout_ms, + true, + true, + NULL, + 0, + NULL, + err, + err_len); + printer_protocol_release_control_lane(); + return rc; +} + +esp_err_t printer_protocol_ota_erase_page(uint16_t page_num, uint32_t timeout_ms, char *err, size_t err_len) { + if (is_direct_backend()) { + return unsupported_for_direct(err, err_len, "ota erase"); + } + + if (!ble_printer_client_is_connected()) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer not connected"); + } + return ESP_ERR_INVALID_STATE; + } + + if (!printer_protocol_acquire_control_lane(runtime_policy_printer_control_lock_timeout_ms(), err, err_len)) { + return ESP_ERR_INVALID_STATE; + } + + uint8_t payload[2] = { + (uint8_t)((page_num >> 8) & 0xFF), + (uint8_t)(page_num & 0xFF), + }; + + esp_err_t rc = printer_protocol_send_cmd_wait_response(CMD_BOOT_ERASE_PAGE, + payload, + sizeof(payload), + true, + timeout_ms, + true, + true, + NULL, + 0, + NULL, + err, + err_len); + printer_protocol_release_control_lane(); + return rc; +} + +esp_err_t printer_protocol_ota_write_frame(uint16_t packet_num, + bool is_last_frame, + const uint8_t *data, + size_t data_len, + uint32_t timeout_ms, + char *err, + size_t err_len) { + if (is_direct_backend()) { + return unsupported_for_direct(err, err_len, "ota write"); + } + + if ((data_len > 0 && data == NULL) || data_len > OTA_MAX_DATA_PER_FRAME) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid frame length"); + } + return ESP_ERR_INVALID_ARG; + } + + if (!ble_printer_client_is_connected()) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer not connected"); + } + return ESP_ERR_INVALID_STATE; + } + + if (!printer_protocol_acquire_control_lane(runtime_policy_printer_control_lock_timeout_ms(), err, err_len)) { + return ESP_ERR_INVALID_STATE; + } + + uint8_t payload[3 + OTA_MAX_DATA_PER_FRAME]; + payload[0] = (uint8_t)((packet_num >> 8) & 0xFF); + payload[1] = (uint8_t)(packet_num & 0xFF); + payload[2] = is_last_frame ? 0x01 : 0x00; + if (data_len > 0) { + memcpy(&payload[3], data, data_len); + } + + esp_err_t rc = printer_protocol_send_cmd_wait_response(CMD_BOOT_WRITE_DATA, + payload, + (uint16_t)(3 + data_len), + true, + timeout_ms, + true, + true, + NULL, + 0, + NULL, + err, + err_len); + printer_protocol_release_control_lane(); + return rc; +} + +esp_err_t printer_protocol_ota_get_version(printer_ota_version_t *out_version, + uint32_t timeout_ms, + char *err, + size_t err_len) { + if (is_direct_backend()) { + return unsupported_for_direct(err, err_len, "ota version"); + } + + if (out_version == NULL) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid args"); + } + return ESP_ERR_INVALID_ARG; + } + + if (!ble_printer_client_is_connected()) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer not connected"); + } + return ESP_ERR_INVALID_STATE; + } + + if (!printer_protocol_acquire_control_lane(runtime_policy_printer_control_lock_timeout_ms(), err, err_len)) { + return ESP_ERR_INVALID_STATE; + } + + uint8_t rsp[16]; + uint16_t rsp_len = 0; + esp_err_t rc = printer_protocol_send_cmd_wait_response(CMD_BOOT_GET_VERSION, + NULL, + 0, + true, + timeout_ms, + false, + false, + rsp, + sizeof(rsp), + &rsp_len, + err, + err_len); + printer_protocol_release_control_lane(); + if (rc != ESP_OK) { + return rc; + } + + if (rsp_len < 3) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid version response"); + } + return ESP_FAIL; + } + + out_version->major = rsp[0]; + out_version->minor = rsp[1]; + out_version->patch = rsp[2]; + return ESP_OK; +} + +esp_err_t printer_protocol_get_direct_debug_config(printer_direct_debug_config_t *out_config, + char *err, + size_t err_len) { + if (out_config == NULL) { + write_err(err, err_len, "invalid args"); + return ESP_ERR_INVALID_ARG; + } + if (!runtime_policy_direct_printer_enabled()) { + write_err(err, err_len, "direct backend disabled"); + return ESP_ERR_NOT_SUPPORTED; + } + + esp_err_t rc = platform_direct_printer_get_debug_config(&out_config->shift_clock_high_us, + &out_config->shift_clock_low_us, + &out_config->latch_pulse_us, + &out_config->strobe_active_high, + &out_config->boost_active_high, + &out_config->override_strobe_on_us, + &out_config->override_strobe_interval_us, + &out_config->override_motor_step_us, + &out_config->override_steps_per_line); + if (rc != ESP_OK) { + write_err(err, err_len, "get direct debug config failed"); + return rc; + } + return ESP_OK; +} + +esp_err_t printer_protocol_set_direct_debug_config(const printer_direct_debug_config_t *config, + bool reset_defaults, + char *err, + size_t err_len) { + if (config == NULL && !reset_defaults) { + write_err(err, err_len, "invalid args"); + return ESP_ERR_INVALID_ARG; + } + if (!runtime_policy_direct_printer_enabled()) { + write_err(err, err_len, "direct backend disabled"); + return ESP_ERR_NOT_SUPPORTED; + } + + return platform_direct_printer_set_debug_config(config != NULL, + config != NULL ? config->shift_clock_high_us : 0, + config != NULL ? config->shift_clock_low_us : 0, + config != NULL ? config->latch_pulse_us : 0, + config != NULL ? config->strobe_active_high : false, + config != NULL ? config->boost_active_high : false, + config != NULL ? config->override_strobe_on_us : 0, + config != NULL ? config->override_strobe_interval_us : 0, + config != NULL ? config->override_motor_step_us : 0, + config != NULL ? config->override_steps_per_line : 0, + reset_defaults, + err, + err_len); +} + +const char *printer_protocol_job_state_str(print_job_state_t state) { + switch (state) { + case PRINT_JOB_STATE_NONE: + return "none"; + case PRINT_JOB_STATE_QUEUED: + return "queued"; + case PRINT_JOB_STATE_RUNNING: + return "running"; + case PRINT_JOB_STATE_SUCCESS: + return "success"; + case PRINT_JOB_STATE_FAILED: + return "failed"; + case PRINT_JOB_STATE_CANCELED: + return "canceled"; + default: + return "unknown"; + } +} diff --git a/components/domain/src/printer_protocol_jobs.c b/components/domain/src/printer_protocol_jobs.c new file mode 100644 index 0000000..dcbff03 --- /dev/null +++ b/components/domain/src/printer_protocol_jobs.c @@ -0,0 +1,358 @@ +#include "domain.h" +#include "printer_protocol_internal.h" + +#include +#include +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "printer_protocol"; + +int printer_protocol_find_job_idx_locked(uint32_t id) { + for (int i = 0; i < JOB_SLOT_MAX; ++i) { + if (s_jobs[i].used && s_jobs[i].id == id) { + return i; + } + } + return -1; +} + +int printer_protocol_alloc_job_slot_locked(void) { + for (int i = 0; i < JOB_SLOT_MAX; ++i) { + if (!s_jobs[i].used) { + return i; + } + } + + /* Reuse completed slot when table is full. */ + for (int i = 0; i < JOB_SLOT_MAX; ++i) { + if (s_jobs[i].state == PRINT_JOB_STATE_SUCCESS || + s_jobs[i].state == PRINT_JOB_STATE_FAILED || + s_jobs[i].state == PRINT_JOB_STATE_CANCELED) { + free(s_jobs[i].data); + memset(&s_jobs[i], 0, sizeof(s_jobs[i])); + return i; + } + } + + return -1; +} + +bool printer_protocol_is_terminal_state(print_job_state_t state) { + return state == PRINT_JOB_STATE_SUCCESS || + state == PRINT_JOB_STATE_FAILED || + state == PRINT_JOB_STATE_CANCELED; +} + +esp_err_t printer_protocol_submit_raster_job_ex(const uint8_t *raster, + size_t raster_len, + uint16_t width, + uint16_t height, + const char *density, + const printer_print_options_t *options, + uint32_t *out_job_id, + char *err, + size_t err_len) { + if (raster == NULL || raster_len == 0 || width == 0 || height == 0) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid args"); + } + return ESP_ERR_INVALID_ARG; + } + + if (!s_protocol_initialized || s_protocol_stopping) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "printer protocol unavailable"); + } + return ESP_ERR_INVALID_STATE; + } + + if (width != 384) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "width must be 384"); + } + return ESP_ERR_INVALID_ARG; + } + + if (raster_len > MAX_RASTER_BYTES) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "raster too large"); + } + return ESP_ERR_INVALID_SIZE; + } + + size_t expected_len = ((size_t)width / 8u) * (size_t)height; + if (expected_len != raster_len) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "size mismatch exp=%u got=%u", + (unsigned)expected_len, + (unsigned)raster_len); + } + return ESP_ERR_INVALID_SIZE; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(runtime_policy_printer_control_lock_timeout_ms())) != pdTRUE) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "lock timeout"); + } + return ESP_ERR_TIMEOUT; + } + + int idx = printer_protocol_alloc_job_slot_locked(); + if (idx < 0) { + xSemaphoreGive(s_mutex); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "job table full"); + } + return ESP_ERR_NO_MEM; + } + + uint8_t *copy = (uint8_t *)malloc(raster_len); + if (copy == NULL) { + xSemaphoreGive(s_mutex); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "malloc failed"); + } + return ESP_ERR_NO_MEM; + } + + memcpy(copy, raster, raster_len); + + uint32_t id = s_next_job_id++; + if (s_next_job_id == 0) { + s_next_job_id = 1; + } + + memset(&s_jobs[idx], 0, sizeof(s_jobs[idx])); + s_jobs[idx].used = true; + s_jobs[idx].id = id; + s_jobs[idx].state = PRINT_JOB_STATE_QUEUED; + s_jobs[idx].progress = 0; + s_jobs[idx].width = width; + s_jobs[idx].height = height; + s_jobs[idx].data_len = raster_len; + s_jobs[idx].direct_ignore_precheck = (options != NULL && options->direct_ignore_precheck); + s_jobs[idx].created_ms = esp_timer_get_time() / 1000; + s_jobs[idx].data = copy; + strlcpy(s_jobs[idx].density, density != NULL ? density : "中等", sizeof(s_jobs[idx].density)); + bool ignore_precheck = s_jobs[idx].direct_ignore_precheck; + + xSemaphoreGive(s_mutex); + + if (xQueueSend(s_job_queue, &id, pdMS_TO_TICKS(500)) != pdTRUE) { + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(500)) == pdTRUE) { + int rollback_idx = printer_protocol_find_job_idx_locked(id); + if (rollback_idx >= 0) { + free(s_jobs[rollback_idx].data); + memset(&s_jobs[rollback_idx], 0, sizeof(s_jobs[rollback_idx])); + } + xSemaphoreGive(s_mutex); + } + + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "job queue full"); + } + ESP_LOGW(TAG, + "job enqueue failed, job_id=%u, reason=%s", + (unsigned)id, + (err != NULL && err[0] != '\0') ? err : "job queue full"); + return ESP_ERR_TIMEOUT; + } + + if (out_job_id != NULL) { + *out_job_id = id; + } + + UBaseType_t queue_depth = uxQueueMessagesWaiting(s_job_queue); + ESP_LOGI(TAG, + "job queued, job_id=%u, raster_bytes=%u, size=%ux%u, density=%s, ignore_precheck=%d, queue_depth=%u", + (unsigned)id, + (unsigned)raster_len, + (unsigned)width, + (unsigned)height, + density != NULL ? density : "中等", + ignore_precheck, + (unsigned)queue_depth); + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_PRINTER_JOB_SUBMITTED, 1); + runtime_diag_set_gauge(RUNTIME_DIAG_GAUGE_PRINTER_QUEUE_DEPTH, (int32_t)queue_depth); + + return ESP_OK; +} + +esp_err_t printer_protocol_submit_raster_job(const uint8_t *raster, + size_t raster_len, + uint16_t width, + uint16_t height, + const char *density, + uint32_t *out_job_id, + char *err, + size_t err_len) { + return printer_protocol_submit_raster_job_ex(raster, + raster_len, + width, + height, + density, + NULL, + out_job_id, + err, + err_len); +} + +bool printer_protocol_get_job(uint32_t job_id, print_job_info_t *out_info) { + if (out_info == NULL || job_id == 0) { + return false; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(500)) != pdTRUE) { + return false; + } + + int idx = printer_protocol_find_job_idx_locked(job_id); + if (idx < 0) { + xSemaphoreGive(s_mutex); + return false; + } + + memset(out_info, 0, sizeof(*out_info)); + + out_info->id = s_jobs[idx].id; + out_info->state = s_jobs[idx].state; + out_info->progress = s_jobs[idx].progress; + out_info->width = s_jobs[idx].width; + out_info->height = s_jobs[idx].height; + out_info->data_len = s_jobs[idx].data_len; + out_info->created_ms = s_jobs[idx].created_ms; + out_info->started_ms = s_jobs[idx].started_ms; + out_info->finished_ms = s_jobs[idx].finished_ms; + strlcpy(out_info->density, s_jobs[idx].density, sizeof(out_info->density)); + strlcpy(out_info->error, s_jobs[idx].error, sizeof(out_info->error)); + + xSemaphoreGive(s_mutex); + return true; +} + +esp_err_t printer_protocol_list_jobs(print_job_info_t *out_jobs, size_t max_jobs, size_t *out_count) { + if (out_count == NULL) { + return ESP_ERR_INVALID_ARG; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(500)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + size_t count = 0; + for (int i = 0; i < JOB_SLOT_MAX; ++i) { + if (!s_jobs[i].used || s_jobs[i].state == PRINT_JOB_STATE_NONE) { + continue; + } + if (out_jobs != NULL && count < max_jobs) { + memset(&out_jobs[count], 0, sizeof(out_jobs[count])); + out_jobs[count].id = s_jobs[i].id; + out_jobs[count].state = s_jobs[i].state; + out_jobs[count].progress = s_jobs[i].progress; + out_jobs[count].width = s_jobs[i].width; + out_jobs[count].height = s_jobs[i].height; + out_jobs[count].data_len = s_jobs[i].data_len; + out_jobs[count].created_ms = s_jobs[i].created_ms; + out_jobs[count].started_ms = s_jobs[i].started_ms; + out_jobs[count].finished_ms = s_jobs[i].finished_ms; + strlcpy(out_jobs[count].density, s_jobs[i].density, sizeof(out_jobs[count].density)); + strlcpy(out_jobs[count].error, s_jobs[i].error, sizeof(out_jobs[count].error)); + } + ++count; + } + + xSemaphoreGive(s_mutex); + *out_count = count; + return ESP_OK; +} + +esp_err_t printer_protocol_cancel_job(uint32_t job_id, char *err, size_t err_len) { + if (job_id == 0) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid job id"); + } + return ESP_ERR_INVALID_ARG; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(500)) != pdTRUE) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "lock timeout"); + } + return ESP_ERR_TIMEOUT; + } + + int idx = printer_protocol_find_job_idx_locked(job_id); + if (idx < 0) { + xSemaphoreGive(s_mutex); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "job not found"); + } + return ESP_ERR_NOT_FOUND; + } + + if (printer_protocol_is_terminal_state(s_jobs[idx].state)) { + xSemaphoreGive(s_mutex); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "job already finished"); + } + return ESP_ERR_INVALID_STATE; + } + + s_jobs[idx].cancel_requested = true; + if (s_jobs[idx].state == PRINT_JOB_STATE_QUEUED) { + s_jobs[idx].state = PRINT_JOB_STATE_CANCELED; + s_jobs[idx].finished_ms = esp_timer_get_time() / 1000; + s_jobs[idx].progress = 0; + strlcpy(s_jobs[idx].error, "job canceled", sizeof(s_jobs[idx].error)); + free(s_jobs[idx].data); + s_jobs[idx].data = NULL; + } else if (s_jobs[idx].state == PRINT_JOB_STATE_RUNNING) { + strlcpy(s_jobs[idx].error, "cancel requested", sizeof(s_jobs[idx].error)); + } + + xSemaphoreGive(s_mutex); + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_PRINTER_JOB_CANCELED, 1); + return ESP_OK; +} + +size_t printer_protocol_cleanup_jobs(bool include_success, bool include_failed, bool include_canceled) { + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(500)) != pdTRUE) { + return 0; + } + + size_t removed = 0; + for (int i = 0; i < JOB_SLOT_MAX; ++i) { + if (!s_jobs[i].used) { + continue; + } + + bool match = false; + if (include_success && s_jobs[i].state == PRINT_JOB_STATE_SUCCESS) { + match = true; + } + if (include_failed && s_jobs[i].state == PRINT_JOB_STATE_FAILED) { + match = true; + } + if (include_canceled && s_jobs[i].state == PRINT_JOB_STATE_CANCELED) { + match = true; + } + + if (!match) { + continue; + } + + free(s_jobs[i].data); + memset(&s_jobs[i], 0, sizeof(s_jobs[i])); + ++removed; + } + + xSemaphoreGive(s_mutex); + runtime_diag_set_gauge(RUNTIME_DIAG_GAUGE_PRINTER_QUEUE_DEPTH, + s_job_queue != NULL ? (int32_t)uxQueueMessagesWaiting(s_job_queue) : 0); + return removed; +} diff --git a/components/domain/src/printer_protocol_worker.c b/components/domain/src/printer_protocol_worker.c new file mode 100644 index 0000000..df44d2e --- /dev/null +++ b/components/domain/src/printer_protocol_worker.c @@ -0,0 +1,464 @@ +#include "domain.h" +#include "printer_protocol_internal.h" + +#include +#include +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "printer_protocol"; + +static bool request_status_sync(uint32_t timeout_ms) { + int64_t old_ms; + + if (printer_protocol_get_backend() != PRINTER_BACKEND_BLE) { + return false; + } + + if (printer_protocol_is_stopping()) { + return false; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) != pdTRUE) { + return false; + } + old_ms = s_status.updated_ms; + xSemaphoreGive(s_mutex); + + uint8_t payload = 0x00; + if (printer_protocol_send_frame(CMD_GET_STATUS, &payload, 0, true) != ESP_OK) { + return false; + } + + int64_t deadline = esp_timer_get_time() / 1000 + timeout_ms; + while ((esp_timer_get_time() / 1000) < deadline) { + if (printer_protocol_is_stopping()) { + return false; + } + + vTaskDelay(pdMS_TO_TICKS(20)); + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) != pdTRUE) { + continue; + } + + bool updated = s_status.updated_ms > old_ms; + xSemaphoreGive(s_mutex); + + if (updated) { + return true; + } + } + + return false; +} + +static uint16_t density_to_hot_time(const char *density) { + if (density == NULL) { + return 2000; + } + if (strcmp(density, "较淡") == 0) { + return 1000; + } + if (strcmp(density, "中等") == 0) { + return 1500; + } + if (strcmp(density, "较浓") == 0) { + return 2000; + } + if (strcmp(density, "最深") == 0) { + return 3000; + } + return 2000; +} + +static bool precheck_printer_ready(bool direct_ignore_precheck, char *err, size_t err_len) { + if (printer_protocol_get_backend() == PRINTER_BACKEND_BLE) { + if (!request_status_sync(1200)) { + snprintf(err, err_len, "status timeout"); + return false; + } + + parsed_status_t status; + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) != pdTRUE) { + snprintf(err, err_len, "status lock failed"); + return false; + } + + status = s_status; + xSemaphoreGive(s_mutex); + + if (!status.has_paper) { + snprintf(err, err_len, "printer out of paper"); + return false; + } + + if (status.battery <= 40) { + snprintf(err, err_len, "battery too low"); + return false; + } + + if (status.temperature >= 60.0f) { + snprintf(err, err_len, "temperature too high"); + return false; + } + return true; + } + + if (direct_ignore_precheck) { + return true; + } + + platform_printer_sensors_t sensors = {0}; + if (platform_direct_printer_get_sensors(&sensors) != ESP_OK) { + snprintf(err, err_len, "sensor read failed"); + return false; + } + if (!sensors.has_paper) { + snprintf(err, err_len, "printer out of paper"); + return false; + } + + float temp_min = runtime_policy_direct_printer_temp_min_c(); + float temp_max = runtime_policy_direct_printer_temp_max_c(); + if (temp_max <= temp_min) { + temp_max = temp_min + 1.0f; + } + if (sensors.temperature_c < temp_min || sensors.temperature_c > temp_max) { + snprintf(err, err_len, "temperature out of range"); + return false; + } + + if (sensors.battery_percent < runtime_policy_direct_printer_battery_min_percent()) { + snprintf(err, err_len, "battery too low"); + return false; + } + return true; +} + +static bool run_print_job_ble(job_slot_t *job) { + char err[96]; + err[0] = '\0'; + uint8_t next_progress_log = 25; + + if (!ble_printer_client_is_connected()) { + snprintf(job->error, sizeof(job->error), "printer not connected"); + ESP_LOGW(TAG, "job %u print aborted: %s", (unsigned)job->id, job->error); + return false; + } + + if (!precheck_printer_ready(false, err, sizeof(err))) { + snprintf(job->error, sizeof(job->error), "%s", err); + ESP_LOGW(TAG, "job %u print aborted: %s", (unsigned)job->id, job->error); + return false; + } + + if (job->cancel_requested) { + snprintf(job->error, sizeof(job->error), "job canceled"); + ESP_LOGW(TAG, "job %u print canceled before start", (unsigned)job->id); + return false; + } + + uint8_t power_on = 0x01; + if (!printer_protocol_send_cmd_with_ack(CMD_POWER, &power_on, 1, true, 1500, true)) { + snprintf(job->error, sizeof(job->error), "power on failed"); + ESP_LOGW(TAG, "job %u print aborted: %s", (unsigned)job->id, job->error); + return false; + } + + uint16_t hot_time = density_to_hot_time(job->density); + uint8_t param[4] = { + 0x01, + 0x02, + (uint8_t)((hot_time >> 8) & 0xFF), + (uint8_t)(hot_time & 0xFF), + }; + + if (!printer_protocol_send_cmd_with_ack(CMD_SET_PARAM, param, sizeof(param), true, 1500, true)) { + snprintf(job->error, sizeof(job->error), "set print param failed"); + ESP_LOGW(TAG, "job %u print aborted: %s", (unsigned)job->id, job->error); + return false; + } + + const size_t chunk_max = 240; + size_t total_chunks = (size_t)ceil((double)job->data_len / (double)chunk_max); + ESP_LOGI(TAG, + "job %u data transfer start, chunks=%u, chunk_bytes=%u, hot_time=%u", + (unsigned)job->id, + (unsigned)total_chunks, + (unsigned)chunk_max, + (unsigned)hot_time); + + for (size_t i = 0; i < total_chunks; ++i) { + if (printer_protocol_is_stopping()) { + snprintf(job->error, sizeof(job->error), "protocol stopping"); + return false; + } + + if (job->cancel_requested) { + snprintf(job->error, sizeof(job->error), "job canceled"); + ESP_LOGW(TAG, + "job %u print canceled at chunk %u/%u", + (unsigned)job->id, + (unsigned)(i + 1), + (unsigned)total_chunks); + return false; + } + + size_t start = i * chunk_max; + size_t remain = job->data_len - start; + size_t chunk_len = remain > chunk_max ? chunk_max : remain; + + if (!printer_protocol_send_cmd_with_ack(CMD_SEND_DATA, + &job->data[start], + (uint16_t)chunk_len, + false, + 2500, + true)) { + snprintf(job->error, sizeof(job->error), "send chunk timeout at %u", (unsigned)i); + ESP_LOGW(TAG, "job %u print aborted: %s", (unsigned)job->id, job->error); + return false; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) == pdTRUE) { + job->progress = (uint8_t)(((i + 1) * 90) / total_chunks); + if (job->progress < 5) { + job->progress = 5; + } + xSemaphoreGive(s_mutex); + } + + while (job->progress >= next_progress_log && next_progress_log <= 90) { + ESP_LOGI(TAG, + "job %u print progress=%u%% (%u/%u chunks)", + (unsigned)job->id, + (unsigned)job->progress, + (unsigned)(i + 1), + (unsigned)total_chunks); + next_progress_log = (uint8_t)(next_progress_log + 25); + } + } + + ESP_LOGI(TAG, "job %u data transfer done", (unsigned)job->id); + + uint8_t power_off = 0x00; + (void)printer_protocol_send_cmd_with_ack(CMD_POWER, &power_off, 1, true, 1200, false); + + uint8_t feed_payload[3] = {0x2B, 0x00, 0x0C}; + (void)printer_protocol_send_cmd_with_ack(CMD_SET_DISTANCE, + feed_payload, + sizeof(feed_payload), + true, + 1200, + false); + + ESP_LOGI(TAG, "job %u print command sequence done", (unsigned)job->id); + return true; +} + +static bool run_print_job_direct(job_slot_t *job) { + if (!platform_direct_printer_is_connected()) { + snprintf(job->error, sizeof(job->error), "printer not connected"); + return false; + } + + char check_err[96]; + if (!precheck_printer_ready(job->direct_ignore_precheck, check_err, sizeof(check_err))) { + snprintf(job->error, sizeof(job->error), "%s", check_err); + return false; + } + + platform_direct_print_request_t req = { + .width = job->width, + .height = job->height, + .raster = job->data, + .raster_len = job->data_len, + .strobe_on_time_us = density_to_hot_time(job->density), + .strobe_interval_us = runtime_policy_direct_printer_strobe_interval_us(), + .motor_step_delay_us = runtime_policy_direct_printer_motor_step_us(), + .motor_steps_per_line = runtime_policy_direct_printer_steps_per_line(), + .timeout_ms = runtime_policy_direct_printer_operation_timeout_ms(), + .ignore_precheck = job->direct_ignore_precheck, + .cancel_flag = &job->cancel_requested, + }; + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) == pdTRUE) { + job->progress = 10; + xSemaphoreGive(s_mutex); + } + + char direct_err[96] = {0}; + esp_err_t rc = platform_direct_printer_print(&req, direct_err, sizeof(direct_err)); + if (rc != ESP_OK) { + if (direct_err[0] != '\0') { + snprintf(job->error, sizeof(job->error), "%s", direct_err); + } else if (rc == ESP_ERR_TIMEOUT) { + snprintf(job->error, sizeof(job->error), "print timeout"); + } else if (rc == ESP_ERR_INVALID_STATE && job->cancel_requested) { + snprintf(job->error, sizeof(job->error), "job canceled"); + } else { + snprintf(job->error, sizeof(job->error), "direct print failed"); + } + return false; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(200)) == pdTRUE) { + job->progress = 95; + xSemaphoreGive(s_mutex); + } + return true; +} + +static bool run_print_job(job_slot_t *job) { + ESP_LOGI(TAG, + "job %u print start, backend=%s, raster_bytes=%u, size=%ux%u, density=%s, ignore_precheck=%d", + (unsigned)job->id, + printer_protocol_get_backend() == PRINTER_BACKEND_BLE ? "ble" : "direct", + (unsigned)job->data_len, + (unsigned)job->width, + (unsigned)job->height, + job->density, + job->direct_ignore_precheck); + + if (printer_protocol_is_stopping()) { + snprintf(job->error, sizeof(job->error), "protocol stopping"); + return false; + } + + if (printer_protocol_get_backend() == PRINTER_BACKEND_BLE) { + return run_print_job_ble(job); + } + return run_print_job_direct(job); +} + +void printer_protocol_worker_task(void *arg) { + (void)arg; + + while (true) { + if (printer_protocol_is_stopping()) { + break; + } + + uint32_t job_id = 0; + if (xQueueReceive(s_job_queue, + &job_id, + pdMS_TO_TICKS(runtime_policy_printer_worker_queue_wait_ms())) != pdTRUE) { + continue; + } + + if (job_id == PRINTER_JOB_SENTINEL_STOP || printer_protocol_is_stopping()) { + break; + } + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + continue; + } + + int idx = printer_protocol_find_job_idx_locked(job_id); + if (idx < 0) { + xSemaphoreGive(s_mutex); + continue; + } + + if (s_jobs[idx].state == PRINT_JOB_STATE_CANCELED) { + free(s_jobs[idx].data); + s_jobs[idx].data = NULL; + if (s_jobs[idx].finished_ms == 0) { + s_jobs[idx].finished_ms = esp_timer_get_time() / 1000; + } + xSemaphoreGive(s_mutex); + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_PRINTER_JOB_CANCELED, 1); + continue; + } + + if (s_busy_refcnt != 0) { + xSemaphoreGive(s_mutex); + (void)xQueueSendToFront(s_job_queue, &job_id, 0); + vTaskDelay(pdMS_TO_TICKS(runtime_policy_printer_queue_retry_delay_ms())); + continue; + } + + job_slot_t *job = &s_jobs[idx]; + job->state = PRINT_JOB_STATE_RUNNING; + job->started_ms = esp_timer_get_time() / 1000; + job->progress = 1; + s_busy_refcnt = 1; + int64_t queue_wait_ms = 0; + if (job->started_ms >= job->created_ms) { + queue_wait_ms = job->started_ms - job->created_ms; + } + ESP_LOGI(TAG, + "job %u running, queue_wait_ms=%lld, raster_bytes=%u, size=%ux%u, density=%s", + (unsigned)job->id, + (long long)queue_wait_ms, + (unsigned)job->data_len, + (unsigned)job->width, + (unsigned)job->height, + job->density); + xSemaphoreGive(s_mutex); + + bool ok = run_print_job(job); + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + continue; + } + + if (s_busy_refcnt > 0) { + --s_busy_refcnt; + } + s_jobs[idx].finished_ms = esp_timer_get_time() / 1000; + if (s_jobs[idx].state != PRINT_JOB_STATE_CANCELED) { + s_jobs[idx].progress = 100; + } + + if (ok) { + s_jobs[idx].state = PRINT_JOB_STATE_SUCCESS; + s_jobs[idx].error[0] = '\0'; + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_PRINTER_JOB_SUCCESS, 1); + } else if (s_jobs[idx].cancel_requested || printer_protocol_is_stopping()) { + s_jobs[idx].state = PRINT_JOB_STATE_CANCELED; + if (s_jobs[idx].error[0] == '\0') { + strlcpy(s_jobs[idx].error, "job canceled", sizeof(s_jobs[idx].error)); + } + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_PRINTER_JOB_CANCELED, 1); + } else { + s_jobs[idx].state = PRINT_JOB_STATE_FAILED; + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_PRINTER_JOB_FAILED, 1); + } + + int64_t duration_ms = 0; + if (s_jobs[idx].finished_ms >= s_jobs[idx].started_ms) { + duration_ms = s_jobs[idx].finished_ms - s_jobs[idx].started_ms; + } + if (s_jobs[idx].state == PRINT_JOB_STATE_SUCCESS) { + ESP_LOGI(TAG, + "job %u done, state=%s, duration_ms=%lld", + (unsigned)s_jobs[idx].id, + printer_protocol_job_state_str(s_jobs[idx].state), + (long long)duration_ms); + } else { + ESP_LOGW(TAG, + "job %u done, state=%s, duration_ms=%lld, error=%s", + (unsigned)s_jobs[idx].id, + printer_protocol_job_state_str(s_jobs[idx].state), + (long long)duration_ms, + s_jobs[idx].error[0] != '\0' ? s_jobs[idx].error : "-"); + } + + free(s_jobs[idx].data); + s_jobs[idx].data = NULL; + runtime_diag_set_gauge(RUNTIME_DIAG_GAUGE_PRINTER_QUEUE_DEPTH, + (int32_t)uxQueueMessagesWaiting(s_job_queue)); + xSemaphoreGive(s_mutex); + } + + if (s_evt != NULL) { + xEventGroupSetBits(s_evt, EVT_WORKER_EXITED); + } + s_worker_task = NULL; + vTaskDelete(NULL); +} diff --git a/components/domain/src/raster_tools.c b/components/domain/src/raster_tools.c new file mode 100644 index 0000000..f2a7092 --- /dev/null +++ b/components/domain/src/raster_tools.c @@ -0,0 +1,467 @@ +#include "domain.h" + +#include +#include +#include +#include + +#define RASTER_WIDTH 384 +#define RASTER_BYTES_PER_ROW (RASTER_WIDTH / 8) +#define CN16_GLYPH_BYTES 32u +#define CN16_INDEX_ENTRY_BYTES 6u +#define UTF8_REPLACEMENT_CODEPOINT 0x003Fu + +extern const uint8_t _binary_cn16_index_bin_start[] asm("_binary_cn16_index_bin_start"); +extern const uint8_t _binary_cn16_index_bin_end[] asm("_binary_cn16_index_bin_end"); +extern const uint8_t _binary_cn16_glyphs_bin_start[] asm("_binary_cn16_glyphs_bin_start"); +extern const uint8_t _binary_cn16_glyphs_bin_end[] asm("_binary_cn16_glyphs_bin_end"); + +/* 5x7 ASCII font table for characters 0x20..0x7E. */ +static const uint8_t font5x7[] = { + 0x00, 0x00, 0x00, 0x00, 0x00, /* space */ + 0x00, 0x00, 0x5F, 0x00, 0x00, /* ! */ + 0x00, 0x07, 0x00, 0x07, 0x00, /* " */ + 0x14, 0x7F, 0x14, 0x7F, 0x14, /* # */ + 0x24, 0x2A, 0x7F, 0x2A, 0x12, /* $ */ + 0x23, 0x13, 0x08, 0x64, 0x62, /* % */ + 0x36, 0x49, 0x55, 0x22, 0x50, /* & */ + 0x00, 0x05, 0x03, 0x00, 0x00, /* ' */ + 0x00, 0x1C, 0x22, 0x41, 0x00, /* ( */ + 0x00, 0x41, 0x22, 0x1C, 0x00, /* ) */ + 0x14, 0x08, 0x3E, 0x08, 0x14, /* * */ + 0x08, 0x08, 0x3E, 0x08, 0x08, /* + */ + 0x00, 0x50, 0x30, 0x00, 0x00, /* , */ + 0x08, 0x08, 0x08, 0x08, 0x08, /* - */ + 0x00, 0x60, 0x60, 0x00, 0x00, /* . */ + 0x20, 0x10, 0x08, 0x04, 0x02, /* / */ + 0x3E, 0x51, 0x49, 0x45, 0x3E, /* 0 */ + 0x00, 0x42, 0x7F, 0x40, 0x00, /* 1 */ + 0x42, 0x61, 0x51, 0x49, 0x46, /* 2 */ + 0x21, 0x41, 0x45, 0x4B, 0x31, /* 3 */ + 0x18, 0x14, 0x12, 0x7F, 0x10, /* 4 */ + 0x27, 0x45, 0x45, 0x45, 0x39, /* 5 */ + 0x3C, 0x4A, 0x49, 0x49, 0x30, /* 6 */ + 0x01, 0x71, 0x09, 0x05, 0x03, /* 7 */ + 0x36, 0x49, 0x49, 0x49, 0x36, /* 8 */ + 0x06, 0x49, 0x49, 0x29, 0x1E, /* 9 */ + 0x00, 0x36, 0x36, 0x00, 0x00, /* : */ + 0x00, 0x56, 0x36, 0x00, 0x00, /* ; */ + 0x08, 0x14, 0x22, 0x41, 0x00, /* < */ + 0x14, 0x14, 0x14, 0x14, 0x14, /* = */ + 0x00, 0x41, 0x22, 0x14, 0x08, /* > */ + 0x02, 0x01, 0x51, 0x09, 0x06, /* ? */ + 0x32, 0x49, 0x79, 0x41, 0x3E, /* @ */ + 0x7E, 0x11, 0x11, 0x11, 0x7E, /* A */ + 0x7F, 0x49, 0x49, 0x49, 0x36, /* B */ + 0x3E, 0x41, 0x41, 0x41, 0x22, /* C */ + 0x7F, 0x41, 0x41, 0x22, 0x1C, /* D */ + 0x7F, 0x49, 0x49, 0x49, 0x41, /* E */ + 0x7F, 0x09, 0x09, 0x09, 0x01, /* F */ + 0x3E, 0x41, 0x49, 0x49, 0x7A, /* G */ + 0x7F, 0x08, 0x08, 0x08, 0x7F, /* H */ + 0x00, 0x41, 0x7F, 0x41, 0x00, /* I */ + 0x20, 0x40, 0x41, 0x3F, 0x01, /* J */ + 0x7F, 0x08, 0x14, 0x22, 0x41, /* K */ + 0x7F, 0x40, 0x40, 0x40, 0x40, /* L */ + 0x7F, 0x02, 0x0C, 0x02, 0x7F, /* M */ + 0x7F, 0x04, 0x08, 0x10, 0x7F, /* N */ + 0x3E, 0x41, 0x41, 0x41, 0x3E, /* O */ + 0x7F, 0x09, 0x09, 0x09, 0x06, /* P */ + 0x3E, 0x41, 0x51, 0x21, 0x5E, /* Q */ + 0x7F, 0x09, 0x19, 0x29, 0x46, /* R */ + 0x46, 0x49, 0x49, 0x49, 0x31, /* S */ + 0x01, 0x01, 0x7F, 0x01, 0x01, /* T */ + 0x3F, 0x40, 0x40, 0x40, 0x3F, /* U */ + 0x1F, 0x20, 0x40, 0x20, 0x1F, /* V */ + 0x7F, 0x20, 0x18, 0x20, 0x7F, /* W */ + 0x63, 0x14, 0x08, 0x14, 0x63, /* X */ + 0x03, 0x04, 0x78, 0x04, 0x03, /* Y */ + 0x61, 0x51, 0x49, 0x45, 0x43, /* Z */ + 0x00, 0x00, 0x7F, 0x41, 0x41, /* [ */ + 0x02, 0x04, 0x08, 0x10, 0x20, /* \\ */ + 0x41, 0x41, 0x7F, 0x00, 0x00, /* ] */ + 0x04, 0x02, 0x01, 0x02, 0x04, /* ^ */ + 0x80, 0x80, 0x80, 0x80, 0x80, /* _ */ + 0x00, 0x03, 0x05, 0x00, 0x00, /* ` */ + 0x20, 0x54, 0x54, 0x54, 0x78, /* a */ + 0x7F, 0x48, 0x44, 0x44, 0x38, /* b */ + 0x38, 0x44, 0x44, 0x44, 0x20, /* c */ + 0x38, 0x44, 0x44, 0x48, 0x7F, /* d */ + 0x38, 0x54, 0x54, 0x54, 0x18, /* e */ + 0x08, 0x7E, 0x09, 0x01, 0x02, /* f */ + 0x0C, 0x52, 0x52, 0x52, 0x3E, /* g */ + 0x7F, 0x08, 0x04, 0x04, 0x78, /* h */ + 0x00, 0x44, 0x7D, 0x40, 0x00, /* i */ + 0x20, 0x40, 0x44, 0x3D, 0x00, /* j */ + 0x7F, 0x10, 0x28, 0x44, 0x00, /* k */ + 0x00, 0x41, 0x7F, 0x40, 0x00, /* l */ + 0x7C, 0x04, 0x18, 0x04, 0x78, /* m */ + 0x7C, 0x08, 0x04, 0x04, 0x78, /* n */ + 0x38, 0x44, 0x44, 0x44, 0x38, /* o */ + 0x7C, 0x14, 0x14, 0x14, 0x08, /* p */ + 0x08, 0x14, 0x14, 0x18, 0x7C, /* q */ + 0x7C, 0x08, 0x04, 0x04, 0x08, /* r */ + 0x48, 0x54, 0x54, 0x54, 0x20, /* s */ + 0x04, 0x3F, 0x44, 0x40, 0x20, /* t */ + 0x3C, 0x40, 0x40, 0x20, 0x7C, /* u */ + 0x1C, 0x20, 0x40, 0x20, 0x1C, /* v */ + 0x3C, 0x40, 0x30, 0x40, 0x3C, /* w */ + 0x44, 0x28, 0x10, 0x28, 0x44, /* x */ + 0x0C, 0x50, 0x50, 0x50, 0x3C, /* y */ + 0x44, 0x64, 0x54, 0x4C, 0x44, /* z */ + 0x00, 0x08, 0x36, 0x41, 0x00, /* { */ + 0x00, 0x00, 0x7F, 0x00, 0x00, /* | */ + 0x00, 0x41, 0x36, 0x08, 0x00, /* } */ + 0x08, 0x08, 0x2A, 0x1C, 0x08 /* ~ */ +}; + +static void set_black(uint8_t *buf, uint16_t width, uint16_t x, uint16_t y) { + if (x >= width) { + return; + } + uint16_t bpr = width / 8; + size_t idx = (size_t)y * bpr + (x / 8); + uint8_t bit = (uint8_t)(7 - (x % 8)); + buf[idx] |= (uint8_t)(1u << bit); +} + +static size_t cn16_index_count(void) { + size_t bytes = (size_t)(_binary_cn16_index_bin_end - _binary_cn16_index_bin_start); + return bytes / CN16_INDEX_ENTRY_BYTES; +} + +static bool cn16_read_index_entry(size_t idx, uint32_t *out_cp, uint16_t *out_gid) { + if (out_cp == NULL || out_gid == NULL) { + return false; + } + size_t count = cn16_index_count(); + if (idx >= count) { + return false; + } + + const uint8_t *p = _binary_cn16_index_bin_start + idx * CN16_INDEX_ENTRY_BYTES; + *out_cp = ((uint32_t)p[0]) | + ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | + ((uint32_t)p[3] << 24); + *out_gid = (uint16_t)(p[4] | ((uint16_t)p[5] << 8)); + return true; +} + +static bool cn16_lookup_glyph(uint32_t codepoint, const uint8_t **out_glyph) { + if (out_glyph == NULL) { + return false; + } + + size_t count = cn16_index_count(); + if (count == 0) { + return false; + } + + size_t lo = 0; + size_t hi = count; + while (lo < hi) { + size_t mid = lo + ((hi - lo) / 2); + uint32_t cp = 0; + uint16_t gid = 0; + if (!cn16_read_index_entry(mid, &cp, &gid)) { + return false; + } + + if (cp == codepoint) { + size_t glyph_bytes = (size_t)(_binary_cn16_glyphs_bin_end - + _binary_cn16_glyphs_bin_start); + size_t off = (size_t)gid * CN16_GLYPH_BYTES; + if ((off + CN16_GLYPH_BYTES) > glyph_bytes) { + return false; + } + *out_glyph = _binary_cn16_glyphs_bin_start + off; + return true; + } + if (cp < codepoint) { + lo = mid + 1; + } else { + hi = mid; + } + } + + return false; +} + +static void draw_char(uint8_t *buf, + uint16_t width, + uint16_t height, + uint16_t x, + uint16_t y, + char ch, + uint8_t scale) { + if (ch < 32 || ch > 126) { + ch = '?'; + } + + const uint8_t *glyph = &font5x7[(ch - 32) * 5]; + for (uint8_t col = 0; col < 5; ++col) { + uint8_t bits = glyph[col]; + for (uint8_t row = 0; row < 7; ++row) { + if (((bits >> row) & 0x01u) == 0) { + continue; + } + for (uint8_t sx = 0; sx < scale; ++sx) { + for (uint8_t sy = 0; sy < scale; ++sy) { + uint16_t px = (uint16_t)(x + col * scale + sx); + uint16_t py = (uint16_t)(y + row * scale + sy); + if (py < height) { + set_black(buf, width, px, py); + } + } + } + } + } +} + +static void draw_cn16_glyph(uint8_t *buf, + uint16_t width, + uint16_t height, + uint16_t x, + uint16_t y, + const uint8_t *glyph, + uint8_t scale) { + if (glyph == NULL || scale == 0) { + return; + } + + for (uint8_t row = 0; row < 16; ++row) { + uint16_t bits = ((uint16_t)glyph[row * 2] << 8) | glyph[row * 2 + 1]; + for (uint8_t col = 0; col < 16; ++col) { + if ((bits & (uint16_t)(1u << (15 - col))) == 0) { + continue; + } + for (uint8_t sx = 0; sx < scale; ++sx) { + for (uint8_t sy = 0; sy < scale; ++sy) { + uint16_t px = (uint16_t)(x + col * scale + sx); + uint16_t py = (uint16_t)(y + row * scale + sy); + if (py < height && px < width) { + set_black(buf, width, px, py); + } + } + } + } + } +} + +static void draw_missing_box(uint8_t *buf, + uint16_t width, + uint16_t height, + uint16_t x, + uint16_t y, + uint8_t scale) { + uint16_t box = (uint16_t)(16 * scale); + for (uint16_t r = 0; r < box; ++r) { + for (uint16_t c = 0; c < box; ++c) { + bool edge = (r < scale) || (c < scale) || (r >= box - scale) || (c >= box - scale); + if (!edge) { + continue; + } + uint16_t px = (uint16_t)(x + c); + uint16_t py = (uint16_t)(y + r); + if (px < width && py < height) { + set_black(buf, width, px, py); + } + } + } +} + +static uint32_t utf8_decode_one(const unsigned char *s, size_t len, size_t *out_consumed) { + if (out_consumed == NULL || s == NULL || len == 0) { + return UTF8_REPLACEMENT_CODEPOINT; + } + + uint8_t b0 = s[0]; + if (b0 < 0x80) { + *out_consumed = 1; + return b0; + } + + if ((b0 & 0xE0u) == 0xC0u && len >= 2) { + uint8_t b1 = s[1]; + if ((b1 & 0xC0u) == 0x80u) { + uint32_t cp = ((uint32_t)(b0 & 0x1Fu) << 6) | + (uint32_t)(b1 & 0x3Fu); + if (cp >= 0x80u) { + *out_consumed = 2; + return cp; + } + } + } else if ((b0 & 0xF0u) == 0xE0u && len >= 3) { + uint8_t b1 = s[1]; + uint8_t b2 = s[2]; + if ((b1 & 0xC0u) == 0x80u && (b2 & 0xC0u) == 0x80u) { + uint32_t cp = ((uint32_t)(b0 & 0x0Fu) << 12) | + ((uint32_t)(b1 & 0x3Fu) << 6) | + (uint32_t)(b2 & 0x3Fu); + if (cp >= 0x800u && !(cp >= 0xD800u && cp <= 0xDFFFu)) { + *out_consumed = 3; + return cp; + } + } + } else if ((b0 & 0xF8u) == 0xF0u && len >= 4) { + uint8_t b1 = s[1]; + uint8_t b2 = s[2]; + uint8_t b3 = s[3]; + if ((b1 & 0xC0u) == 0x80u && (b2 & 0xC0u) == 0x80u && (b3 & 0xC0u) == 0x80u) { + uint32_t cp = ((uint32_t)(b0 & 0x07u) << 18) | + ((uint32_t)(b1 & 0x3Fu) << 12) | + ((uint32_t)(b2 & 0x3Fu) << 6) | + (uint32_t)(b3 & 0x3Fu); + if (cp >= 0x10000u && cp <= 0x10FFFFu) { + *out_consumed = 4; + return cp; + } + } + } + + *out_consumed = 1; + return UTF8_REPLACEMENT_CODEPOINT; +} + +esp_err_t raster_tools_render_text_384(const char *text, + uint8_t scale, + uint8_t line_spacing, + uint16_t max_height, + uint16_t *out_width, + uint16_t *out_height, + uint8_t **out_raster, + size_t *out_len, + char *err, + size_t err_len) { + if (text == NULL || out_width == NULL || out_height == NULL || + out_raster == NULL || out_len == NULL) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid args"); + } + return ESP_ERR_INVALID_ARG; + } + + if (scale == 0) { + scale = 1; + } + + if (max_height == 0 || max_height > 3000) { + max_height = 3000; + } + + size_t full_len = (size_t)RASTER_BYTES_PER_ROW * max_height; + uint8_t *buf = (uint8_t *)calloc(1, full_len); + if (buf == NULL) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "no memory"); + } + return ESP_ERR_NO_MEM; + } + + uint16_t cursor_x = 0; + uint16_t cursor_y = 0; + uint16_t ascii_w = (uint16_t)(6 * scale); + uint16_t ascii_h = (uint16_t)(7 * scale); + uint16_t cjk_w = (uint16_t)(16 * scale); + uint16_t cjk_h = (uint16_t)(16 * scale); + uint16_t line_h = (uint16_t)(cjk_h + line_spacing * scale); + if (line_h == 0) { + line_h = cjk_h; + } + + uint16_t used_bottom = 0; + bool truncated = false; + + const unsigned char *p = (const unsigned char *)text; + size_t remain = strlen(text); + while (remain > 0) { + size_t consumed = 0; + uint32_t cp = utf8_decode_one(p, remain, &consumed); + if (consumed == 0) { + break; + } + p += consumed; + remain -= consumed; + + if (cp == '\r') { + continue; + } + if (cp == '\n') { + cursor_x = 0; + cursor_y = (uint16_t)(cursor_y + line_h); + continue; + } + + bool is_ascii = cp >= 0x20u && cp <= 0x7Eu; + uint16_t draw_w = is_ascii ? ascii_w : cjk_w; + uint16_t draw_h = is_ascii ? ascii_h : cjk_h; + uint16_t draw_y = cursor_y; + if (is_ascii && cjk_h > ascii_h) { + draw_y = (uint16_t)(cursor_y + (cjk_h - ascii_h) / 2); + } + + if ((uint16_t)(cursor_x + draw_w) > RASTER_WIDTH) { + cursor_x = 0; + cursor_y = (uint16_t)(cursor_y + line_h); + draw_y = cursor_y; + if (is_ascii && cjk_h > ascii_h) { + draw_y = (uint16_t)(cursor_y + (cjk_h - ascii_h) / 2); + } + } + + if ((uint16_t)(draw_y + draw_h) > max_height) { + truncated = true; + break; + } + + if (is_ascii) { + draw_char(buf, RASTER_WIDTH, max_height, cursor_x, draw_y, (char)cp, scale); + } else { + const uint8_t *glyph = NULL; + if (cn16_lookup_glyph(cp, &glyph)) { + draw_cn16_glyph(buf, RASTER_WIDTH, max_height, cursor_x, draw_y, glyph, scale); + } else { + draw_missing_box(buf, RASTER_WIDTH, max_height, cursor_x, draw_y, scale); + } + } + cursor_x = (uint16_t)(cursor_x + draw_w); + + uint16_t bottom = (uint16_t)(draw_y + draw_h); + if (bottom > used_bottom) { + used_bottom = bottom; + } + } + + if (used_bottom == 0) { + used_bottom = cjk_h; + } + + if (used_bottom > max_height) { + used_bottom = max_height; + } + + size_t final_len = (size_t)RASTER_BYTES_PER_ROW * used_bottom; + uint8_t *final_buf = (uint8_t *)malloc(final_len); + if (final_buf == NULL) { + free(buf); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "no memory"); + } + return ESP_ERR_NO_MEM; + } + + memcpy(final_buf, buf, final_len); + free(buf); + + *out_width = RASTER_WIDTH; + *out_height = used_bottom; + *out_raster = final_buf; + *out_len = final_len; + + if (truncated && err != NULL && err_len > 0) { + snprintf(err, err_len, "text truncated due to max height"); + } + + return ESP_OK; +} diff --git a/components/domain/src/raster_tools_image_qr.c b/components/domain/src/raster_tools_image_qr.c new file mode 100644 index 0000000..3658234 --- /dev/null +++ b/components/domain/src/raster_tools_image_qr.c @@ -0,0 +1,531 @@ +#include "domain.h" + +#include +#include +#include +#include + +#include "esp_heap_caps.h" +#include "png.h" +#include "qrcodegen.h" + +#define RASTER_WIDTH 384 +#define RASTER_BYTES_PER_ROW (RASTER_WIDTH / 8) +#define PNG_DECODE_MAX_BYTES (2 * 1024 * 1024) +#define PNG_DECODE_MAX_WIDTH 1600 + +static const uint8_t s_bit_mask[8] = {0x80u, 0x40u, 0x20u, 0x10u, 0x08u, 0x04u, 0x02u, 0x01u}; + +static void set_black(uint8_t *buf, uint16_t width, uint16_t x, uint16_t y) { + if (x >= width) { + return; + } + uint16_t bpr = width / 8; + size_t idx = (size_t)y * bpr + (x / 8); + uint8_t bit = (uint8_t)(7 - (x % 8)); + buf[idx] |= (uint8_t)(1u << bit); +} + +static void *alloc_prefer_psram(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 *calloc_prefer_psram(size_t n, size_t size) { + void *ptr = heap_caps_calloc(n, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (ptr == NULL) { + ptr = calloc(n, size); + } + return ptr; +} + +static uint8_t gray8_to_work_luma(uint8_t gray, bool invert) { + return invert ? (uint8_t)(255u - gray) : gray; +} + +static uint8_t compute_otsu_threshold(const uint32_t hist[256], uint32_t total_count) { + if (hist == NULL || total_count == 0) { + return 160; + } + + uint64_t all_sum = 0; + for (uint32_t i = 0; i < 256; ++i) { + all_sum += (uint64_t)i * hist[i]; + } + + uint64_t bg_sum = 0; + uint32_t bg_count = 0; + double max_between_var = -1.0; + uint8_t best_thr = 160; + + for (uint32_t t = 0; t < 256; ++t) { + bg_count += hist[t]; + if (bg_count == 0) { + continue; + } + + uint32_t fg_count = total_count - bg_count; + if (fg_count == 0) { + break; + } + + bg_sum += (uint64_t)t * hist[t]; + double bg_mean = (double)bg_sum / (double)bg_count; + double fg_mean = (double)(all_sum - bg_sum) / (double)fg_count; + double mean_diff = bg_mean - fg_mean; + double between_var = (double)bg_count * (double)fg_count * mean_diff * mean_diff; + + if (between_var > max_between_var) { + max_between_var = between_var; + best_thr = (uint8_t)t; + } + } + + return best_thr; +} + +esp_err_t raster_tools_convert_gray8_to_raster_384(const uint8_t *gray, + uint16_t src_width, + uint16_t src_height, + bool scale_to_width, + uint8_t threshold, + bool invert, + uint16_t max_height, + uint16_t *out_width, + uint16_t *out_height, + uint8_t **out_raster, + size_t *out_len, + char *err, + size_t err_len) { + if (gray == NULL || src_width == 0 || src_height == 0 || + out_width == NULL || out_height == NULL || out_raster == NULL || out_len == NULL) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid args"); + } + return ESP_ERR_INVALID_ARG; + } + + if (max_height == 0 || max_height > 3000) { + max_height = 3000; + } + + uint16_t dst_width = RASTER_WIDTH; + uint16_t dst_height = src_height; + if (scale_to_width) { + if (src_width == 0) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid source width"); + } + return ESP_ERR_INVALID_ARG; + } + uint32_t scaled = ((uint32_t)src_height * RASTER_WIDTH + (src_width / 2u)) / src_width; + if (scaled == 0) { + scaled = 1; + } + if (scaled > max_height) { + scaled = max_height; + } + dst_height = (uint16_t)scaled; + } else if (src_width != RASTER_WIDTH) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "width must be 384 when scale_to_width=false"); + } + return ESP_ERR_INVALID_ARG; + } + + if (dst_height == 0 || dst_height > max_height) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid output height"); + } + return ESP_ERR_INVALID_SIZE; + } + + uint16_t *x_map = (uint16_t *)alloc_prefer_psram(sizeof(uint16_t) * RASTER_WIDTH); + uint16_t *y_map = (uint16_t *)alloc_prefer_psram(sizeof(uint16_t) * dst_height); + size_t total_px = (size_t)RASTER_WIDTH * dst_height; + uint8_t *luma = (uint8_t *)alloc_prefer_psram(total_px); + if (x_map == NULL || y_map == NULL || luma == NULL) { + free(x_map); + free(y_map); + free(luma); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "no memory"); + } + return ESP_ERR_NO_MEM; + } + + for (uint16_t x = 0; x < RASTER_WIDTH; ++x) { + uint16_t src_x = scale_to_width + ? (uint16_t)(((uint32_t)x * src_width) / RASTER_WIDTH) + : x; + if (src_x >= src_width) { + src_x = (uint16_t)(src_width - 1); + } + x_map[x] = src_x; + } + for (uint16_t y = 0; y < dst_height; ++y) { + uint16_t src_y = scale_to_width + ? (uint16_t)(((uint32_t)y * src_height) / dst_height) + : y; + if (src_y >= src_height) { + src_y = (uint16_t)(src_height - 1); + } + y_map[y] = src_y; + } + + size_t len = (size_t)RASTER_BYTES_PER_ROW * dst_height; + uint8_t *raster = (uint8_t *)calloc_prefer_psram(1, len); + if (raster == NULL) { + free(x_map); + free(y_map); + free(luma); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "no memory"); + } + return ESP_ERR_NO_MEM; + } + + uint32_t hist[256] = {0}; + uint64_t sum_luma = 0; + for (uint16_t y = 0; y < dst_height; ++y) { + const uint8_t *src_row = gray + (size_t)y_map[y] * src_width; + uint8_t *dst_luma_row = luma + (size_t)y * RASTER_WIDTH; + for (uint16_t x = 0; x < RASTER_WIDTH; ++x) { + uint8_t src_v = src_row[x_map[x]]; + uint8_t work_v = gray8_to_work_luma(src_v, invert); + dst_luma_row[x] = work_v; + hist[work_v]++; + sum_luma += work_v; + } + } + + uint8_t otsu_thr = compute_otsu_threshold(hist, (uint32_t)total_px); + uint8_t mean_luma = total_px > 0 ? (uint8_t)(sum_luma / total_px) : (uint8_t)160; + + /* Keep backward compatibility: threshold still works as bias around 160. */ + int final_thr = (int)otsu_thr + (((int)threshold - 160) / 2); + /* White-bias to reduce large black blocks on thermal paper. */ + if (mean_luma < 112) { + final_thr -= 10; + } else if (mean_luma > 182) { + final_thr += 6; + } + if (final_thr < 40) { + final_thr = 40; + } else if (final_thr > 220) { + final_thr = 220; + } + + int16_t *err_curr = (int16_t *)calloc_prefer_psram(RASTER_WIDTH + 2u, sizeof(int16_t)); + int16_t *err_next = (int16_t *)calloc_prefer_psram(RASTER_WIDTH + 2u, sizeof(int16_t)); + bool use_dither = (err_curr != NULL && err_next != NULL); + + for (uint16_t y = 0; y < dst_height; ++y) { + const uint8_t *luma_row = luma + (size_t)y * RASTER_WIDTH; + uint8_t *raster_row = raster + (size_t)y * RASTER_BYTES_PER_ROW; + for (uint16_t x = 0; x < RASTER_WIDTH; ++x) { + int px_luma = (int)luma_row[x]; + if (use_dither) { + px_luma += err_curr[x + 1u]; + if (px_luma < 0) { + px_luma = 0; + } else if (px_luma > 255) { + px_luma = 255; + } + } + + bool black = px_luma < final_thr; + if (black) { + raster_row[x >> 3] |= s_bit_mask[x & 7u]; + } + + if (use_dither) { + int quantized = black ? 0 : 255; + int e = px_luma - quantized; + + if ((x + 1u) < RASTER_WIDTH) { + err_curr[x + 2u] += (int16_t)((e * 7) / 16); + } + err_next[x] += (int16_t)((e * 3) / 16); + err_next[x + 1u] += (int16_t)((e * 5) / 16); + err_next[x + 2u] += (int16_t)(e / 16); + } + } + + if (use_dither) { + int16_t *tmp = err_curr; + err_curr = err_next; + err_next = tmp; + memset(err_next, 0, (RASTER_WIDTH + 2u) * sizeof(int16_t)); + } + } + + free(err_curr); + free(err_next); + free(x_map); + free(y_map); + free(luma); + + *out_width = dst_width; + *out_height = dst_height; + *out_raster = raster; + *out_len = len; + return ESP_OK; +} + +esp_err_t raster_tools_convert_png_to_raster_384(const uint8_t *png, + size_t png_len, + bool scale_to_width, + uint8_t threshold, + bool invert, + uint16_t max_height, + uint16_t *out_width, + uint16_t *out_height, + uint8_t **out_raster, + size_t *out_len, + char *err, + size_t err_len) { + if (png == NULL || png_len == 0 || + out_width == NULL || out_height == NULL || out_raster == NULL || out_len == NULL) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid args"); + } + return ESP_ERR_INVALID_ARG; + } + + *out_width = 0; + *out_height = 0; + *out_raster = NULL; + *out_len = 0; + + if (png_len > UINT32_MAX) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "png too large"); + } + return ESP_ERR_INVALID_SIZE; + } + + if (max_height == 0 || max_height > 3000) { + max_height = 3000; + } + + png_image image; + memset(&image, 0, sizeof(image)); + image.version = PNG_IMAGE_VERSION; + + if (!png_image_begin_read_from_memory(&image, png, png_len) || + image.width == 0 || image.height == 0) { + png_image_free(&image); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid png"); + } + return ESP_FAIL; + } + + if (image.width > PNG_DECODE_MAX_WIDTH) { + png_image_free(&image); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "png too wide"); + } + return ESP_ERR_INVALID_SIZE; + } + + image.format = PNG_FORMAT_GRAY; + png_alloc_size_t gray_size = PNG_IMAGE_SIZE(image); + if (gray_size == 0 || gray_size > PNG_DECODE_MAX_BYTES) { + png_image_free(&image); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "png output too large"); + } + return ESP_ERR_INVALID_SIZE; + } + + if (image.width > UINT16_MAX || image.height > UINT16_MAX) { + png_image_free(&image); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "png dimensions too large"); + } + return ESP_ERR_INVALID_SIZE; + } + + uint8_t *gray = (uint8_t *)alloc_prefer_psram((size_t)gray_size); + if (gray == NULL) { + png_image_free(&image); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "no memory"); + } + return ESP_ERR_NO_MEM; + } + + png_alloc_size_t stride = PNG_IMAGE_ROW_STRIDE(image); + if (!png_image_finish_read(&image, NULL, gray, stride, NULL)) { + const char *png_err = image.message[0] != '\0' ? image.message : "png decode failed"; + png_image_free(&image); + free(gray); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "%s", png_err); + } + return ESP_FAIL; + } + png_image_free(&image); + + esp_err_t convert_rc = raster_tools_convert_gray8_to_raster_384(gray, + (uint16_t)image.width, + (uint16_t)image.height, + scale_to_width, + threshold, + invert, + max_height, + out_width, + out_height, + out_raster, + out_len, + err, + err_len); + free(gray); + return convert_rc; +} + +static enum qrcodegen_Ecc map_qr_ecc(uint8_t ecc_level) { + switch (ecc_level) { + case 0: + return qrcodegen_Ecc_LOW; + case 1: + return qrcodegen_Ecc_MEDIUM; + case 2: + return qrcodegen_Ecc_QUARTILE; + case 3: + default: + return qrcodegen_Ecc_HIGH; + } +} + +esp_err_t raster_tools_render_qr_384(const char *text, + uint8_t module_scale, + uint8_t margin_modules, + uint8_t ecc_level, + uint16_t max_height, + uint16_t *out_width, + uint16_t *out_height, + uint8_t **out_raster, + size_t *out_len, + char *err, + size_t err_len) { + if (text == NULL || text[0] == '\0' || + out_width == NULL || out_height == NULL || out_raster == NULL || out_len == NULL) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "invalid args"); + } + return ESP_ERR_INVALID_ARG; + } + + if (max_height == 0 || max_height > 3000) { + max_height = 3000; + } + + if (margin_modules == 0) { + margin_modules = 2; + } + + size_t qr_buf_len = qrcodegen_BUFFER_LEN_MAX; + uint8_t *temp = (uint8_t *)malloc(qr_buf_len); + uint8_t *qr = (uint8_t *)malloc(qr_buf_len); + if (temp == NULL || qr == NULL) { + free(temp); + free(qr); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "no memory"); + } + return ESP_ERR_NO_MEM; + } + + bool ok = qrcodegen_encodeText(text, + temp, + qr, + map_qr_ecc(ecc_level), + qrcodegen_VERSION_MIN, + qrcodegen_VERSION_MAX, + qrcodegen_Mask_AUTO, + true); + if (!ok) { + free(temp); + free(qr); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "qr encode failed"); + } + return ESP_FAIL; + } + + int qr_size = qrcodegen_getSize(qr); + int full_modules = qr_size + 2 * margin_modules; + + if (module_scale == 0) { + module_scale = (uint8_t)(RASTER_WIDTH / full_modules); + if (module_scale == 0) { + module_scale = 1; + } + } + + uint32_t image_size = (uint32_t)full_modules * module_scale; + if (image_size > RASTER_WIDTH || image_size > max_height) { + free(temp); + free(qr); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "qr too large for 384 width"); + } + return ESP_ERR_INVALID_SIZE; + } + + uint16_t out_h = (uint16_t)image_size; + size_t len = (size_t)RASTER_BYTES_PER_ROW * out_h; + uint8_t *raster = (uint8_t *)calloc_prefer_psram(1, len); + if (raster == NULL) { + free(temp); + free(qr); + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "no memory"); + } + return ESP_ERR_NO_MEM; + } + + uint16_t x_origin = (uint16_t)((RASTER_WIDTH - image_size) / 2); + for (int my = 0; my < full_modules; ++my) { + for (int mx = 0; mx < full_modules; ++mx) { + int qx = mx - margin_modules; + int qy = my - margin_modules; + bool black = (qx >= 0 && qx < qr_size && qy >= 0 && qy < qr_size) + ? qrcodegen_getModule(qr, qx, qy) + : false; + if (!black) { + continue; + } + + uint16_t px0 = (uint16_t)(x_origin + mx * module_scale); + uint16_t py0 = (uint16_t)(my * module_scale); + for (uint8_t sy = 0; sy < module_scale; ++sy) { + uint16_t py = (uint16_t)(py0 + sy); + if (py >= out_h) { + continue; + } + for (uint8_t sx = 0; sx < module_scale; ++sx) { + uint16_t px = (uint16_t)(px0 + sx); + if (px < RASTER_WIDTH) { + set_black(raster, RASTER_WIDTH, px, py); + } + } + } + } + } + + free(temp); + free(qr); + + *out_width = RASTER_WIDTH; + *out_height = out_h; + *out_raster = raster; + *out_len = len; + return ESP_OK; +} diff --git a/components/domain/src/screen_preview.c b/components/domain/src/screen_preview.c new file mode 100644 index 0000000..961b3aa --- /dev/null +++ b/components/domain/src/screen_preview.c @@ -0,0 +1,22 @@ +#include "domain.h" +#include "platform.h" + +esp_err_t screen_preview_show_raster(const uint8_t *raster, + size_t raster_len, + uint16_t width, + uint16_t height, + uint32_t timeout_ms, + char *err, + size_t err_len) +{ + return platform_display_show_raster_1bpp(raster, raster_len, width, height, timeout_ms, err, err_len); +} + +esp_err_t screen_preview_show_png(const uint8_t *png, + size_t png_len, + uint32_t timeout_ms, + char *err, + size_t err_len) +{ + return platform_display_show_png(png, png_len, timeout_ms, err, err_len); +} diff --git a/components/domain/src/system_runtime.c b/components/domain/src/system_runtime.c new file mode 100644 index 0000000..ad3fd64 --- /dev/null +++ b/components/domain/src/system_runtime.c @@ -0,0 +1,34 @@ +#include "domain.h" +#include "platform.h" + +esp_err_t system_runtime_start(void) { + esp_err_t err = platform_bootstrap_init(); + if (err != ESP_OK) { + return err; + } + + err = wifi_manager_start(); + if (err != ESP_OK) { + // Reset platform state so lifecycle retry runs from a clean baseline. + (void)wifi_manager_stop(); + return err; + } + + return ESP_OK; +} + +esp_err_t system_runtime_shutdown(void) { + return wifi_manager_stop(); +} + +esp_err_t system_runtime_bootstrap(void) { + return system_runtime_start(); +} + +bool system_runtime_wifi_ready(void) { + return wifi_manager_is_ready(); +} + +void system_runtime_get_ip(char *buf, size_t buf_len) { + wifi_manager_get_ip(buf, buf_len); +} diff --git a/components/domain/src/voice_interaction.c b/components/domain/src/voice_interaction.c new file mode 100644 index 0000000..ff09f90 --- /dev/null +++ b/components/domain/src/voice_interaction.c @@ -0,0 +1,529 @@ +#include "voice_interaction_internal.h" + +#include +#include +#include + +#include "esp_crt_bundle.h" +#include "esp_log.h" + +static const char *TAG = "voice_interaction"; + +static uint32_t clamp_timeout_ms(uint32_t timeout_ms, uint32_t default_ms, uint32_t min_ms, uint32_t max_ms) { + uint32_t value = timeout_ms == 0 ? default_ms : timeout_ms; + if (value < min_ms) { + value = min_ms; + } + if (value > max_ms) { + value = max_ms; + } + return value; +} + +static bool voice_wait_worker_tasks_exit(uint32_t timeout_ms) { + int64_t deadline_ms = voice_now_ms() + (int64_t)timeout_ms; + while (voice_now_ms() < deadline_ms) { + bool uplink_alive = false; + bool heartbeat_alive = false; + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + uplink_alive = (s_voice.uplink_task != NULL); + heartbeat_alive = (s_voice.heartbeat_task != NULL); + voice_unlock(); + } + + if (!uplink_alive && !heartbeat_alive) { + return true; + } + vTaskDelay(pdMS_TO_TICKS(20)); + } + + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + ESP_LOGW(TAG, + "[stage] worker_task_exit_timeout: uplink_alive=%d heartbeat_alive=%d", + s_voice.uplink_task != NULL, + s_voice.heartbeat_task != NULL); + voice_unlock(); + } + return false; +} + +const char *voice_interaction_dialog_state_str(voice_dialog_state_t state) { + switch (state) { + case VOICE_DIALOG_STATE_IDLE: + return "Idle"; + case VOICE_DIALOG_STATE_LISTENING: + return "Listening"; + case VOICE_DIALOG_STATE_THINKING: + return "Thinking"; + case VOICE_DIALOG_STATE_RESPONDING: + return "Responding"; + default: + return "Unknown"; + } +} + +esp_err_t voice_interaction_init(void) { + if (s_voice.ready) { + return ESP_OK; + } + + memset(&s_voice, 0, sizeof(s_voice)); + + s_voice.lock = xSemaphoreCreateMutex(); + if (s_voice.lock == NULL) { + return ESP_ERR_NO_MEM; + } + + s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE; + s_voice.last_event_ms = voice_now_ms(); + s_voice.ready = true; + return ESP_OK; +} + +esp_err_t voice_interaction_start_with_timeout(uint32_t timeout_ms, char *err, size_t err_len) { + uint32_t connect_timeout_ms = + clamp_timeout_ms(timeout_ms, VOICE_START_CONNECT_TIMEOUT_MS, 1000, 30000); + ESP_LOGI(TAG, "[stage] session_start_enter"); + if (!s_voice.ready) { + voice_fill_err(err, err_len, "voice interaction not initialized"); + ESP_LOGW(TAG, "[stage] session_start_abort: not initialized"); + return ESP_ERR_INVALID_STATE; + } + + if (strlen(CONFIG_TQ_VOICE_API_KEY) == 0 || + strlen(CONFIG_TQ_VOICE_WORKSPACE_ID) == 0 || + strlen(CONFIG_TQ_VOICE_APP_ID) == 0) { + voice_fill_err(err, err_len, "voice API key/workspace/app_id is missing"); + ESP_LOGW(TAG, "[stage] session_start_abort: config missing"); + return ESP_ERR_INVALID_STATE; + } + ESP_LOGI(TAG, + "[stage] session_config_ok: ws_uri=%s sample_rate=%d frame_ms=%d bitrate_kbps=%d ws_stack=%d", + CONFIG_TQ_VOICE_WS_URI, + CONFIG_TQ_VOICE_SAMPLE_RATE, + CONFIG_TQ_VOICE_OPUS_FRAME_MS, + CONFIG_TQ_VOICE_OPUS_BITRATE_KBPS, + VOICE_WS_TASK_STACK); + + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + voice_fill_err(err, err_len, "voice lock timeout"); + ESP_LOGW(TAG, "[stage] session_start_abort: lock timeout"); + return ESP_ERR_TIMEOUT; + } + bool already_active = s_voice.session_active; + bool ws_connected = s_voice.ws_connected; + bool started = s_voice.started; + voice_unlock(); + + if (already_active) { + if (ws_connected && started) { + ESP_LOGI(TAG, "[stage] session_start_skip: already active"); + voice_log_status_snapshot("already_active"); + return ESP_OK; + } + + ESP_LOGW(TAG, + "[stage] session_start_recover: active=%d ws_connected=%d started=%d", + already_active, + ws_connected, + started); + (void)voice_interaction_stop(NULL, 0); + } + + voice_interaction_ws_init_cjson_hooks(); + + ESP_LOGI(TAG, "[stage] opus_setup_begin"); + esp_err_t opus_setup_err = voice_setup_opus(err, err_len); + if (opus_setup_err != ESP_OK) { + ESP_LOGE(TAG, "voice opus setup failed: %s", esp_err_to_name(opus_setup_err)); + goto exit_err; + } + ESP_LOGI(TAG, "[stage] opus_setup_ok"); + + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + voice_make_uuid(s_voice.task_id); + voice_make_device_uuid(s_voice.device_uuid); + s_voice.dialog_id[0] = '\0'; + s_voice.last_error[0] = '\0'; + s_voice.upstream_packets = 0; + s_voice.downstream_packets = 0; + s_voice.last_downstream_ms = 0; + s_voice.playback_deadline_ms = 0; + s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE; + s_voice.stop_requested = false; + s_voice.tap_active = false; + s_voice.ws_low_stack_warned = false; + s_voice.response_first_sentence_checked = false; + s_voice.uplink_task_with_caps = false; + s_voice.heartbeat_task_with_caps = false; + s_voice.last_event_ms = voice_now_ms(); + ESP_LOGI(TAG, + "[stage] session_context_prepared: task_id=%s device_uuid=%s", + s_voice.task_id, + s_voice.device_uuid); + voice_unlock(); + } + + esp_websocket_client_config_t ws_cfg = { + .uri = CONFIG_TQ_VOICE_WS_URI, + .task_name = "voice_ws", + .task_prio = 5, + .task_stack = VOICE_WS_TASK_STACK, + .buffer_size = 2048, + .network_timeout_ms = 10000, + .reconnect_timeout_ms = 3000, + .ping_interval_sec = 20, + .disable_auto_reconnect = false, + .pingpong_timeout_sec = 10, +#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE + .crt_bundle_attach = esp_crt_bundle_attach, +#endif + }; + + ESP_LOGI(TAG, "[stage] ws_init_begin"); + esp_websocket_client_handle_t ws = esp_websocket_client_init(&ws_cfg); + if (ws == NULL) { + voice_fill_err(err, err_len, "websocket init failed"); + ESP_LOGE(TAG, "[stage] ws_init_failed"); + goto exit_err; + } + ESP_LOGI(TAG, "[stage] ws_init_ok"); + + size_t auth_len = strlen(CONFIG_TQ_VOICE_API_KEY) + 8; + char *auth_value = (char *)voice_malloc_prefer_psram(auth_len); + if (auth_value == NULL) { + voice_fill_err(err, err_len, "no memory for auth header"); + ESP_LOGE(TAG, "[stage] ws_auth_header_alloc_failed"); + esp_websocket_client_destroy(ws); + goto exit_err; + } + snprintf(auth_value, auth_len, "Bearer %s", CONFIG_TQ_VOICE_API_KEY); + esp_websocket_client_append_header(ws, "Authorization", auth_value); + free(auth_value); + ESP_LOGI(TAG, "[stage] ws_auth_header_set"); + + ESP_LOGI(TAG, "[stage] ws_register_events_begin"); + esp_err_t reg_err = esp_websocket_register_events(ws, + WEBSOCKET_EVENT_ANY, + voice_websocket_event_handler, + NULL); + if (reg_err != ESP_OK) { + voice_fill_err(err, err_len, "websocket register events failed"); + ESP_LOGE(TAG, "[stage] ws_register_events_failed: %s", esp_err_to_name(reg_err)); + esp_websocket_client_destroy(ws); + goto exit_err; + } + ESP_LOGI(TAG, "[stage] ws_register_events_ok"); + + ESP_LOGI(TAG, "[stage] ws_start_begin"); + esp_err_t ws_start_err = esp_websocket_client_start(ws); + if (ws_start_err != ESP_OK) { + voice_fill_err(err, err_len, "websocket start failed"); + ESP_LOGE(TAG, "[stage] ws_start_failed: %s", esp_err_to_name(ws_start_err)); + esp_websocket_client_destroy(ws); + goto exit_err; + } + ESP_LOGI(TAG, "[stage] ws_start_ok"); + + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + voice_fill_err(err, err_len, "voice lock timeout"); + ESP_LOGE(TAG, "[stage] session_start_abort: lock timeout after ws_start"); + esp_websocket_client_stop(ws); + esp_websocket_client_destroy(ws); + goto exit_err; + } + s_voice.ws = ws; + s_voice.session_active = true; + s_voice.ws_connected = false; + s_voice.started = false; + voice_unlock(); + voice_log_status_snapshot("session_marked_active"); + voice_log_heap_snapshot("before_create_uplink"); + + ESP_LOGI(TAG, "[stage] uplink_task_create_begin"); + BaseType_t ok = voice_create_task_prefer_psram(voice_uplink_task, + "voice_uplink", + VOICE_UPLINK_TASK_STACK, + 5, + &s_voice.uplink_task, + &s_voice.uplink_task_with_caps); + if (ok != pdPASS) { + voice_fill_err(err, err_len, "create uplink task failed"); + ESP_LOGE(TAG, "[stage] uplink_task_create_failed"); + voice_log_heap_snapshot("uplink_create_failed"); + (void)voice_interaction_stop(NULL, 0); + return ESP_ERR_NO_MEM; + } + ESP_LOGI(TAG, "[stage] uplink_task_create_ok"); + voice_log_heap_snapshot("after_create_uplink"); + + ESP_LOGI(TAG, "[stage] heartbeat_task_create_begin"); + ok = voice_create_task_prefer_psram(voice_heartbeat_task, + "voice_hb", + VOICE_HEARTBEAT_TASK_STACK, + 4, + &s_voice.heartbeat_task, + &s_voice.heartbeat_task_with_caps); + if (ok != pdPASS) { + voice_fill_err(err, err_len, "create heartbeat task failed"); + ESP_LOGE(TAG, "[stage] heartbeat_task_create_failed"); + voice_log_heap_snapshot("heartbeat_create_failed"); + (void)voice_interaction_stop(NULL, 0); + return ESP_ERR_NO_MEM; + } + ESP_LOGI(TAG, "[stage] heartbeat_task_create_ok"); + voice_log_heap_snapshot("after_create_heartbeat"); + + ESP_LOGI(TAG, "[stage] wait_ws_connected_begin: timeout_ms=%u", (unsigned)connect_timeout_ms); + int64_t deadline = voice_now_ms() + connect_timeout_ms; + while (voice_now_ms() < deadline) { + bool connected = false; + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + connected = s_voice.ws_connected; + voice_unlock(); + } + if (connected) { + break; + } + vTaskDelay(pdMS_TO_TICKS(100)); + } + + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + voice_fill_err(err, err_len, "voice lock timeout"); + ESP_LOGE(TAG, "[stage] wait_ws_connected_abort: lock timeout"); + (void)voice_interaction_stop(NULL, 0); + return ESP_ERR_TIMEOUT; + } + bool connected = s_voice.ws_connected; + voice_unlock(); + if (!connected) { + voice_fill_err(err, err_len, "websocket connect timeout"); + ESP_LOGE(TAG, "[stage] wait_ws_connected_timeout"); + voice_log_status_snapshot("wait_ws_connected_timeout"); + (void)voice_interaction_stop(NULL, 0); + return ESP_ERR_TIMEOUT; + } + + ESP_LOGI(TAG, "[stage] wait_ws_connected_ok"); + voice_log_status_snapshot("session_start_exit_ok"); + return ESP_OK; + +exit_err: + ESP_LOGE(TAG, "[stage] session_start_exit_err: %s", (err != NULL && err[0] != '\0') ? err : "unknown"); + voice_release_runtime_resources(); + voice_close_audio_with_drain(VOICE_AUDIO_DRAIN_WAIT_MS, "session_start_exit_err_close"); + return ESP_FAIL; +} + +esp_err_t voice_interaction_start(char *err, size_t err_len) { + return voice_interaction_start_with_timeout(VOICE_START_CONNECT_TIMEOUT_MS, err, err_len); +} + +esp_err_t voice_interaction_stop_with_timeout(uint32_t timeout_ms, char *err, size_t err_len) { + uint32_t worker_exit_timeout_ms = clamp_timeout_ms(timeout_ms, 1500, 500, 10000); + ESP_LOGI(TAG, "[stage] session_stop_enter"); + + if (!s_voice.ready) { + voice_fill_err(err, err_len, "voice interaction not initialized"); + ESP_LOGW(TAG, "[stage] session_stop_abort: not initialized"); + return ESP_ERR_INVALID_STATE; + } + + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + voice_fill_err(err, err_len, "voice lock timeout"); + ESP_LOGW(TAG, "[stage] session_stop_abort: lock timeout"); + return ESP_ERR_TIMEOUT; + } + + if (!s_voice.session_active) { + voice_unlock(); + voice_close_audio_with_drain(VOICE_AUDIO_DRAIN_WAIT_MS, "session_stop_skip_close"); + ESP_LOGI(TAG, "[stage] session_stop_skip: not active"); + return ESP_OK; + } + + s_voice.stop_requested = true; + bool ws_connected = s_voice.ws_connected; + TaskHandle_t uplink_task = s_voice.uplink_task; + TaskHandle_t heartbeat_task = s_voice.heartbeat_task; + esp_websocket_client_handle_t ws = s_voice.ws; + + s_voice.tap_active = false; + voice_unlock(); + voice_log_status_snapshot("session_stop_requested"); + + if (ws_connected) { + (void)voice_send_directive("finish-task", "Stop", true); + } + voice_close_audio_with_drain(VOICE_AUDIO_DRAIN_WAIT_ON_STOP_MS, "session_stop_close"); + if (uplink_task != NULL) { + (void)xTaskAbortDelay(uplink_task); + } + if (heartbeat_task != NULL) { + (void)xTaskAbortDelay(heartbeat_task); + } + bool workers_exited = voice_wait_worker_tasks_exit(worker_exit_timeout_ms); + if (!workers_exited) { + voice_fill_err(err, err_len, "voice worker exit timeout"); + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + voice_set_last_error_locked("worker exit timeout"); + voice_unlock(); + } + ESP_LOGW(TAG, "[stage] session_stop_abort: workers still alive"); + return ESP_ERR_TIMEOUT; + } + + if (ws != NULL) { + ESP_LOGI(TAG, "[stage] ws_stop_destroy_begin"); + esp_websocket_unregister_events(ws, + WEBSOCKET_EVENT_ANY, + voice_websocket_event_handler); + esp_websocket_client_stop(ws); + esp_websocket_client_destroy(ws); + ESP_LOGI(TAG, "[stage] ws_stop_destroy_ok"); + } + + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + s_voice.ws = NULL; + s_voice.uplink_task = NULL; + s_voice.heartbeat_task = NULL; + s_voice.uplink_task_with_caps = false; + s_voice.heartbeat_task_with_caps = false; + s_voice.ws_connected = false; + s_voice.started = false; + s_voice.session_active = false; + s_voice.stop_requested = false; + s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE; + s_voice.dialog_id[0] = '\0'; + s_voice.response_first_sentence_checked = false; + s_voice.last_downstream_ms = 0; + s_voice.playback_deadline_ms = 0; + voice_unlock(); + } + voice_log_status_snapshot("session_stop_cleared"); + + voice_release_runtime_resources(); + voice_close_audio_with_drain(VOICE_AUDIO_DRAIN_WAIT_MS, "session_stop_exit_close"); + ESP_LOGI(TAG, "[stage] session_stop_exit_ok"); + return ESP_OK; +} + +esp_err_t voice_interaction_stop(char *err, size_t err_len) { + return voice_interaction_stop_with_timeout(1500, err, err_len); +} + +esp_err_t voice_interaction_tap_start(char *err, size_t err_len) { + ESP_LOGI(TAG, "[stage] tap_start_enter"); + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + voice_fill_err(err, err_len, "voice lock timeout"); + ESP_LOGW(TAG, "[stage] tap_start_abort: lock timeout"); + return ESP_ERR_TIMEOUT; + } + + if (!s_voice.session_active || !s_voice.ws_connected || !s_voice.started) { + ESP_LOGW(TAG, + "[stage] tap_start_abort: session_active=%d ws_connected=%d started=%d", + s_voice.session_active, + s_voice.ws_connected, + s_voice.started); + voice_unlock(); + voice_fill_err(err, err_len, "voice session not ready"); + return ESP_ERR_INVALID_STATE; + } + + voice_unlock(); + voice_log_heap_snapshot("tap_start_before_audio_open"); + + esp_err_t audio_err = voice_open_audio_for_dialog(err, err_len); + if (audio_err != ESP_OK) { + voice_log_heap_snapshot("tap_start_audio_open_failed"); + return audio_err; + } + + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + voice_fill_err(err, err_len, "voice lock timeout"); + voice_audio_close(); + ESP_LOGW(TAG, "[stage] tap_start_abort: lock timeout after audio open"); + return ESP_ERR_TIMEOUT; + } + + if (!s_voice.session_active || !s_voice.ws_connected || !s_voice.started) { + ESP_LOGW(TAG, + "[stage] tap_start_abort_after_audio_open: session_active=%d ws_connected=%d started=%d", + s_voice.session_active, + s_voice.ws_connected, + s_voice.started); + voice_unlock(); + voice_audio_close(); + voice_fill_err(err, err_len, "voice session not ready"); + return ESP_ERR_INVALID_STATE; + } + + bool listening = (s_voice.dialog_state == VOICE_DIALOG_STATE_LISTENING); + s_voice.tap_active = true; + voice_unlock(); + ESP_LOGI(TAG, "[stage] tap_start_marked_active: listening=%d", listening); + + if (!listening) { + (void)voice_send_directive("continue-task", "RequestToSpeak", true); + } + + voice_log_status_snapshot("tap_start_exit_ok"); + return ESP_OK; +} + +esp_err_t voice_interaction_tap_cancel(char *err, size_t err_len) { + ESP_LOGI(TAG, "[stage] tap_cancel_enter"); + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + voice_fill_err(err, err_len, "voice lock timeout"); + ESP_LOGW(TAG, "[stage] tap_cancel_abort: lock timeout"); + return ESP_ERR_TIMEOUT; + } + + if (!s_voice.session_active || !s_voice.ws_connected || !s_voice.started) { + ESP_LOGW(TAG, + "[stage] tap_cancel_abort: session_active=%d ws_connected=%d started=%d", + s_voice.session_active, + s_voice.ws_connected, + s_voice.started); + voice_unlock(); + voice_fill_err(err, err_len, "voice session not ready"); + return ESP_ERR_INVALID_STATE; + } + + s_voice.tap_active = false; + voice_unlock(); + voice_log_status_snapshot("tap_cancel_marked_inactive"); + esp_err_t cancel_err = voice_send_directive("continue-task", "CancelSpeech", true); + voice_close_audio_with_drain(VOICE_AUDIO_DRAIN_WAIT_ON_STOP_MS, "tap_cancel_close"); + return cancel_err; +} + +void voice_interaction_get_status(voice_interaction_status_t *out_status) { + if (out_status == NULL) { + return; + } + + memset(out_status, 0, sizeof(*out_status)); + + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + return; + } + + out_status->ready = s_voice.ready; + out_status->session_active = s_voice.session_active; + out_status->ws_connected = s_voice.ws_connected; + out_status->started = s_voice.started; + out_status->tap_active = s_voice.tap_active; + out_status->marker_image_gen_running = voice_marker_image_generation_is_running(); + out_status->dialog_state = s_voice.dialog_state; + out_status->last_event_ms = s_voice.last_event_ms; + out_status->upstream_packets = s_voice.upstream_packets; + out_status->downstream_packets = s_voice.downstream_packets; + + strlcpy(out_status->task_id, s_voice.task_id, sizeof(out_status->task_id)); + strlcpy(out_status->dialog_id, s_voice.dialog_id, sizeof(out_status->dialog_id)); + strlcpy(out_status->last_error, s_voice.last_error, sizeof(out_status->last_error)); + + voice_unlock(); +} diff --git a/components/domain/src/voice_interaction_common.c b/components/domain/src/voice_interaction_common.c new file mode 100644 index 0000000..4d6eced --- /dev/null +++ b/components/domain/src/voice_interaction_common.c @@ -0,0 +1,488 @@ +#include "voice_interaction_internal.h" + +#include +#include +#include +#include + +#include "esp_audio_dec.h" +#include "esp_audio_enc.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_mac.h" +#include "esp_random.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "freertos/idf_additions.h" + +static const char *TAG = "voice_interaction"; + +voice_context_t s_voice; + +int64_t voice_now_ms(void) { + return esp_timer_get_time() / 1000; +} + +void voice_fill_err(char *err, size_t err_len, const char *msg) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "%s", (msg != NULL) ? msg : "unknown error"); + } +} + +void voice_set_last_error_locked(const char *msg) { + if (msg == NULL) { + s_voice.last_error[0] = '\0'; + return; + } + strlcpy(s_voice.last_error, msg, sizeof(s_voice.last_error)); +} + +bool voice_lock(uint32_t timeout_ms) { + return s_voice.lock != NULL && xSemaphoreTake(s_voice.lock, pdMS_TO_TICKS(timeout_ms)) == pdTRUE; +} + +void voice_unlock(void) { + if (s_voice.lock != NULL) { + xSemaphoreGive(s_voice.lock); + } +} + +void voice_log_status_snapshot(const char *stage) { + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + ESP_LOGW(TAG, "[stage] %s: status snapshot lock timeout", stage); + return; + } + + ESP_LOGI(TAG, + "[stage] %s: active=%d ws=%d started=%d tap=%d state=%s up=%" PRIu32 " down=%" PRIu32 " err=%s", + stage, + s_voice.session_active, + s_voice.ws_connected, + s_voice.started, + s_voice.tap_active, + voice_interaction_dialog_state_str(s_voice.dialog_state), + s_voice.upstream_packets, + s_voice.downstream_packets, + s_voice.last_error[0] != '\0' ? s_voice.last_error : "-"); + voice_unlock(); +} + +void voice_log_heap_snapshot(const char *stage) { + size_t free_8bit = heap_caps_get_free_size(MALLOC_CAP_8BIT); + size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + size_t free_dma = heap_caps_get_free_size(MALLOC_CAP_DMA | MALLOC_CAP_8BIT); + size_t free_spiram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + size_t largest_8bit = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT); + size_t largest_internal = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + size_t largest_dma = heap_caps_get_largest_free_block(MALLOC_CAP_DMA | MALLOC_CAP_8BIT); + size_t largest_spiram = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + ESP_LOGI(TAG, + "[stage] %s heap: free8=%u free_internal=%u free_dma=%u free_spiram=%u largest8=%u largest_internal=%u largest_dma=%u largest_spiram=%u", + stage, + (unsigned)free_8bit, + (unsigned)free_internal, + (unsigned)free_dma, + (unsigned)free_spiram, + (unsigned)largest_8bit, + (unsigned)largest_internal, + (unsigned)largest_dma, + (unsigned)largest_spiram); +} + +void voice_wait_audio_playback_done(uint32_t timeout_ms, const char *stage) { + if (!voice_audio_is_open() || timeout_ms == 0) { + return; + } + + int64_t deadline_ms = voice_now_ms() + (int64_t)timeout_ms; + while (voice_now_ms() < deadline_ms) { + int64_t playback_deadline_ms = 0; + int64_t last_downstream_ms = 0; + voice_dialog_state_t dialog_state = VOICE_DIALOG_STATE_IDLE; + bool ws_connected = false; + bool started = false; + + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + break; + } + playback_deadline_ms = s_voice.playback_deadline_ms; + last_downstream_ms = s_voice.last_downstream_ms; + dialog_state = s_voice.dialog_state; + ws_connected = s_voice.ws_connected; + started = s_voice.started; + voice_unlock(); + + int64_t now_ms = voice_now_ms(); + bool codec_tail_done = now_ms >= playback_deadline_ms; + bool downstream_quiet = (last_downstream_ms == 0) || + ((now_ms - last_downstream_ms) >= VOICE_AUDIO_DRAIN_QUIET_MS); + bool remote_not_responding = !ws_connected || + !started || + dialog_state != VOICE_DIALOG_STATE_RESPONDING; + if (codec_tail_done && downstream_quiet && remote_not_responding) { + ESP_LOGI(TAG, + "[stage] %s: audio_drain_done now=%" PRId64 " play_deadline=%" PRId64 " last_down=%" PRId64, + stage != NULL ? stage : "audio_drain", + now_ms, + playback_deadline_ms, + last_downstream_ms); + return; + } + + vTaskDelay(pdMS_TO_TICKS(30)); + } + + ESP_LOGI(TAG, + "[stage] %s: audio_drain_timeout timeout_ms=%u", + stage != NULL ? stage : "audio_drain", + (unsigned)timeout_ms); +} + +void voice_close_audio_with_drain(uint32_t timeout_ms, const char *stage) { + if (!voice_audio_is_open()) { + return; + } + + voice_wait_audio_playback_done(timeout_ms, stage); + voice_audio_close(); + + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + s_voice.last_downstream_ms = 0; + s_voice.playback_deadline_ms = 0; + voice_unlock(); + } +} + +void *voice_malloc_prefer_psram(size_t size) { + void *ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (ptr == NULL) { + ptr = malloc(size); + } + return ptr; +} + +void *voice_calloc_prefer_psram(size_t n, size_t size) { + void *ptr = heap_caps_calloc(n, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (ptr == NULL) { + ptr = calloc(n, size); + } + return ptr; +} + +void *voice_realloc_prefer_psram(void *ptr, size_t size) { + void *new_ptr = heap_caps_realloc(ptr, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (new_ptr == NULL) { + new_ptr = realloc(ptr, size); + } + return new_ptr; +} + +BaseType_t voice_create_task_prefer_psram(TaskFunction_t task_fn, + const char *name, + uint32_t stack_size, + UBaseType_t priority, + TaskHandle_t *out_task, + bool *out_with_caps) { + if (out_with_caps != NULL) { + *out_with_caps = false; + } + + BaseType_t rc = pdFAIL; + +#if defined(CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY) && \ + defined(CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM) && \ + (configSUPPORT_STATIC_ALLOCATION == 1) + rc = xTaskCreatePinnedToCoreWithCaps(task_fn, + name, + (configSTACK_DEPTH_TYPE)stack_size, + NULL, + priority, + out_task, + tskNO_AFFINITY, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (rc == pdPASS) { + if (out_with_caps != NULL) { + *out_with_caps = true; + } + ESP_LOGI(TAG, "[stage] task_create_psram_ok: name=%s stack=%u", name, (unsigned)stack_size); + return pdPASS; + } + ESP_LOGW(TAG, "[stage] task_create_psram_failed: name=%s stack=%u", name, (unsigned)stack_size); +#endif + + rc = xTaskCreate(task_fn, + name, + (configSTACK_DEPTH_TYPE)stack_size, + NULL, + priority, + out_task); + if (rc == pdPASS) { + ESP_LOGI(TAG, "[stage] task_create_internal_ok: name=%s stack=%u", name, (unsigned)stack_size); + } else { + ESP_LOGE(TAG, "[stage] task_create_internal_failed: name=%s stack=%u", name, (unsigned)stack_size); + } + return rc; +} + +void voice_delete_task(TaskHandle_t task, bool with_caps) { + if (task == NULL) { + return; + } + +#if (configSUPPORT_STATIC_ALLOCATION == 1) + if (with_caps) { + vTaskDeleteWithCaps(task); + return; + } +#else + (void)with_caps; +#endif + vTaskDelete(task); +} + +void voice_delete_self_task(bool with_caps) { +#if (configSUPPORT_STATIC_ALLOCATION == 1) + if (with_caps) { + vTaskDeleteWithCaps(NULL); + return; + } +#else + (void)with_caps; +#endif + vTaskDelete(NULL); +} + +void voice_make_uuid(char out[40]) { + uint8_t raw[16]; + esp_fill_random(raw, sizeof(raw)); + + raw[6] = (uint8_t)((raw[6] & 0x0F) | 0x40); + raw[8] = (uint8_t)((raw[8] & 0x3F) | 0x80); + + snprintf(out, + 40, + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + raw[0], + raw[1], + raw[2], + raw[3], + raw[4], + raw[5], + raw[6], + raw[7], + raw[8], + raw[9], + raw[10], + raw[11], + raw[12], + raw[13], + raw[14], + raw[15]); +} + +void voice_make_device_uuid(char out[40]) { + if (strlen(CONFIG_TQ_VOICE_DEVICE_UUID) > 0) { + strlcpy(out, CONFIG_TQ_VOICE_DEVICE_UUID, 40); + return; + } + + uint8_t mac[6] = {0}; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + + uint32_t r = esp_random(); + snprintf(out, + 40, + "%02x%02x%02x%02x-%02x%02x-4%03x-%04x-%012" PRIx32, + mac[0], + mac[1], + mac[2], + mac[3], + mac[4], + mac[5], + (unsigned)(r & 0x0FFF), + (unsigned)((r >> 12) & 0xFFFF), + esp_random()); +} + +const char *voice_safe_user_id(void) { + if (strlen(CONFIG_TQ_VOICE_USER_ID) > 0) { + return CONFIG_TQ_VOICE_USER_ID; + } + return "esp32-user"; +} + +static esp_opus_enc_frame_duration_t voice_to_enc_frame_duration(int frame_ms) { + switch (frame_ms) { + case 10: + return ESP_OPUS_ENC_FRAME_DURATION_10_MS; + case 20: + return ESP_OPUS_ENC_FRAME_DURATION_20_MS; + case 40: + return ESP_OPUS_ENC_FRAME_DURATION_40_MS; + case 60: + return ESP_OPUS_ENC_FRAME_DURATION_60_MS; + case 80: + return ESP_OPUS_ENC_FRAME_DURATION_80_MS; + case 100: + return ESP_OPUS_ENC_FRAME_DURATION_100_MS; + case 120: + return ESP_OPUS_ENC_FRAME_DURATION_120_MS; + default: + return ESP_OPUS_ENC_FRAME_DURATION_ARG; + } +} + +static esp_opus_dec_frame_duration_t voice_to_dec_frame_duration(int frame_ms) { + switch (frame_ms) { + case 10: + return ESP_OPUS_DEC_FRAME_DURATION_10_MS; + case 20: + return ESP_OPUS_DEC_FRAME_DURATION_20_MS; + case 40: + return ESP_OPUS_DEC_FRAME_DURATION_40_MS; + case 60: + return ESP_OPUS_DEC_FRAME_DURATION_60_MS; + case 80: + return ESP_OPUS_DEC_FRAME_DURATION_80_MS; + case 100: + return ESP_OPUS_DEC_FRAME_DURATION_100_MS; + case 120: + return ESP_OPUS_DEC_FRAME_DURATION_120_MS; + default: + return ESP_OPUS_DEC_FRAME_DURATION_INVALID; + } +} + +esp_err_t voice_setup_opus(char *err, size_t err_len) { + esp_opus_enc_frame_duration_t enc_duration = + voice_to_enc_frame_duration(CONFIG_TQ_VOICE_OPUS_FRAME_MS); + esp_opus_dec_frame_duration_t dec_duration = + voice_to_dec_frame_duration(CONFIG_TQ_VOICE_OPUS_FRAME_MS); + + if (enc_duration == ESP_OPUS_ENC_FRAME_DURATION_ARG || + dec_duration == ESP_OPUS_DEC_FRAME_DURATION_INVALID) { + voice_fill_err(err, err_len, "invalid opus frame duration"); + return ESP_ERR_INVALID_ARG; + } + + esp_opus_enc_config_t enc_cfg = ESP_OPUS_ENC_CONFIG_DEFAULT(); + enc_cfg.sample_rate = CONFIG_TQ_VOICE_SAMPLE_RATE; + enc_cfg.channel = ESP_AUDIO_MONO; + enc_cfg.bits_per_sample = ESP_AUDIO_BIT16; + enc_cfg.frame_duration = enc_duration; + enc_cfg.bitrate = CONFIG_TQ_VOICE_OPUS_BITRATE_KBPS * 1000; + enc_cfg.application_mode = ESP_OPUS_ENC_APPLICATION_VOIP; + enc_cfg.enable_vbr = true; + + esp_audio_err_t enc_err = esp_opus_enc_open(&enc_cfg, + sizeof(enc_cfg), + &s_voice.opus_enc); + if (enc_err != ESP_AUDIO_ERR_OK || s_voice.opus_enc == NULL) { + voice_fill_err(err, err_len, "opus encoder open failed"); + return ESP_FAIL; + } + + enc_err = esp_opus_enc_get_frame_size(s_voice.opus_enc, + &s_voice.opus_enc_in_size, + &s_voice.opus_enc_out_size); + if (enc_err != ESP_AUDIO_ERR_OK || + s_voice.opus_enc_in_size <= 0 || + s_voice.opus_enc_out_size <= 0) { + voice_fill_err(err, err_len, "opus frame size query failed"); + return ESP_FAIL; + } + + s_voice.pcm_tx_buf = (int16_t *)voice_malloc_prefer_psram((size_t)s_voice.opus_enc_in_size); + s_voice.opus_tx_buf = (uint8_t *)voice_malloc_prefer_psram((size_t)s_voice.opus_enc_out_size); + if (s_voice.pcm_tx_buf == NULL || s_voice.opus_tx_buf == NULL) { + voice_fill_err(err, err_len, "no memory for opus tx buffers"); + return ESP_ERR_NO_MEM; + } + + esp_opus_dec_cfg_t dec_cfg = ESP_OPUS_DEC_CONFIG_DEFAULT(); + dec_cfg.sample_rate = CONFIG_TQ_VOICE_SAMPLE_RATE; + dec_cfg.channel = ESP_AUDIO_MONO; + dec_cfg.frame_duration = dec_duration; + dec_cfg.self_delimited = false; + + esp_audio_err_t dec_err = esp_opus_dec_open(&dec_cfg, + sizeof(dec_cfg), + &s_voice.opus_dec); + if (dec_err != ESP_AUDIO_ERR_OK || s_voice.opus_dec == NULL) { + voice_fill_err(err, err_len, "opus decoder open failed"); + return ESP_FAIL; + } + + size_t rx_pcm_size = (size_t)CONFIG_TQ_VOICE_SAMPLE_RATE * + VOICE_SAMPLE_BYTES * + (size_t)CONFIG_TQ_VOICE_OPUS_FRAME_MS * + 2 / 1000; + if (rx_pcm_size < 2048) { + rx_pcm_size = 2048; + } + + s_voice.pcm_rx_buf = (uint8_t *)voice_malloc_prefer_psram(rx_pcm_size); + if (s_voice.pcm_rx_buf == NULL) { + voice_fill_err(err, err_len, "no memory for opus rx buffer"); + return ESP_ERR_NO_MEM; + } + s_voice.pcm_rx_buf_size = rx_pcm_size; + + return ESP_OK; +} + +esp_err_t voice_open_audio_for_dialog(char *err, size_t err_len) { + if (voice_audio_is_open()) { + return ESP_OK; + } + + voice_audio_open_config_t audio_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_LOGI(TAG, "[stage] audio_open_begin"); + esp_err_t open_err = voice_audio_open(&audio_cfg, err, err_len); + if (open_err != ESP_OK) { + ESP_LOGE(TAG, "[stage] audio_open_failed: %s", esp_err_to_name(open_err)); + return open_err; + } + + ESP_LOGI(TAG, "[stage] audio_open_ok"); + return ESP_OK; +} + +void voice_release_runtime_resources(void) { + if (s_voice.opus_enc != NULL) { + esp_opus_enc_close(s_voice.opus_enc); + s_voice.opus_enc = NULL; + } + if (s_voice.opus_dec != NULL) { + (void)esp_opus_dec_close(s_voice.opus_dec); + s_voice.opus_dec = NULL; + } + + free(s_voice.pcm_tx_buf); + s_voice.pcm_tx_buf = NULL; + + free(s_voice.opus_tx_buf); + s_voice.opus_tx_buf = NULL; + + free(s_voice.pcm_rx_buf); + s_voice.pcm_rx_buf = NULL; + s_voice.pcm_rx_buf_size = 0; + + free(s_voice.text_agg); + s_voice.text_agg = NULL; + s_voice.text_agg_size = 0; + + free(s_voice.bin_agg); + s_voice.bin_agg = NULL; + s_voice.bin_agg_size = 0; + + s_voice.opus_enc_in_size = 0; + s_voice.opus_enc_out_size = 0; +} diff --git a/components/domain/src/voice_interaction_marker.c b/components/domain/src/voice_interaction_marker.c new file mode 100644 index 0000000..7720256 --- /dev/null +++ b/components/domain/src/voice_interaction_marker.c @@ -0,0 +1,520 @@ +#include "voice_interaction_internal.h" + +#include +#include +#include +#include + +#include "esp_log.h" +#include "domain.h" + +static const char *TAG = "voice_interaction"; + +#define VOICE_IMAGE_MARKER_PROMPT_MAX_LEN 800 +#define VOICE_IMAGE_MARKER_FIRST_SENTENCE_MAX_LEN 1024 +#define VOICE_IMAGE_GEN_TASK_STACK 16384 +#define VOICE_IMAGE_GEN_TASK_PRIO 3 +#define VOICE_IMAGE_MARKER_REPEAT_SUPPRESS_MS 45000 +#define VOICE_IMAGE_PRINT_THRESHOLD 160 +#define VOICE_IMAGE_PRINT_MAX_HEIGHT 2200 +#define VOICE_IMAGE_PREVIEW_TIMEOUT_MS 200 +#define VOICE_IMAGE_MARKER_TEMPLATE \ + "%s,线稿风格,白色背景,边缘清晰,细节简洁,高对比,避免大面积阴影和灰度渐变,适配热敏打印机打印。" +#define VOICE_IMAGE_MARKER_TEMPLATE_FALLBACK_SUBJECT "简洁卡通图案" + +typedef struct { + char prompt[VOICE_IMAGE_MARKER_PROMPT_MAX_LEN + 1]; + char dialog_id[40]; +} voice_image_generation_task_arg_t; + +static portMUX_TYPE s_marker_image_gen_lock = portMUX_INITIALIZER_UNLOCKED; +static bool s_marker_image_gen_running; +static int64_t s_marker_last_success_ms; +static char s_marker_last_success_prompt[VOICE_IMAGE_MARKER_PROMPT_MAX_LEN + 1]; +static char s_marker_last_success_dialog_id[40]; + +static bool voice_marker_image_generation_try_acquire(const char *prompt, + const char *dialog_id, + int64_t now_ms, + uint32_t *out_remain_ms) { + bool acquired = false; + uint32_t remain_ms = 0; + const char *safe_prompt = prompt != NULL ? prompt : ""; + const char *safe_dialog = dialog_id != NULL ? dialog_id : ""; + + portENTER_CRITICAL(&s_marker_image_gen_lock); + if (s_marker_image_gen_running) { + acquired = false; + } else if (s_marker_last_success_ms > 0 && + strcmp(safe_prompt, s_marker_last_success_prompt) == 0 && + strcmp(safe_dialog, s_marker_last_success_dialog_id) == 0) { + int64_t elapsed_ms = now_ms - s_marker_last_success_ms; + if (elapsed_ms >= 0 && elapsed_ms < VOICE_IMAGE_MARKER_REPEAT_SUPPRESS_MS) { + remain_ms = (uint32_t)(VOICE_IMAGE_MARKER_REPEAT_SUPPRESS_MS - elapsed_ms); + acquired = false; + } else { + s_marker_image_gen_running = true; + acquired = true; + } + } else { + s_marker_image_gen_running = true; + acquired = true; + } + portEXIT_CRITICAL(&s_marker_image_gen_lock); + + if (out_remain_ms != NULL) { + *out_remain_ms = remain_ms; + } + return acquired; +} + +static void voice_marker_image_generation_release(void) { + portENTER_CRITICAL(&s_marker_image_gen_lock); + s_marker_image_gen_running = false; + portEXIT_CRITICAL(&s_marker_image_gen_lock); +} + +static void voice_marker_image_generation_record_success(const char *prompt, + const char *dialog_id, + int64_t now_ms) { + const char *safe_prompt = prompt != NULL ? prompt : ""; + const char *safe_dialog = dialog_id != NULL ? dialog_id : ""; + + portENTER_CRITICAL(&s_marker_image_gen_lock); + strlcpy(s_marker_last_success_prompt, safe_prompt, sizeof(s_marker_last_success_prompt)); + strlcpy(s_marker_last_success_dialog_id, safe_dialog, sizeof(s_marker_last_success_dialog_id)); + s_marker_last_success_ms = now_ms; + portEXIT_CRITICAL(&s_marker_image_gen_lock); +} + +static void voice_log_text_preview(const char *stage, const char *text) { + if (stage == NULL || text == NULL || text[0] == '\0') { + return; + } + + size_t len = strlen(text); + const int preview = 240; + ESP_LOGI(TAG, + "[stage] %s: len=%u text=%.*s%s", + stage, + (unsigned)len, + preview, + text, + (len > (size_t)preview) ? "..." : ""); +} + +static bool voice_is_space_char(char c) { + return isspace((unsigned char)c) != 0; +} + +static size_t voice_find_first_sentence_end(const char *text, size_t len) { + if (text == NULL || len == 0) { + return 0; + } + + for (size_t i = 0; i < len; ++i) { + unsigned char c = (unsigned char)text[i]; + if (c == '.' || c == '!' || c == '?' || c == '\n' || c == '\r') { + return i + 1; + } + + if (i + 2 < len) { + unsigned char b1 = (unsigned char)text[i]; + unsigned char b2 = (unsigned char)text[i + 1]; + unsigned char b3 = (unsigned char)text[i + 2]; + + if (b1 == 0xE3 && b2 == 0x80 && b3 == 0x82) { + return i + 3; + } + if (b1 == 0xEF && b2 == 0xBC && + (b3 == 0x81 || b3 == 0x9F || b3 == 0x9B)) { + return i + 3; + } + } + } + + return len; +} + +static bool voice_extract_first_sentence_text(const char *text, + char *out_sentence, + size_t out_len) { + if (text == NULL || out_sentence == NULL || out_len == 0) { + return false; + } + + size_t len = strlen(text); + size_t begin = 0; + while (begin < len && voice_is_space_char(text[begin])) { + begin++; + } + if (begin >= len) { + return false; + } + + size_t end = begin + voice_find_first_sentence_end(text + begin, len - begin); + while (end > begin && voice_is_space_char(text[end - 1])) { + end--; + } + if (end <= begin) { + return false; + } + + size_t copy_len = end - begin; + if (copy_len >= out_len) { + copy_len = out_len - 1; + } + memcpy(out_sentence, text + begin, copy_len); + out_sentence[copy_len] = '\0'; + return copy_len > 0; +} + +static bool voice_extract_marker_prompt(const char *text, char *out_prompt, size_t out_len) { + if (text == NULL || out_prompt == NULL || out_len == 0) { + return false; + } + + const char *open = strstr(text, "/*"); + if (open == NULL) { + return false; + } + + const char *close = strstr(open + 2, "*/"); + if (close == NULL) { + return false; + } + + const char *begin = open + 2; + const char *end = close; + while (begin < end && voice_is_space_char(*begin)) { + begin++; + } + while (end > begin && voice_is_space_char(*(end - 1))) { + end--; + } + if (end <= begin) { + return false; + } + + size_t prompt_len = (size_t)(end - begin); + if (prompt_len >= out_len) { + prompt_len = out_len - 1; + } + memcpy(out_prompt, begin, prompt_len); + out_prompt[prompt_len] = '\0'; + return prompt_len > 0; +} + +static void voice_build_marker_image_prompt(const char *subject, + char *out_prompt, + size_t out_len) { + if (out_prompt == NULL || out_len == 0) { + return; + } + + const char *safe_subject = (subject != NULL && subject[0] != '\0') ? + subject : + VOICE_IMAGE_MARKER_TEMPLATE_FALLBACK_SUBJECT; + int written = snprintf(out_prompt, + out_len, + VOICE_IMAGE_MARKER_TEMPLATE, + safe_subject); + if (written < 0) { + out_prompt[0] = '\0'; + return; + } + + if ((size_t)written >= out_len) { + out_prompt[out_len - 1] = '\0'; + } +} + +static void voice_try_preview_marker_png(const voice_image_generation_task_arg_t *task_arg, + const uint8_t *png, + size_t png_len) { + if (task_arg == NULL || png == NULL || png_len == 0) { + return; + } + + char preview_err[96] = {0}; + esp_err_t preview_rc = screen_preview_show_png(png, + png_len, + VOICE_IMAGE_PREVIEW_TIMEOUT_MS, + preview_err, + sizeof(preview_err)); + if (preview_rc == ESP_OK) { + ESP_LOGI(TAG, + "[stage] marker_image_preview_ok: dialog_id=%s png_bytes=%u", + task_arg->dialog_id[0] != '\0' ? task_arg->dialog_id : "-", + (unsigned)png_len); + return; + } + + if (preview_rc == ESP_ERR_NOT_SUPPORTED) { + return; + } + + ESP_LOGW(TAG, + "[stage] marker_image_preview_failed: dialog_id=%s rc=0x%x msg=%s", + task_arg->dialog_id[0] != '\0' ? task_arg->dialog_id : "-", + (unsigned)preview_rc, + preview_err[0] != '\0' ? preview_err : "preview failed"); +} + +static esp_err_t voice_submit_marker_generated_image(const voice_image_generation_task_arg_t *task_arg, + const image_generation_result_t *result, + char *err, + size_t err_len) { + if (task_arg == NULL || result == NULL || result->png == NULL || result->png_len == 0) { + voice_fill_err(err, err_len, "invalid image result"); + return ESP_ERR_INVALID_ARG; + } + + voice_try_preview_marker_png(task_arg, result->png, result->png_len); + + uint16_t raster_width = 0; + uint16_t raster_height = 0; + uint8_t *raster = NULL; + size_t raster_len = 0; + char decode_err[128] = {0}; + ESP_LOGI(TAG, + "[stage] marker_image_print_decode_start: dialog_id=%s request_id=%s png_bytes=%u", + task_arg->dialog_id[0] != '\0' ? task_arg->dialog_id : "-", + result->request_id[0] != '\0' ? result->request_id : "-", + (unsigned)result->png_len); + esp_err_t decode_rc = raster_tools_convert_png_to_raster_384(result->png, + result->png_len, + true, + VOICE_IMAGE_PRINT_THRESHOLD, + false, + VOICE_IMAGE_PRINT_MAX_HEIGHT, + &raster_width, + &raster_height, + &raster, + &raster_len, + decode_err, + sizeof(decode_err)); + if (decode_rc != ESP_OK) { + voice_fill_err(err, + err_len, + decode_err[0] != '\0' ? decode_err : "decode generated image failed"); + ESP_LOGW(TAG, + "[stage] marker_image_print_decode_failed: dialog_id=%s rc=0x%x msg=%s", + task_arg->dialog_id[0] != '\0' ? task_arg->dialog_id : "-", + (unsigned)decode_rc, + err != NULL && err[0] != '\0' ? err : "decode generated image failed"); + return decode_rc; + } + + ESP_LOGI(TAG, + "[stage] marker_image_print_decode_done: dialog_id=%s raster_size=%ux%u raster_bytes=%u", + task_arg->dialog_id[0] != '\0' ? task_arg->dialog_id : "-", + (unsigned)raster_width, + (unsigned)raster_height, + (unsigned)raster_len); + + uint32_t job_id = 0; + char submit_err[128] = {0}; + printer_print_options_t print_opt = { + .direct_ignore_precheck = true, + }; + esp_err_t submit_rc = printer_protocol_submit_raster_job_ex(raster, + raster_len, + raster_width, + raster_height, + NULL, + &print_opt, + &job_id, + submit_err, + sizeof(submit_err)); + if (submit_rc != ESP_OK) { + voice_fill_err(err, + err_len, + submit_err[0] != '\0' ? submit_err : "submit print job failed"); + ESP_LOGW(TAG, + "[stage] marker_image_print_submit_failed: dialog_id=%s rc=0x%x msg=%s", + task_arg->dialog_id[0] != '\0' ? task_arg->dialog_id : "-", + (unsigned)submit_rc, + err != NULL && err[0] != '\0' ? err : "submit print job failed"); + } else { + ESP_LOGI(TAG, + "[stage] marker_image_print_submit_ok: dialog_id=%s request_id=%s job_id=%u", + task_arg->dialog_id[0] != '\0' ? task_arg->dialog_id : "-", + result->request_id[0] != '\0' ? result->request_id : "-", + (unsigned)job_id); + } + + free(raster); + return submit_rc; +} + +static void voice_image_generation_task(void *arg) { + voice_image_generation_task_arg_t *task_arg = (voice_image_generation_task_arg_t *)arg; + if (task_arg == NULL) { + vTaskDelete(NULL); + return; + } + + image_generation_request_t req = { + .prompt = task_arg->prompt, + .size = NULL, + .prompt_extend = false, + .has_seed = false, + .seed = 0, + .generation_timeout_ms = 0, + .download_timeout_ms = 0, + }; + + image_generation_result_t result = {0}; + image_generation_result_reset(&result); + + printer_status_poll_pause_token_t status_poll_pause_token = 0; + char gen_err[160] = {0}; + ESP_LOGI(TAG, + "[stage] marker_image_gen_start: dialog_id=%s prompt_len=%u", + task_arg->dialog_id[0] != '\0' ? task_arg->dialog_id : "-", + (unsigned)strlen(task_arg->prompt)); + status_poll_pause_token = printer_protocol_status_poll_pause_acquire(); + esp_err_t rc = image_generation_generate_png(&req, &result, gen_err, sizeof(gen_err)); + printer_protocol_status_poll_pause_release(&status_poll_pause_token); + if (rc == ESP_OK) { + ESP_LOGI(TAG, + "[stage] marker_image_gen_done: dialog_id=%s request_id=%s size=%ux%u png_bytes=%u", + task_arg->dialog_id[0] != '\0' ? task_arg->dialog_id : "-", + result.request_id[0] != '\0' ? result.request_id : "-", + (unsigned)result.width, + (unsigned)result.height, + (unsigned)result.png_len); + + char submit_err[160] = {0}; + esp_err_t submit_rc = voice_submit_marker_generated_image(task_arg, + &result, + submit_err, + sizeof(submit_err)); + if (submit_rc == ESP_OK) { + voice_marker_image_generation_record_success(task_arg->prompt, + task_arg->dialog_id, + voice_now_ms()); + } else { + ESP_LOGW(TAG, + "[stage] marker_image_print_failed: dialog_id=%s rc=0x%x msg=%s", + task_arg->dialog_id[0] != '\0' ? task_arg->dialog_id : "-", + (unsigned)submit_rc, + submit_err[0] != '\0' ? submit_err : "marker image print failed"); + } + } else { + ESP_LOGW(TAG, + "[stage] marker_image_gen_failed: dialog_id=%s rc=0x%x msg=%s", + task_arg->dialog_id[0] != '\0' ? task_arg->dialog_id : "-", + (unsigned)rc, + gen_err[0] != '\0' ? gen_err : "image generation failed"); + } + + image_generation_result_free(&result); + voice_marker_image_generation_release(); + image_generation_schedule_prewarm(); + free(task_arg); + vTaskDelete(NULL); +} + +static void voice_schedule_marker_image_generation(const char *prompt, const char *dialog_id) { + if (prompt == NULL || prompt[0] == '\0') { + return; + } + + size_t prompt_len = strlen(prompt); + if (prompt_len > VOICE_IMAGE_MARKER_PROMPT_MAX_LEN) { + ESP_LOGW(TAG, + "[stage] marker_image_gen_skip: prompt too long, len=%u", + (unsigned)prompt_len); + return; + } + + uint32_t repeat_remain_ms = 0; + if (!voice_marker_image_generation_try_acquire(prompt, + dialog_id, + voice_now_ms(), + &repeat_remain_ms)) { + if (repeat_remain_ms > 0) { + ESP_LOGI(TAG, + "[stage] marker_image_gen_skip: duplicate prompt suppressed, remain_ms=%u", + (unsigned)repeat_remain_ms); + } else { + ESP_LOGI(TAG, "[stage] marker_image_gen_skip: previous marker task running"); + } + return; + } + + voice_image_generation_task_arg_t *task_arg = + (voice_image_generation_task_arg_t *)calloc(1, sizeof(*task_arg)); + if (task_arg == NULL) { + ESP_LOGW(TAG, "[stage] marker_image_gen_skip: no memory for task arg"); + voice_marker_image_generation_release(); + return; + } + + strlcpy(task_arg->prompt, prompt, sizeof(task_arg->prompt)); + if (dialog_id != NULL) { + strlcpy(task_arg->dialog_id, dialog_id, sizeof(task_arg->dialog_id)); + } + + BaseType_t ok = xTaskCreate(voice_image_generation_task, + "voice_img_gen", + VOICE_IMAGE_GEN_TASK_STACK, + task_arg, + VOICE_IMAGE_GEN_TASK_PRIO, + NULL); + if (ok != pdPASS) { + ESP_LOGW(TAG, "[stage] marker_image_gen_skip: create task failed"); + voice_marker_image_generation_release(); + free(task_arg); + return; + } + + ESP_LOGI(TAG, + "[stage] marker_image_gen_queued: dialog_id=%s prompt=%s", + task_arg->dialog_id[0] != '\0' ? task_arg->dialog_id : "-", + task_arg->prompt); +} + +void voice_marker_try_trigger_image_generation(const char *dialog_id, const char *final_text) { + if (final_text == NULL || final_text[0] == '\0') { + return; + } + + char first_sentence[VOICE_IMAGE_MARKER_FIRST_SENTENCE_MAX_LEN] = {0}; + if (!voice_extract_first_sentence_text(final_text, first_sentence, sizeof(first_sentence))) { + return; + } + + char marker_subject[VOICE_IMAGE_MARKER_PROMPT_MAX_LEN + 1] = {0}; + if (!voice_extract_marker_prompt(first_sentence, marker_subject, sizeof(marker_subject))) { + return; + } + + char templated_prompt[VOICE_IMAGE_MARKER_PROMPT_MAX_LEN + 1] = {0}; + voice_build_marker_image_prompt(marker_subject, + templated_prompt, + sizeof(templated_prompt)); + if (templated_prompt[0] == '\0') { + ESP_LOGW(TAG, "[stage] marker_image_gen_skip: build prompt failed"); + return; + } + + ESP_LOGI(TAG, + "[stage] marker_image_gen_detected: first_sentence=%s", + first_sentence); + ESP_LOGI(TAG, + "[stage] marker_image_gen_subject: keyword=%s", + marker_subject); + voice_log_text_preview("marker_image_gen_prompt_templated", templated_prompt); + voice_schedule_marker_image_generation(templated_prompt, dialog_id); +} + +bool voice_marker_image_generation_is_running(void) { + bool running = false; + portENTER_CRITICAL(&s_marker_image_gen_lock); + running = s_marker_image_gen_running; + portEXIT_CRITICAL(&s_marker_image_gen_lock); + return running; +} diff --git a/components/domain/src/voice_interaction_tasks.c b/components/domain/src/voice_interaction_tasks.c new file mode 100644 index 0000000..fea87c2 --- /dev/null +++ b/components/domain/src/voice_interaction_tasks.c @@ -0,0 +1,154 @@ +#include "voice_interaction_internal.h" + +#include + +#include "esp_audio_enc.h" +#include "esp_log.h" + +static const char *TAG = "voice_interaction"; + +void voice_uplink_task(void *arg) { + (void)arg; + + bool low_stack_warned = false; + UBaseType_t start_hwm = uxTaskGetStackHighWaterMark(NULL); + ESP_LOGI(TAG, "[stage] uplink_task_started: stack_hwm_words=%u", (unsigned)start_hwm); + + while (true) { + if (!low_stack_warned) { + UBaseType_t hwm = uxTaskGetStackHighWaterMark(NULL); + if (hwm < 256) { + low_stack_warned = true; + ESP_LOGW(TAG, "[stage] uplink_task_low_stack: hwm_words=%u", (unsigned)hwm); + } + } + + bool active = false; + bool connected = false; + bool started = false; + bool tap_active = false; + bool listening = false; + + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + active = s_voice.session_active && !s_voice.stop_requested; + connected = s_voice.ws_connected; + started = s_voice.started; + tap_active = s_voice.tap_active; + listening = (s_voice.dialog_state == VOICE_DIALOG_STATE_LISTENING); + voice_unlock(); + } + + if (!active) { + break; + } + + if (!(connected && started && listening && tap_active)) { + vTaskDelay(pdMS_TO_TICKS(20)); + continue; + } + + char read_err[64] = {0}; + esp_err_t rc = voice_audio_read_pcm(s_voice.pcm_tx_buf, + (size_t)s_voice.opus_enc_in_size / VOICE_SAMPLE_BYTES, + VOICE_AUDIO_IO_TIMEOUT_MS, + read_err, + sizeof(read_err)); + if (rc != ESP_OK) { + memset(s_voice.pcm_tx_buf, 0, (size_t)s_voice.opus_enc_in_size); + } + + esp_audio_enc_in_frame_t in_frame = { + .buffer = (uint8_t *)s_voice.pcm_tx_buf, + .len = (uint32_t)s_voice.opus_enc_in_size, + }; + esp_audio_enc_out_frame_t out_frame = { + .buffer = s_voice.opus_tx_buf, + .len = (uint32_t)s_voice.opus_enc_out_size, + .encoded_bytes = 0, + }; + + esp_audio_err_t enc_err = esp_opus_enc_process(s_voice.opus_enc, &in_frame, &out_frame); + if (enc_err != ESP_AUDIO_ERR_OK || out_frame.encoded_bytes == 0) { + continue; + } + + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + esp_websocket_client_handle_t ws = s_voice.ws; + bool ws_connected = s_voice.ws_connected; + voice_unlock(); + + bool transport_connected = (ws != NULL) && esp_websocket_client_is_connected(ws); + if (ws_connected && !transport_connected && voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + if (s_voice.ws_connected) { + s_voice.ws_connected = false; + s_voice.started = false; + s_voice.tap_active = false; + s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE; + s_voice.last_event_ms = voice_now_ms(); + ESP_LOGW(TAG, "[stage] uplink_detected_ws_disconnected"); + } + voice_unlock(); + } + + if (ws != NULL && ws_connected && transport_connected) { + int ret = esp_websocket_client_send_bin(ws, + (const char *)s_voice.opus_tx_buf, + (int)out_frame.encoded_bytes, + pdMS_TO_TICKS(VOICE_WS_SEND_TIMEOUT_MS)); + if (ret >= 0) { + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + s_voice.upstream_packets++; + s_voice.last_event_ms = voice_now_ms(); + voice_unlock(); + } + } + } + } + } + + bool delete_with_caps = false; + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + delete_with_caps = s_voice.uplink_task_with_caps; + s_voice.uplink_task = NULL; + s_voice.uplink_task_with_caps = false; + voice_unlock(); + } + voice_delete_self_task(delete_with_caps); +} + +void voice_heartbeat_task(void *arg) { + (void)arg; + + while (true) { + bool active = false; + bool connected = false; + bool started = false; + + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + active = s_voice.session_active && !s_voice.stop_requested; + connected = s_voice.ws_connected; + started = s_voice.started; + voice_unlock(); + } + + if (!active) { + break; + } + + if (connected && started) { + ESP_LOGI(TAG, "[stage] heartbeat_send"); + (void)voice_send_directive("continue-task", "HeartBeat", true); + } + + vTaskDelay(pdMS_TO_TICKS(CONFIG_TQ_VOICE_HEARTBEAT_SEC * 1000)); + } + + bool delete_with_caps = false; + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + delete_with_caps = s_voice.heartbeat_task_with_caps; + s_voice.heartbeat_task = NULL; + s_voice.heartbeat_task_with_caps = false; + voice_unlock(); + } + voice_delete_self_task(delete_with_caps); +} diff --git a/components/domain/src/voice_interaction_ws.c b/components/domain/src/voice_interaction_ws.c new file mode 100644 index 0000000..ba28922 --- /dev/null +++ b/components/domain/src/voice_interaction_ws.c @@ -0,0 +1,340 @@ +#include "voice_interaction_internal.h" + +#include +#include + +#define cJSON_malloc voice_malloc_prefer_psram +#define cJSON_realloc voice_realloc_prefer_psram +#include "cJSON.h" +#undef cJSON_realloc +#undef cJSON_malloc +#include "esp_log.h" + +static const char *TAG = "voice_interaction"; + +static void *voice_cjson_malloc_hook(size_t size) { + return voice_malloc_prefer_psram(size); +} + +static void voice_cjson_init_hooks_once(void) { + static bool s_cjson_hooks_ready; + if (s_cjson_hooks_ready) { + return; + } + + cJSON_Hooks hooks = { + .malloc_fn = voice_cjson_malloc_hook, + .free_fn = free, + }; + cJSON_InitHooks(&hooks); + s_cjson_hooks_ready = true; +} + +void voice_interaction_ws_init_cjson_hooks(void) { + voice_cjson_init_hooks_once(); +} + +static char *voice_build_directive_message(const char *action, + const char *directive, + bool with_dialog_id) { + voice_cjson_init_hooks_once(); + + cJSON *root = cJSON_CreateObject(); + if (root == NULL) { + return NULL; + } + + cJSON *header = cJSON_AddObjectToObject(root, "header"); + cJSON_AddStringToObject(header, "action", action); + cJSON_AddStringToObject(header, "task_id", s_voice.task_id); + cJSON_AddStringToObject(header, "streaming", VOICE_STREAMING_MODE); + + cJSON *payload = cJSON_AddObjectToObject(root, "payload"); + cJSON *input = cJSON_AddObjectToObject(payload, "input"); + cJSON_AddStringToObject(input, "directive", directive); + if (with_dialog_id && s_voice.dialog_id[0] != '\0') { + cJSON_AddStringToObject(input, "dialog_id", s_voice.dialog_id); + } + + char *text = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + return text; +} + +static char *voice_build_start_message(void) { + voice_cjson_init_hooks_once(); + + cJSON *root = cJSON_CreateObject(); + if (root == NULL) { + return NULL; + } + + cJSON *header = cJSON_AddObjectToObject(root, "header"); + cJSON_AddStringToObject(header, "action", "run-task"); + cJSON_AddStringToObject(header, "task_id", s_voice.task_id); + cJSON_AddStringToObject(header, "streaming", VOICE_STREAMING_MODE); + + cJSON *payload = cJSON_AddObjectToObject(root, "payload"); + cJSON_AddStringToObject(payload, "task_group", VOICE_TASK_GROUP); + cJSON_AddStringToObject(payload, "task", VOICE_TASK_NAME); + cJSON_AddStringToObject(payload, "function", VOICE_FUNCTION_NAME); + cJSON_AddStringToObject(payload, "model", VOICE_MODEL_NAME); + + cJSON *input = cJSON_AddObjectToObject(payload, "input"); + cJSON_AddStringToObject(input, "directive", "Start"); + cJSON_AddStringToObject(input, "workspace_id", CONFIG_TQ_VOICE_WORKSPACE_ID); + cJSON_AddStringToObject(input, "app_id", CONFIG_TQ_VOICE_APP_ID); + + cJSON *parameters = cJSON_AddObjectToObject(payload, "parameters"); + cJSON *upstream = cJSON_AddObjectToObject(parameters, "upstream"); + cJSON_AddStringToObject(upstream, "type", "AudioOnly"); + cJSON_AddStringToObject(upstream, "mode", "tap2talk"); + cJSON_AddStringToObject(upstream, "audio_format", "raw-opus"); + cJSON_AddNumberToObject(upstream, "sample_rate", CONFIG_TQ_VOICE_SAMPLE_RATE); + + cJSON *downstream = cJSON_AddObjectToObject(parameters, "downstream"); + cJSON_AddNumberToObject(downstream, "sample_rate", CONFIG_TQ_VOICE_SAMPLE_RATE); + cJSON_AddStringToObject(downstream, "audio_format", "raw-opus"); + cJSON_AddNumberToObject(downstream, "frame_size", CONFIG_TQ_VOICE_OPUS_FRAME_MS); + cJSON_AddNumberToObject(downstream, + "bit_rate", + CONFIG_TQ_VOICE_OPUS_BITRATE_KBPS); + if (strlen(CONFIG_TQ_VOICE_TTS_VOICE) > 0) { + cJSON_AddStringToObject(downstream, "voice", CONFIG_TQ_VOICE_TTS_VOICE); + } + + cJSON *client_info = cJSON_AddObjectToObject(parameters, "client_info"); + cJSON_AddStringToObject(client_info, "user_id", voice_safe_user_id()); + cJSON *device = cJSON_AddObjectToObject(client_info, "device"); + cJSON_AddStringToObject(device, "uuid", s_voice.device_uuid); + + char *text = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + return text; +} + +static esp_err_t voice_send_text(const char *text, int len) { + if (text == NULL || len <= 0) { + return ESP_ERR_INVALID_ARG; + } + + esp_websocket_client_handle_t ws = NULL; + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + return ESP_ERR_TIMEOUT; + } + ws = s_voice.ws; + bool connected = s_voice.ws_connected; + voice_unlock(); + + bool transport_connected = (ws != NULL) && esp_websocket_client_is_connected(ws); + if (connected && !transport_connected && voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + if (s_voice.ws_connected) { + s_voice.ws_connected = false; + s_voice.started = false; + s_voice.tap_active = false; + s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE; + s_voice.last_event_ms = voice_now_ms(); + ESP_LOGW(TAG, "[stage] ws_state_reconciled: transport disconnected"); + } + voice_unlock(); + } + + if (ws == NULL || !connected || !transport_connected) { + ESP_LOGW(TAG, "[stage] ws_send_text skipped: ws=%p connected=%d len=%d", (void *)ws, connected, len); + return ESP_ERR_INVALID_STATE; + } + + int ret = esp_websocket_client_send_text(ws, + text, + len, + pdMS_TO_TICKS(VOICE_WS_SEND_TIMEOUT_MS)); + if (ret < 0) { + ESP_LOGW(TAG, "[stage] ws_send_text failed: len=%d", len); + return ESP_FAIL; + } + ESP_LOGI(TAG, "[stage] ws_send_text ok: len=%d", len); + return ESP_OK; +} + +esp_err_t voice_send_directive(const char *action, + const char *directive, + bool with_dialog_id) { + char *text = NULL; + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + return ESP_ERR_TIMEOUT; + } + text = voice_build_directive_message(action, directive, with_dialog_id); + voice_unlock(); + + if (text == NULL) { + return ESP_ERR_NO_MEM; + } + + esp_err_t err = voice_send_text(text, (int)strlen(text)); + ESP_LOGI(TAG, + "[stage] directive: action=%s directive=%s with_dialog_id=%d result=%s", + action, + directive, + with_dialog_id, + esp_err_to_name(err)); + cJSON_free(text); + return err; +} + +static void voice_handle_text_chunk(esp_websocket_event_data_t *data) { + if (data->payload_offset == 0) { + free(s_voice.text_agg); + s_voice.text_agg = NULL; + s_voice.text_agg_size = 0; + + if (data->payload_len <= 0 || data->payload_len > 4096) { + return; + } + + s_voice.text_agg = (uint8_t *)voice_calloc_prefer_psram(1, (size_t)data->payload_len + 1); + if (s_voice.text_agg == NULL) { + ESP_LOGW(TAG, "[stage] text_agg_alloc_failed: len=%d", data->payload_len); + return; + } + s_voice.text_agg_size = (size_t)data->payload_len; + } + + if (s_voice.text_agg == NULL || s_voice.text_agg_size == 0) { + return; + } + + if ((size_t)data->payload_offset + (size_t)data->data_len > s_voice.text_agg_size) { + free(s_voice.text_agg); + s_voice.text_agg = NULL; + s_voice.text_agg_size = 0; + return; + } + + memcpy(s_voice.text_agg + data->payload_offset, data->data_ptr, (size_t)data->data_len); + + bool complete = data->fin && + ((size_t)data->payload_offset + (size_t)data->data_len == s_voice.text_agg_size); + if (!complete) { + return; + } + + voice_ws_protocol_handle_text_message((const char *)s_voice.text_agg, s_voice.text_agg_size); + free(s_voice.text_agg); + s_voice.text_agg = NULL; + s_voice.text_agg_size = 0; +} + +static void voice_handle_binary_chunk(esp_websocket_event_data_t *data) { + if (data->payload_offset == 0) { + free(s_voice.bin_agg); + s_voice.bin_agg = NULL; + s_voice.bin_agg_size = 0; + + if (data->payload_len <= 0 || data->payload_len > 16384) { + return; + } + + s_voice.bin_agg = (uint8_t *)voice_malloc_prefer_psram((size_t)data->payload_len); + if (s_voice.bin_agg == NULL) { + ESP_LOGW(TAG, "[stage] bin_agg_alloc_failed: len=%d", data->payload_len); + return; + } + s_voice.bin_agg_size = (size_t)data->payload_len; + } + + if (s_voice.bin_agg == NULL || s_voice.bin_agg_size == 0) { + return; + } + + if ((size_t)data->payload_offset + (size_t)data->data_len > s_voice.bin_agg_size) { + free(s_voice.bin_agg); + s_voice.bin_agg = NULL; + s_voice.bin_agg_size = 0; + return; + } + + memcpy(s_voice.bin_agg + data->payload_offset, data->data_ptr, (size_t)data->data_len); + + bool complete = data->fin && + ((size_t)data->payload_offset + (size_t)data->data_len == s_voice.bin_agg_size); + if (!complete) { + return; + } + + voice_ws_audio_handle_downstream_packet(s_voice.bin_agg, s_voice.bin_agg_size); + free(s_voice.bin_agg); + s_voice.bin_agg = NULL; + s_voice.bin_agg_size = 0; +} + +void voice_websocket_event_handler(void *handler_args, + esp_event_base_t base, + int32_t event_id, + void *event_data) { + (void)handler_args; + (void)base; + + UBaseType_t ws_hwm = uxTaskGetStackHighWaterMark(NULL); + if (!s_voice.ws_low_stack_warned && ws_hwm < 256) { + s_voice.ws_low_stack_warned = true; + ESP_LOGW(TAG, "[stage] ws_task_low_stack: hwm_words=%u", (unsigned)ws_hwm); + } + + esp_websocket_event_data_t *data = (esp_websocket_event_data_t *)event_data; + switch ((esp_websocket_event_id_t)event_id) { + case WEBSOCKET_EVENT_CONNECTED: { + ESP_LOGI(TAG, "[stage] ws_connected"); + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + s_voice.ws_connected = true; + s_voice.last_event_ms = voice_now_ms(); + voice_unlock(); + } + + char *start_msg = NULL; + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + start_msg = voice_build_start_message(); + voice_unlock(); + } + if (start_msg != NULL) { + esp_err_t send_err = voice_send_text(start_msg, (int)strlen(start_msg)); + ESP_LOGI(TAG, "[stage] start_message_sent: result=%s", esp_err_to_name(send_err)); + cJSON_free(start_msg); + } else { + ESP_LOGE(TAG, "[stage] start_message_build_failed"); + } + break; + } + case WEBSOCKET_EVENT_DISCONNECTED: + ESP_LOGW(TAG, "[stage] ws_disconnected"); + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + s_voice.ws_connected = false; + s_voice.started = false; + s_voice.tap_active = false; + s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE; + s_voice.last_event_ms = voice_now_ms(); + voice_unlock(); + } + voice_close_audio_with_drain(VOICE_AUDIO_DRAIN_WAIT_MS, "ws_disconnected_close"); + break; + case WEBSOCKET_EVENT_DATA: + if (data == NULL || data->data_ptr == NULL || data->data_len <= 0) { + break; + } + if (data->op_code == WS_TRANSPORT_OPCODES_TEXT) { + voice_handle_text_chunk(data); + } else if (data->op_code == WS_TRANSPORT_OPCODES_BINARY) { + voice_handle_binary_chunk(data); + } + break; + case WEBSOCKET_EVENT_ERROR: + ESP_LOGW(TAG, "[stage] ws_error"); + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + voice_set_last_error_locked("websocket error"); + s_voice.last_event_ms = voice_now_ms(); + voice_unlock(); + } + break; + default: + break; + } +} diff --git a/components/domain/src/voice_interaction_ws_audio.c b/components/domain/src/voice_interaction_ws_audio.c new file mode 100644 index 0000000..518bfbe --- /dev/null +++ b/components/domain/src/voice_interaction_ws_audio.c @@ -0,0 +1,99 @@ +#include "voice_interaction_internal.h" + +#include "esp_audio_dec.h" +#include "esp_log.h" + +static const char *TAG = "voice_interaction"; + +void voice_ws_audio_handle_downstream_packet(const uint8_t *data, size_t len) { + if (data == NULL || len == 0) { + return; + } + + if (!voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + return; + } + + void *opus_dec = s_voice.opus_dec; + uint8_t *pcm_buf = s_voice.pcm_rx_buf; + size_t pcm_buf_size = s_voice.pcm_rx_buf_size; + voice_unlock(); + + if (opus_dec == NULL || pcm_buf == NULL || pcm_buf_size == 0) { + return; + } + + esp_audio_dec_in_raw_t raw = { + .buffer = (uint8_t *)data, + .len = (uint32_t)len, + .consumed = 0, + }; + + while (raw.len > 0) { + esp_audio_dec_out_frame_t out = { + .buffer = pcm_buf, + .len = (uint32_t)pcm_buf_size, + .needed_size = 0, + .decoded_size = 0, + }; + esp_audio_dec_info_t dec_info = {0}; + + esp_audio_err_t ret = esp_opus_dec_decode(opus_dec, &raw, &out, &dec_info); + if (ret == ESP_AUDIO_ERR_BUFF_NOT_ENOUGH) { + if (out.needed_size > pcm_buf_size) { + uint8_t *new_buf = (uint8_t *)voice_realloc_prefer_psram(pcm_buf, out.needed_size); + if (new_buf == NULL) { + ESP_LOGE(TAG, "No memory to extend pcm rx buffer to %u", out.needed_size); + break; + } + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + s_voice.pcm_rx_buf = new_buf; + s_voice.pcm_rx_buf_size = out.needed_size; + voice_unlock(); + } + pcm_buf = new_buf; + pcm_buf_size = out.needed_size; + continue; + } + break; + } + + if (ret != ESP_AUDIO_ERR_OK) { + ESP_LOGW(TAG, "opus decode failed: %d", ret); + break; + } + + if (out.decoded_size > 0) { + char audio_err[64] = {0}; + size_t samples = (size_t)out.decoded_size / VOICE_SAMPLE_BYTES; + esp_err_t write_err = voice_audio_write_pcm((const int16_t *)out.buffer, + samples, + VOICE_AUDIO_IO_TIMEOUT_MS, + audio_err, + sizeof(audio_err)); + if (write_err == ESP_OK && voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + int64_t now_ms = voice_now_ms(); + int64_t pcm_ms = ((int64_t)samples * 1000 + + (int64_t)CONFIG_TQ_VOICE_SAMPLE_RATE - 1) / + (int64_t)CONFIG_TQ_VOICE_SAMPLE_RATE; + int64_t base_ms = s_voice.playback_deadline_ms > now_ms ? + s_voice.playback_deadline_ms : now_ms; + s_voice.playback_deadline_ms = base_ms + pcm_ms + VOICE_AUDIO_DRAIN_MARGIN_MS; + s_voice.last_downstream_ms = now_ms; + voice_unlock(); + } + } + + if (raw.consumed == 0 || raw.consumed > raw.len) { + break; + } + raw.buffer += raw.consumed; + raw.len -= raw.consumed; + } + + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + s_voice.downstream_packets++; + s_voice.last_event_ms = voice_now_ms(); + voice_unlock(); + } +} diff --git a/components/domain/src/voice_interaction_ws_protocol.c b/components/domain/src/voice_interaction_ws_protocol.c new file mode 100644 index 0000000..97572ef --- /dev/null +++ b/components/domain/src/voice_interaction_ws_protocol.c @@ -0,0 +1,252 @@ +#include "voice_interaction_internal.h" + +#include + +#define cJSON_malloc voice_malloc_prefer_psram +#define cJSON_realloc voice_realloc_prefer_psram +#include "cJSON.h" +#undef cJSON_realloc +#undef cJSON_malloc +#include "esp_log.h" + +static const char *TAG = "voice_interaction"; + +static const char *voice_get_json_str(cJSON *obj, const char *name) { + if (obj == NULL || name == NULL) { + return NULL; + } + + cJSON *item = cJSON_GetObjectItemCaseSensitive(obj, name); + if (cJSON_IsString(item) && item->valuestring != NULL) { + return item->valuestring; + } + return NULL; +} + +static bool voice_get_json_bool(cJSON *obj, const char *name, bool *out_value) { + if (obj == NULL || name == NULL || out_value == NULL) { + return false; + } + + cJSON *item = cJSON_GetObjectItemCaseSensitive(obj, name); + if (!cJSON_IsBool(item)) { + return false; + } + *out_value = cJSON_IsTrue(item); + return true; +} + +static void voice_log_final_text(const char *stage, const char *text) { + if (stage == NULL || text == NULL || text[0] == '\0') { + return; + } + + size_t len = strlen(text); + const int preview = 240; + ESP_LOGI(TAG, + "[stage] %s: len=%u text=%.*s%s", + stage, + (unsigned)len, + preview, + text, + (len > (size_t)preview) ? "..." : ""); +} + +static void voice_handle_output_event(cJSON *output) { + const char *event_name = voice_get_json_str(output, "event"); + if (event_name == NULL) { + return; + } + + const char *dialog_id = voice_get_json_str(output, "dialog_id"); + const char *state = NULL; + if (strcmp(event_name, "DialogStateChanged") == 0) { + state = voice_get_json_str(output, "state"); + } + ESP_LOGI(TAG, + "[stage] ws_event_output: event=%s dialog_id=%s state=%s", + event_name, + dialog_id != NULL ? dialog_id : "-", + state != NULL ? state : "-"); + + bool finished = false; + bool has_finished = voice_get_json_bool(output, "finished", &finished); + const char *responding_final_text = NULL; + bool should_check_marker_in_first_sentence = false; + if (has_finished && finished) { + if (strcmp(event_name, "SpeechContent") == 0) { + voice_log_final_text("asr_final_text", voice_get_json_str(output, "text")); + } else if (strcmp(event_name, "RespondingContent") == 0) { + const char *final_text = voice_get_json_str(output, "text"); + if (final_text == NULL || final_text[0] == '\0') { + final_text = voice_get_json_str(output, "spoken"); + } + voice_log_final_text("response_final_text", final_text); + responding_final_text = final_text; + if (responding_final_text != NULL && responding_final_text[0] != '\0' && + voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + if (!s_voice.response_first_sentence_checked) { + s_voice.response_first_sentence_checked = true; + should_check_marker_in_first_sentence = true; + } + voice_unlock(); + } + } + } + + bool should_close_audio = false; + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + if (dialog_id != NULL) { + strlcpy(s_voice.dialog_id, dialog_id, sizeof(s_voice.dialog_id)); + } + s_voice.last_event_ms = voice_now_ms(); + + if (strcmp(event_name, "Started") == 0) { + s_voice.started = true; + s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE; + s_voice.response_first_sentence_checked = false; + ESP_LOGI(TAG, "[stage] session_started: dialog_id=%s", s_voice.dialog_id); + } else if (strcmp(event_name, "DialogStateChanged") == 0) { + if (state != NULL) { + voice_dialog_state_t prev_state = s_voice.dialog_state; + if (strcmp(state, "Listening") == 0) { + s_voice.dialog_state = VOICE_DIALOG_STATE_LISTENING; + if (prev_state != VOICE_DIALOG_STATE_LISTENING) { + s_voice.response_first_sentence_checked = false; + } + } else if (strcmp(state, "Thinking") == 0) { + s_voice.dialog_state = VOICE_DIALOG_STATE_THINKING; + if (prev_state != VOICE_DIALOG_STATE_THINKING) { + s_voice.response_first_sentence_checked = false; + } + } else if (strcmp(state, "Responding") == 0) { + s_voice.dialog_state = VOICE_DIALOG_STATE_RESPONDING; + if (prev_state != VOICE_DIALOG_STATE_RESPONDING) { + s_voice.response_first_sentence_checked = false; + } + } + } + ESP_LOGI(TAG, + "[stage] dialog_state_changed: state=%s current=%s", + state != NULL ? state : "-", + voice_interaction_dialog_state_str(s_voice.dialog_state)); + } else if (strcmp(event_name, "SpeechEnded") == 0) { + s_voice.tap_active = false; + ESP_LOGI(TAG, "[stage] speech_ended: mark single_round_input_done"); + } else if (strcmp(event_name, "RespondingEnded") == 0) { + s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE; + ESP_LOGI(TAG, "[stage] responding_ended: wait_local_playback_before_ack"); + } else if (strcmp(event_name, "Stopped") == 0) { + s_voice.started = false; + s_voice.tap_active = false; + s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE; + s_voice.response_first_sentence_checked = false; + should_close_audio = true; + ESP_LOGI(TAG, "[stage] session_stopped_by_server"); + } else if (strcmp(event_name, "Error") == 0) { + const char *error_msg = voice_get_json_str(output, "error_message"); + if (error_msg != NULL) { + voice_set_last_error_locked(error_msg); + ESP_LOGW(TAG, "[stage] server_error: %s", error_msg); + } + s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE; + s_voice.tap_active = false; + s_voice.response_first_sentence_checked = false; + should_close_audio = true; + } + voice_unlock(); + } + + if (should_close_audio) { + voice_close_audio_with_drain(VOICE_AUDIO_DRAIN_WAIT_MS, "server_event_close"); + } + + if (should_check_marker_in_first_sentence) { + voice_marker_try_trigger_image_generation(dialog_id, responding_final_text); + } + + if (strcmp(event_name, "RespondingStarted") == 0) { + (void)voice_send_directive("continue-task", "LocalRespondingStarted", true); + } else if (strcmp(event_name, "RespondingEnded") == 0) { + uint32_t playback_wait_ms = VOICE_AUDIO_DRAIN_WAIT_MS; + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + int64_t now_ms = voice_now_ms(); + int64_t deadline_ms = s_voice.playback_deadline_ms; + voice_unlock(); + + if (deadline_ms > now_ms) { + int64_t remain_ms = (deadline_ms - now_ms) + VOICE_AUDIO_DRAIN_QUIET_MS + 120; + if (remain_ms > (int64_t)playback_wait_ms) { + if (remain_ms > 12000) { + playback_wait_ms = 12000; + } else { + playback_wait_ms = (uint32_t)remain_ms; + } + } + } + } + ESP_LOGI(TAG, "[stage] responding_ended_local_playback_wait: timeout_ms=%u", (unsigned)playback_wait_ms); + voice_wait_audio_playback_done(playback_wait_ms, + "responding_ended_local_playback"); + (void)voice_send_directive("continue-task", "LocalRespondingEnded", true); + (void)voice_send_directive("finish-task", "Stop", true); + } +} + +void voice_ws_protocol_handle_text_message(const char *text, size_t len) { + if (text == NULL || len == 0) { + return; + } + + voice_interaction_ws_init_cjson_hooks(); + + cJSON *root = cJSON_ParseWithLength(text, len); + if (root == NULL) { + ESP_LOGW(TAG, "Invalid ws text payload"); + return; + } + + cJSON *header = cJSON_GetObjectItemCaseSensitive(root, "header"); + const char *header_event = voice_get_json_str(header, "event"); + + cJSON *payload = cJSON_GetObjectItemCaseSensitive(root, "payload"); + cJSON *output = NULL; + if (payload != NULL) { + output = cJSON_GetObjectItemCaseSensitive(payload, "output"); + } + + if (header != NULL) { + cJSON *status_code = cJSON_GetObjectItemCaseSensitive(header, "status_code"); + cJSON *status_msg = cJSON_GetObjectItemCaseSensitive(header, "status_message"); + if (cJSON_IsNumber(status_code) && status_code->valueint >= 400) { + ESP_LOGW(TAG, + "[stage] ws_header_error: status_code=%d status_message=%s", + status_code->valueint, + (cJSON_IsString(status_msg) && status_msg->valuestring != NULL) ? status_msg->valuestring : "-"); + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + if (cJSON_IsString(status_msg) && status_msg->valuestring != NULL) { + voice_set_last_error_locked(status_msg->valuestring); + } else { + voice_set_last_error_locked("websocket status error"); + } + voice_unlock(); + } + } + } + + if (output != NULL) { + voice_handle_output_event(output); + } else if (header_event != NULL && strcmp(header_event, "task-failed") == 0) { + const char *error_msg = voice_get_json_str(header, "error_message"); + ESP_LOGW(TAG, "[stage] ws_task_failed: %s", error_msg != NULL ? error_msg : "task failed"); + if (voice_lock(VOICE_STATUS_LOCK_TIMEOUT_MS)) { + voice_set_last_error_locked(error_msg != NULL ? error_msg : "task failed"); + s_voice.started = false; + s_voice.tap_active = false; + s_voice.dialog_state = VOICE_DIALOG_STATE_IDLE; + voice_unlock(); + } + } + + cJSON_Delete(root); +} diff --git a/components/domain/third_party/qrcodegen.c b/components/domain/third_party/qrcodegen.c new file mode 100644 index 0000000..34f1002 --- /dev/null +++ b/components/domain/third_party/qrcodegen.c @@ -0,0 +1,1027 @@ +/* + * QR Code generator library (C) + * + * Copyright (c) Project Nayuki. (MIT License) + * https://www.nayuki.io/page/qr-code-generator-library + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * - The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * - The Software is provided "as is", without warranty of any kind, express or + * implied, including but not limited to the warranties of merchantability, + * fitness for a particular purpose and noninfringement. In no event shall the + * authors or copyright holders be liable for any claim, damages or other + * liability, whether in an action of contract, tort or otherwise, arising from, + * out of or in connection with the Software or the use or other dealings in the + * Software. + */ + +#include +#include +#include +#include +#include "qrcodegen.h" + +#ifndef QRCODEGEN_TEST + #define testable static // Keep functions private +#else + #define testable // Expose private functions +#endif + + +/*---- Forward declarations for private functions ----*/ + +// Regarding all public and private functions defined in this source file: +// - They require all pointer/array arguments to be not null unless the array length is zero. +// - They only read input scalar/array arguments, write to output pointer/array +// arguments, and return scalar values; they are "pure" functions. +// - They don't read mutable global variables or write to any global variables. +// - They don't perform I/O, read the clock, print to console, etc. +// - They allocate a small and constant amount of stack memory. +// - They don't allocate or free any memory on the heap. +// - They don't recurse or mutually recurse. All the code +// could be inlined into the top-level public functions. +// - They run in at most quadratic time with respect to input arguments. +// Most functions run in linear time, and some in constant time. +// There are no unbounded loops or non-obvious termination conditions. +// - They are completely thread-safe if the caller does not give the +// same writable buffer to concurrent calls to these functions. + +testable void appendBitsToBuffer(unsigned int val, int numBits, uint8_t buffer[], int *bitLen); + +testable void addEccAndInterleave(uint8_t data[], int version, enum qrcodegen_Ecc ecl, uint8_t result[]); +testable int getNumDataCodewords(int version, enum qrcodegen_Ecc ecl); +testable int getNumRawDataModules(int ver); + +testable void reedSolomonComputeDivisor(int degree, uint8_t result[]); +testable void reedSolomonComputeRemainder(const uint8_t data[], int dataLen, + const uint8_t generator[], int degree, uint8_t result[]); +testable uint8_t reedSolomonMultiply(uint8_t x, uint8_t y); + +testable void initializeFunctionModules(int version, uint8_t qrcode[]); +static void drawLightFunctionModules(uint8_t qrcode[], int version); +static void drawFormatBits(enum qrcodegen_Ecc ecl, enum qrcodegen_Mask mask, uint8_t qrcode[]); +testable int getAlignmentPatternPositions(int version, uint8_t result[7]); +static void fillRectangle(int left, int top, int width, int height, uint8_t qrcode[]); + +static void drawCodewords(const uint8_t data[], int dataLen, uint8_t qrcode[]); +static void applyMask(const uint8_t functionModules[], uint8_t qrcode[], enum qrcodegen_Mask mask); +static long getPenaltyScore(const uint8_t qrcode[]); +static int finderPenaltyCountPatterns(const int runHistory[7], int qrsize); +static int finderPenaltyTerminateAndCount(bool currentRunColor, int currentRunLength, int runHistory[7], int qrsize); +static void finderPenaltyAddHistory(int currentRunLength, int runHistory[7], int qrsize); + +testable bool getModuleBounded(const uint8_t qrcode[], int x, int y); +testable void setModuleBounded(uint8_t qrcode[], int x, int y, bool isDark); +testable void setModuleUnbounded(uint8_t qrcode[], int x, int y, bool isDark); +static bool getBit(int x, int i); + +testable int calcSegmentBitLength(enum qrcodegen_Mode mode, size_t numChars); +testable int getTotalBits(const struct qrcodegen_Segment segs[], size_t len, int version); +static int numCharCountBits(enum qrcodegen_Mode mode, int version); + + + +/*---- Private tables of constants ----*/ + +// The set of all legal characters in alphanumeric mode, where each character +// value maps to the index in the string. For checking text and encoding segments. +static const char *ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + +// Sentinel value for use in only some functions. +#define LENGTH_OVERFLOW -1 + +// For generating error correction codes. +testable const int8_t ECC_CODEWORDS_PER_BLOCK[4][41] = { + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + {-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // Low + {-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28}, // Medium + {-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // Quartile + {-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // High +}; + +#define qrcodegen_REED_SOLOMON_DEGREE_MAX 30 // Based on the table above + +// For generating error correction codes. +testable const int8_t NUM_ERROR_CORRECTION_BLOCKS[4][41] = { + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + {-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25}, // Low + {-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49}, // Medium + {-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68}, // Quartile + {-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81}, // High +}; + +// For automatic mask pattern selection. +static const int PENALTY_N1 = 3; +static const int PENALTY_N2 = 3; +static const int PENALTY_N3 = 40; +static const int PENALTY_N4 = 10; + + + +/*---- High-level QR Code encoding functions ----*/ + +// Public function - see documentation comment in header file. +bool qrcodegen_encodeText(const char *text, uint8_t tempBuffer[], uint8_t qrcode[], + enum qrcodegen_Ecc ecl, int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl) { + + size_t textLen = strlen(text); + if (textLen == 0) + return qrcodegen_encodeSegmentsAdvanced(NULL, 0, ecl, minVersion, maxVersion, mask, boostEcl, tempBuffer, qrcode); + size_t bufLen = (size_t)qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion); + + struct qrcodegen_Segment seg; + if (qrcodegen_isNumeric(text)) { + if (qrcodegen_calcSegmentBufferSize(qrcodegen_Mode_NUMERIC, textLen) > bufLen) + goto fail; + seg = qrcodegen_makeNumeric(text, tempBuffer); + } else if (qrcodegen_isAlphanumeric(text)) { + if (qrcodegen_calcSegmentBufferSize(qrcodegen_Mode_ALPHANUMERIC, textLen) > bufLen) + goto fail; + seg = qrcodegen_makeAlphanumeric(text, tempBuffer); + } else { + if (textLen > bufLen) + goto fail; + for (size_t i = 0; i < textLen; i++) + tempBuffer[i] = (uint8_t)text[i]; + seg.mode = qrcodegen_Mode_BYTE; + seg.bitLength = calcSegmentBitLength(seg.mode, textLen); + if (seg.bitLength == LENGTH_OVERFLOW) + goto fail; + seg.numChars = (int)textLen; + seg.data = tempBuffer; + } + return qrcodegen_encodeSegmentsAdvanced(&seg, 1, ecl, minVersion, maxVersion, mask, boostEcl, tempBuffer, qrcode); + +fail: + qrcode[0] = 0; // Set size to invalid value for safety + return false; +} + + +// Public function - see documentation comment in header file. +bool qrcodegen_encodeBinary(uint8_t dataAndTemp[], size_t dataLen, uint8_t qrcode[], + enum qrcodegen_Ecc ecl, int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl) { + + struct qrcodegen_Segment seg; + seg.mode = qrcodegen_Mode_BYTE; + seg.bitLength = calcSegmentBitLength(seg.mode, dataLen); + if (seg.bitLength == LENGTH_OVERFLOW) { + qrcode[0] = 0; // Set size to invalid value for safety + return false; + } + seg.numChars = (int)dataLen; + seg.data = dataAndTemp; + return qrcodegen_encodeSegmentsAdvanced(&seg, 1, ecl, minVersion, maxVersion, mask, boostEcl, dataAndTemp, qrcode); +} + + +// Appends the given number of low-order bits of the given value to the given byte-based +// bit buffer, increasing the bit length. Requires 0 <= numBits <= 16 and val < 2^numBits. +testable void appendBitsToBuffer(unsigned int val, int numBits, uint8_t buffer[], int *bitLen) { + assert(0 <= numBits && numBits <= 16 && (unsigned long)val >> numBits == 0); + for (int i = numBits - 1; i >= 0; i--, (*bitLen)++) + buffer[*bitLen >> 3] |= ((val >> i) & 1) << (7 - (*bitLen & 7)); +} + + + +/*---- Low-level QR Code encoding functions ----*/ + +// Public function - see documentation comment in header file. +bool qrcodegen_encodeSegments(const struct qrcodegen_Segment segs[], size_t len, + enum qrcodegen_Ecc ecl, uint8_t tempBuffer[], uint8_t qrcode[]) { + return qrcodegen_encodeSegmentsAdvanced(segs, len, ecl, + qrcodegen_VERSION_MIN, qrcodegen_VERSION_MAX, qrcodegen_Mask_AUTO, true, tempBuffer, qrcode); +} + + +// Public function - see documentation comment in header file. +bool qrcodegen_encodeSegmentsAdvanced(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl, + int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl, uint8_t tempBuffer[], uint8_t qrcode[]) { + assert(segs != NULL || len == 0); + assert(qrcodegen_VERSION_MIN <= minVersion && minVersion <= maxVersion && maxVersion <= qrcodegen_VERSION_MAX); + assert(0 <= (int)ecl && (int)ecl <= 3 && -1 <= (int)mask && (int)mask <= 7); + + // Find the minimal version number to use + int version, dataUsedBits; + for (version = minVersion; ; version++) { + int dataCapacityBits = getNumDataCodewords(version, ecl) * 8; // Number of data bits available + dataUsedBits = getTotalBits(segs, len, version); + if (dataUsedBits != LENGTH_OVERFLOW && dataUsedBits <= dataCapacityBits) + break; // This version number is found to be suitable + if (version >= maxVersion) { // All versions in the range could not fit the given data + qrcode[0] = 0; // Set size to invalid value for safety + return false; + } + } + assert(dataUsedBits != LENGTH_OVERFLOW); + + // Increase the error correction level while the data still fits in the current version number + for (int i = (int)qrcodegen_Ecc_MEDIUM; i <= (int)qrcodegen_Ecc_HIGH; i++) { // From low to high + if (boostEcl && dataUsedBits <= getNumDataCodewords(version, (enum qrcodegen_Ecc)i) * 8) + ecl = (enum qrcodegen_Ecc)i; + } + + // Concatenate all segments to create the data bit string + memset(qrcode, 0, (size_t)qrcodegen_BUFFER_LEN_FOR_VERSION(version) * sizeof(qrcode[0])); + int bitLen = 0; + for (size_t i = 0; i < len; i++) { + const struct qrcodegen_Segment *seg = &segs[i]; + appendBitsToBuffer((unsigned int)seg->mode, 4, qrcode, &bitLen); + appendBitsToBuffer((unsigned int)seg->numChars, numCharCountBits(seg->mode, version), qrcode, &bitLen); + for (int j = 0; j < seg->bitLength; j++) { + int bit = (seg->data[j >> 3] >> (7 - (j & 7))) & 1; + appendBitsToBuffer((unsigned int)bit, 1, qrcode, &bitLen); + } + } + assert(bitLen == dataUsedBits); + + // Add terminator and pad up to a byte if applicable + int dataCapacityBits = getNumDataCodewords(version, ecl) * 8; + assert(bitLen <= dataCapacityBits); + int terminatorBits = dataCapacityBits - bitLen; + if (terminatorBits > 4) + terminatorBits = 4; + appendBitsToBuffer(0, terminatorBits, qrcode, &bitLen); + appendBitsToBuffer(0, (8 - bitLen % 8) % 8, qrcode, &bitLen); + assert(bitLen % 8 == 0); + + // Pad with alternating bytes until data capacity is reached + for (uint8_t padByte = 0xEC; bitLen < dataCapacityBits; padByte ^= 0xEC ^ 0x11) + appendBitsToBuffer(padByte, 8, qrcode, &bitLen); + + // Compute ECC, draw modules + addEccAndInterleave(qrcode, version, ecl, tempBuffer); + initializeFunctionModules(version, qrcode); + drawCodewords(tempBuffer, getNumRawDataModules(version) / 8, qrcode); + drawLightFunctionModules(qrcode, version); + initializeFunctionModules(version, tempBuffer); + + // Do masking + if (mask == qrcodegen_Mask_AUTO) { // Automatically choose best mask + long minPenalty = LONG_MAX; + for (int i = 0; i < 8; i++) { + enum qrcodegen_Mask msk = (enum qrcodegen_Mask)i; + applyMask(tempBuffer, qrcode, msk); + drawFormatBits(ecl, msk, qrcode); + long penalty = getPenaltyScore(qrcode); + if (penalty < minPenalty) { + mask = msk; + minPenalty = penalty; + } + applyMask(tempBuffer, qrcode, msk); // Undoes the mask due to XOR + } + } + assert(0 <= (int)mask && (int)mask <= 7); + applyMask(tempBuffer, qrcode, mask); // Apply the final choice of mask + drawFormatBits(ecl, mask, qrcode); // Overwrite old format bits + return true; +} + + + +/*---- Error correction code generation functions ----*/ + +// Appends error correction bytes to each block of the given data array, then interleaves +// bytes from the blocks and stores them in the result array. data[0 : dataLen] contains +// the input data. data[dataLen : rawCodewords] is used as a temporary work area and will +// be clobbered by this function. The final answer is stored in result[0 : rawCodewords]. +testable void addEccAndInterleave(uint8_t data[], int version, enum qrcodegen_Ecc ecl, uint8_t result[]) { + // Calculate parameter numbers + assert(0 <= (int)ecl && (int)ecl < 4 && qrcodegen_VERSION_MIN <= version && version <= qrcodegen_VERSION_MAX); + int numBlocks = NUM_ERROR_CORRECTION_BLOCKS[(int)ecl][version]; + int blockEccLen = ECC_CODEWORDS_PER_BLOCK [(int)ecl][version]; + int rawCodewords = getNumRawDataModules(version) / 8; + int dataLen = getNumDataCodewords(version, ecl); + int numShortBlocks = numBlocks - rawCodewords % numBlocks; + int shortBlockDataLen = rawCodewords / numBlocks - blockEccLen; + + // Split data into blocks, calculate ECC, and interleave + // (not concatenate) the bytes into a single sequence + uint8_t rsdiv[qrcodegen_REED_SOLOMON_DEGREE_MAX]; + reedSolomonComputeDivisor(blockEccLen, rsdiv); + const uint8_t *dat = data; + for (int i = 0; i < numBlocks; i++) { + int datLen = shortBlockDataLen + (i < numShortBlocks ? 0 : 1); + uint8_t *ecc = &data[dataLen]; // Temporary storage + reedSolomonComputeRemainder(dat, datLen, rsdiv, blockEccLen, ecc); + for (int j = 0, k = i; j < datLen; j++, k += numBlocks) { // Copy data + if (j == shortBlockDataLen) + k -= numShortBlocks; + result[k] = dat[j]; + } + for (int j = 0, k = dataLen + i; j < blockEccLen; j++, k += numBlocks) // Copy ECC + result[k] = ecc[j]; + dat += datLen; + } +} + + +// Returns the number of 8-bit codewords that can be used for storing data (not ECC), +// for the given version number and error correction level. The result is in the range [9, 2956]. +testable int getNumDataCodewords(int version, enum qrcodegen_Ecc ecl) { + int v = version, e = (int)ecl; + assert(0 <= e && e < 4); + return getNumRawDataModules(v) / 8 + - ECC_CODEWORDS_PER_BLOCK [e][v] + * NUM_ERROR_CORRECTION_BLOCKS[e][v]; +} + + +// Returns the number of data bits that can be stored in a QR Code of the given version number, after +// all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. +// The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. +testable int getNumRawDataModules(int ver) { + assert(qrcodegen_VERSION_MIN <= ver && ver <= qrcodegen_VERSION_MAX); + int result = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + int numAlign = ver / 7 + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) + result -= 36; + } + assert(208 <= result && result <= 29648); + return result; +} + + + +/*---- Reed-Solomon ECC generator functions ----*/ + +// Computes a Reed-Solomon ECC generator polynomial for the given degree, storing in result[0 : degree]. +// This could be implemented as a lookup table over all possible parameter values, instead of as an algorithm. +testable void reedSolomonComputeDivisor(int degree, uint8_t result[]) { + assert(1 <= degree && degree <= qrcodegen_REED_SOLOMON_DEGREE_MAX); + // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. + // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array {255, 8, 93}. + memset(result, 0, (size_t)degree * sizeof(result[0])); + result[degree - 1] = 1; // Start off with the monomial x^0 + + // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), + // drop the highest monomial term which is always 1x^degree. + // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). + uint8_t root = 1; + for (int i = 0; i < degree; i++) { + // Multiply the current product by (x - r^i) + for (int j = 0; j < degree; j++) { + result[j] = reedSolomonMultiply(result[j], root); + if (j + 1 < degree) + result[j] ^= result[j + 1]; + } + root = reedSolomonMultiply(root, 0x02); + } +} + + +// Computes the Reed-Solomon error correction codeword for the given data and divisor polynomials. +// The remainder when data[0 : dataLen] is divided by divisor[0 : degree] is stored in result[0 : degree]. +// All polynomials are in big endian, and the generator has an implicit leading 1 term. +testable void reedSolomonComputeRemainder(const uint8_t data[], int dataLen, + const uint8_t generator[], int degree, uint8_t result[]) { + assert(1 <= degree && degree <= qrcodegen_REED_SOLOMON_DEGREE_MAX); + memset(result, 0, (size_t)degree * sizeof(result[0])); + for (int i = 0; i < dataLen; i++) { // Polynomial division + uint8_t factor = data[i] ^ result[0]; + memmove(&result[0], &result[1], (size_t)(degree - 1) * sizeof(result[0])); + result[degree - 1] = 0; + for (int j = 0; j < degree; j++) + result[j] ^= reedSolomonMultiply(generator[j], factor); + } +} + +#undef qrcodegen_REED_SOLOMON_DEGREE_MAX + + +// Returns the product of the two given field elements modulo GF(2^8/0x11D). +// All inputs are valid. This could be implemented as a 256*256 lookup table. +testable uint8_t reedSolomonMultiply(uint8_t x, uint8_t y) { + // Russian peasant multiplication + uint8_t z = 0; + for (int i = 7; i >= 0; i--) { + z = (uint8_t)((z << 1) ^ ((z >> 7) * 0x11D)); + z ^= ((y >> i) & 1) * x; + } + return z; +} + + + +/*---- Drawing function modules ----*/ + +// Clears the given QR Code grid with light modules for the given +// version's size, then marks every function module as dark. +testable void initializeFunctionModules(int version, uint8_t qrcode[]) { + // Initialize QR Code + int qrsize = version * 4 + 17; + memset(qrcode, 0, (size_t)((qrsize * qrsize + 7) / 8 + 1) * sizeof(qrcode[0])); + qrcode[0] = (uint8_t)qrsize; + + // Fill horizontal and vertical timing patterns + fillRectangle(6, 0, 1, qrsize, qrcode); + fillRectangle(0, 6, qrsize, 1, qrcode); + + // Fill 3 finder patterns (all corners except bottom right) and format bits + fillRectangle(0, 0, 9, 9, qrcode); + fillRectangle(qrsize - 8, 0, 8, 9, qrcode); + fillRectangle(0, qrsize - 8, 9, 8, qrcode); + + // Fill numerous alignment patterns + uint8_t alignPatPos[7]; + int numAlign = getAlignmentPatternPositions(version, alignPatPos); + for (int i = 0; i < numAlign; i++) { + for (int j = 0; j < numAlign; j++) { + // Don't draw on the three finder corners + if (!((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0))) + fillRectangle(alignPatPos[i] - 2, alignPatPos[j] - 2, 5, 5, qrcode); + } + } + + // Fill version blocks + if (version >= 7) { + fillRectangle(qrsize - 11, 0, 3, 6, qrcode); + fillRectangle(0, qrsize - 11, 6, 3, qrcode); + } +} + + +// Draws light function modules and possibly some dark modules onto the given QR Code, without changing +// non-function modules. This does not draw the format bits. This requires all function modules to be previously +// marked dark (namely by initializeFunctionModules()), because this may skip redrawing dark function modules. +static void drawLightFunctionModules(uint8_t qrcode[], int version) { + // Draw horizontal and vertical timing patterns + int qrsize = qrcodegen_getSize(qrcode); + for (int i = 7; i < qrsize - 7; i += 2) { + setModuleBounded(qrcode, 6, i, false); + setModuleBounded(qrcode, i, 6, false); + } + + // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) + for (int dy = -4; dy <= 4; dy++) { + for (int dx = -4; dx <= 4; dx++) { + int dist = abs(dx); + if (abs(dy) > dist) + dist = abs(dy); + if (dist == 2 || dist == 4) { + setModuleUnbounded(qrcode, 3 + dx, 3 + dy, false); + setModuleUnbounded(qrcode, qrsize - 4 + dx, 3 + dy, false); + setModuleUnbounded(qrcode, 3 + dx, qrsize - 4 + dy, false); + } + } + } + + // Draw numerous alignment patterns + uint8_t alignPatPos[7]; + int numAlign = getAlignmentPatternPositions(version, alignPatPos); + for (int i = 0; i < numAlign; i++) { + for (int j = 0; j < numAlign; j++) { + if ((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0)) + continue; // Don't draw on the three finder corners + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) + setModuleBounded(qrcode, alignPatPos[i] + dx, alignPatPos[j] + dy, dx == 0 && dy == 0); + } + } + } + + // Draw version blocks + if (version >= 7) { + // Calculate error correction code and pack bits + int rem = version; // version is uint6, in the range [7, 40] + for (int i = 0; i < 12; i++) + rem = (rem << 1) ^ ((rem >> 11) * 0x1F25); + long bits = (long)version << 12 | rem; // uint18 + assert(bits >> 18 == 0); + + // Draw two copies + for (int i = 0; i < 6; i++) { + for (int j = 0; j < 3; j++) { + int k = qrsize - 11 + j; + setModuleBounded(qrcode, k, i, (bits & 1) != 0); + setModuleBounded(qrcode, i, k, (bits & 1) != 0); + bits >>= 1; + } + } + } +} + + +// Draws two copies of the format bits (with its own error correction code) based +// on the given mask and error correction level. This always draws all modules of +// the format bits, unlike drawLightFunctionModules() which might skip dark modules. +static void drawFormatBits(enum qrcodegen_Ecc ecl, enum qrcodegen_Mask mask, uint8_t qrcode[]) { + // Calculate error correction code and pack bits + assert(0 <= (int)mask && (int)mask <= 7); + static const int table[] = {1, 0, 3, 2}; + int data = table[(int)ecl] << 3 | (int)mask; // errCorrLvl is uint2, mask is uint3 + int rem = data; + for (int i = 0; i < 10; i++) + rem = (rem << 1) ^ ((rem >> 9) * 0x537); + int bits = (data << 10 | rem) ^ 0x5412; // uint15 + assert(bits >> 15 == 0); + + // Draw first copy + for (int i = 0; i <= 5; i++) + setModuleBounded(qrcode, 8, i, getBit(bits, i)); + setModuleBounded(qrcode, 8, 7, getBit(bits, 6)); + setModuleBounded(qrcode, 8, 8, getBit(bits, 7)); + setModuleBounded(qrcode, 7, 8, getBit(bits, 8)); + for (int i = 9; i < 15; i++) + setModuleBounded(qrcode, 14 - i, 8, getBit(bits, i)); + + // Draw second copy + int qrsize = qrcodegen_getSize(qrcode); + for (int i = 0; i < 8; i++) + setModuleBounded(qrcode, qrsize - 1 - i, 8, getBit(bits, i)); + for (int i = 8; i < 15; i++) + setModuleBounded(qrcode, 8, qrsize - 15 + i, getBit(bits, i)); + setModuleBounded(qrcode, 8, qrsize - 8, true); // Always dark +} + + +// Calculates and stores an ascending list of positions of alignment patterns +// for this version number, returning the length of the list (in the range [0,7]). +// Each position is in the range [0,177), and are used on both the x and y axes. +// This could be implemented as lookup table of 40 variable-length lists of unsigned bytes. +testable int getAlignmentPatternPositions(int version, uint8_t result[7]) { + if (version == 1) + return 0; + int numAlign = version / 7 + 2; + int step = (version * 8 + numAlign * 3 + 5) / (numAlign * 4 - 4) * 2; + for (int i = numAlign - 1, pos = version * 4 + 10; i >= 1; i--, pos -= step) + result[i] = (uint8_t)pos; + result[0] = 6; + return numAlign; +} + + +// Sets every module in the range [left : left + width] * [top : top + height] to dark. +static void fillRectangle(int left, int top, int width, int height, uint8_t qrcode[]) { + for (int dy = 0; dy < height; dy++) { + for (int dx = 0; dx < width; dx++) + setModuleBounded(qrcode, left + dx, top + dy, true); + } +} + + + +/*---- Drawing data modules and masking ----*/ + +// Draws the raw codewords (including data and ECC) onto the given QR Code. This requires the initial state of +// the QR Code to be dark at function modules and light at codeword modules (including unused remainder bits). +static void drawCodewords(const uint8_t data[], int dataLen, uint8_t qrcode[]) { + int qrsize = qrcodegen_getSize(qrcode); + int i = 0; // Bit index into the data + // Do the funny zigzag scan + for (int right = qrsize - 1; right >= 1; right -= 2) { // Index of right column in each column pair + if (right == 6) + right = 5; + for (int vert = 0; vert < qrsize; vert++) { // Vertical counter + for (int j = 0; j < 2; j++) { + int x = right - j; // Actual x coordinate + bool upward = ((right + 1) & 2) == 0; + int y = upward ? qrsize - 1 - vert : vert; // Actual y coordinate + if (!getModuleBounded(qrcode, x, y) && i < dataLen * 8) { + bool dark = getBit(data[i >> 3], 7 - (i & 7)); + setModuleBounded(qrcode, x, y, dark); + i++; + } + // If this QR Code has any remainder bits (0 to 7), they were assigned as + // 0/false/light by the constructor and are left unchanged by this method + } + } + } + assert(i == dataLen * 8); +} + + +// XORs the codeword modules in this QR Code with the given mask pattern +// and given pattern of function modules. The codeword bits must be drawn +// before masking. Due to the arithmetic of XOR, calling applyMask() with +// the same mask value a second time will undo the mask. A final well-formed +// QR Code needs exactly one (not zero, two, etc.) mask applied. +static void applyMask(const uint8_t functionModules[], uint8_t qrcode[], enum qrcodegen_Mask mask) { + assert(0 <= (int)mask && (int)mask <= 7); // Disallows qrcodegen_Mask_AUTO + int qrsize = qrcodegen_getSize(qrcode); + for (int y = 0; y < qrsize; y++) { + for (int x = 0; x < qrsize; x++) { + if (getModuleBounded(functionModules, x, y)) + continue; + bool invert; + switch ((int)mask) { + case 0: invert = (x + y) % 2 == 0; break; + case 1: invert = y % 2 == 0; break; + case 2: invert = x % 3 == 0; break; + case 3: invert = (x + y) % 3 == 0; break; + case 4: invert = (x / 3 + y / 2) % 2 == 0; break; + case 5: invert = x * y % 2 + x * y % 3 == 0; break; + case 6: invert = (x * y % 2 + x * y % 3) % 2 == 0; break; + case 7: invert = ((x + y) % 2 + x * y % 3) % 2 == 0; break; + default: assert(false); return; + } + bool val = getModuleBounded(qrcode, x, y); + setModuleBounded(qrcode, x, y, val ^ invert); + } + } +} + + +// Calculates and returns the penalty score based on state of the given QR Code's current modules. +// This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. +static long getPenaltyScore(const uint8_t qrcode[]) { + int qrsize = qrcodegen_getSize(qrcode); + long result = 0; + + // Adjacent modules in row having same color, and finder-like patterns + for (int y = 0; y < qrsize; y++) { + bool runColor = false; + int runX = 0; + int runHistory[7] = {0}; + for (int x = 0; x < qrsize; x++) { + if (getModuleBounded(qrcode, x, y) == runColor) { + runX++; + if (runX == 5) + result += PENALTY_N1; + else if (runX > 5) + result++; + } else { + finderPenaltyAddHistory(runX, runHistory, qrsize); + if (!runColor) + result += finderPenaltyCountPatterns(runHistory, qrsize) * PENALTY_N3; + runColor = getModuleBounded(qrcode, x, y); + runX = 1; + } + } + result += finderPenaltyTerminateAndCount(runColor, runX, runHistory, qrsize) * PENALTY_N3; + } + // Adjacent modules in column having same color, and finder-like patterns + for (int x = 0; x < qrsize; x++) { + bool runColor = false; + int runY = 0; + int runHistory[7] = {0}; + for (int y = 0; y < qrsize; y++) { + if (getModuleBounded(qrcode, x, y) == runColor) { + runY++; + if (runY == 5) + result += PENALTY_N1; + else if (runY > 5) + result++; + } else { + finderPenaltyAddHistory(runY, runHistory, qrsize); + if (!runColor) + result += finderPenaltyCountPatterns(runHistory, qrsize) * PENALTY_N3; + runColor = getModuleBounded(qrcode, x, y); + runY = 1; + } + } + result += finderPenaltyTerminateAndCount(runColor, runY, runHistory, qrsize) * PENALTY_N3; + } + + // 2*2 blocks of modules having same color + for (int y = 0; y < qrsize - 1; y++) { + for (int x = 0; x < qrsize - 1; x++) { + bool color = getModuleBounded(qrcode, x, y); + if ( color == getModuleBounded(qrcode, x + 1, y) && + color == getModuleBounded(qrcode, x, y + 1) && + color == getModuleBounded(qrcode, x + 1, y + 1)) + result += PENALTY_N2; + } + } + + // Balance of dark and light modules + int dark = 0; + for (int y = 0; y < qrsize; y++) { + for (int x = 0; x < qrsize; x++) { + if (getModuleBounded(qrcode, x, y)) + dark++; + } + } + int total = qrsize * qrsize; // Note that size is odd, so dark/total != 1/2 + // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% + int k = (int)((labs(dark * 20L - total * 10L) + total - 1) / total) - 1; + assert(0 <= k && k <= 9); + result += k * PENALTY_N4; + assert(0 <= result && result <= 2568888L); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 + return result; +} + + +// Can only be called immediately after a light run is added, and +// returns either 0, 1, or 2. A helper function for getPenaltyScore(). +static int finderPenaltyCountPatterns(const int runHistory[7], int qrsize) { + int n = runHistory[1]; + assert(n <= qrsize * 3); (void)qrsize; + bool core = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n; + // The maximum QR Code size is 177, hence the dark run length n <= 177. + // Arithmetic is promoted to int, so n*4 will not overflow. + return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0); +} + + +// Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). +static int finderPenaltyTerminateAndCount(bool currentRunColor, int currentRunLength, int runHistory[7], int qrsize) { + if (currentRunColor) { // Terminate dark run + finderPenaltyAddHistory(currentRunLength, runHistory, qrsize); + currentRunLength = 0; + } + currentRunLength += qrsize; // Add light border to final run + finderPenaltyAddHistory(currentRunLength, runHistory, qrsize); + return finderPenaltyCountPatterns(runHistory, qrsize); +} + + +// Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). +static void finderPenaltyAddHistory(int currentRunLength, int runHistory[7], int qrsize) { + if (runHistory[0] == 0) + currentRunLength += qrsize; // Add light border to initial run + memmove(&runHistory[1], &runHistory[0], 6 * sizeof(runHistory[0])); + runHistory[0] = currentRunLength; +} + + + +/*---- Basic QR Code information ----*/ + +// Public function - see documentation comment in header file. +int qrcodegen_getSize(const uint8_t qrcode[]) { + assert(qrcode != NULL); + int result = qrcode[0]; + assert((qrcodegen_VERSION_MIN * 4 + 17) <= result + && result <= (qrcodegen_VERSION_MAX * 4 + 17)); + return result; +} + + +// Public function - see documentation comment in header file. +bool qrcodegen_getModule(const uint8_t qrcode[], int x, int y) { + assert(qrcode != NULL); + int qrsize = qrcode[0]; + return (0 <= x && x < qrsize && 0 <= y && y < qrsize) && getModuleBounded(qrcode, x, y); +} + + +// Returns the color of the module at the given coordinates, which must be in bounds. +testable bool getModuleBounded(const uint8_t qrcode[], int x, int y) { + int qrsize = qrcode[0]; + assert(21 <= qrsize && qrsize <= 177 && 0 <= x && x < qrsize && 0 <= y && y < qrsize); + int index = y * qrsize + x; + return getBit(qrcode[(index >> 3) + 1], index & 7); +} + + +// Sets the color of the module at the given coordinates, which must be in bounds. +testable void setModuleBounded(uint8_t qrcode[], int x, int y, bool isDark) { + int qrsize = qrcode[0]; + assert(21 <= qrsize && qrsize <= 177 && 0 <= x && x < qrsize && 0 <= y && y < qrsize); + int index = y * qrsize + x; + int bitIndex = index & 7; + int byteIndex = (index >> 3) + 1; + if (isDark) + qrcode[byteIndex] |= 1 << bitIndex; + else + qrcode[byteIndex] &= (1 << bitIndex) ^ 0xFF; +} + + +// Sets the color of the module at the given coordinates, doing nothing if out of bounds. +testable void setModuleUnbounded(uint8_t qrcode[], int x, int y, bool isDark) { + int qrsize = qrcode[0]; + if (0 <= x && x < qrsize && 0 <= y && y < qrsize) + setModuleBounded(qrcode, x, y, isDark); +} + + +// Returns true iff the i'th bit of x is set to 1. Requires x >= 0 and 0 <= i <= 14. +static bool getBit(int x, int i) { + return ((x >> i) & 1) != 0; +} + + + +/*---- Segment handling ----*/ + +// Public function - see documentation comment in header file. +bool qrcodegen_isNumeric(const char *text) { + assert(text != NULL); + for (; *text != '\0'; text++) { + if (*text < '0' || *text > '9') + return false; + } + return true; +} + + +// Public function - see documentation comment in header file. +bool qrcodegen_isAlphanumeric(const char *text) { + assert(text != NULL); + for (; *text != '\0'; text++) { + if (strchr(ALPHANUMERIC_CHARSET, *text) == NULL) + return false; + } + return true; +} + + +// Public function - see documentation comment in header file. +size_t qrcodegen_calcSegmentBufferSize(enum qrcodegen_Mode mode, size_t numChars) { + int temp = calcSegmentBitLength(mode, numChars); + if (temp == LENGTH_OVERFLOW) + return SIZE_MAX; + assert(0 <= temp && temp <= INT16_MAX); + return ((size_t)temp + 7) / 8; +} + + +// Returns the number of data bits needed to represent a segment +// containing the given number of characters using the given mode. Notes: +// - Returns LENGTH_OVERFLOW on failure, i.e. numChars > INT16_MAX +// or the number of needed bits exceeds INT16_MAX (i.e. 32767). +// - Otherwise, all valid results are in the range [0, INT16_MAX]. +// - For byte mode, numChars measures the number of bytes, not Unicode code points. +// - For ECI mode, numChars must be 0, and the worst-case number of bits is returned. +// An actual ECI segment can have shorter data. For non-ECI modes, the result is exact. +testable int calcSegmentBitLength(enum qrcodegen_Mode mode, size_t numChars) { + // All calculations are designed to avoid overflow on all platforms + if (numChars > (unsigned int)INT16_MAX) + return LENGTH_OVERFLOW; + long result = (long)numChars; + if (mode == qrcodegen_Mode_NUMERIC) + result = (result * 10 + 2) / 3; // ceil(10/3 * n) + else if (mode == qrcodegen_Mode_ALPHANUMERIC) + result = (result * 11 + 1) / 2; // ceil(11/2 * n) + else if (mode == qrcodegen_Mode_BYTE) + result *= 8; + else if (mode == qrcodegen_Mode_KANJI) + result *= 13; + else if (mode == qrcodegen_Mode_ECI && numChars == 0) + result = 3 * 8; + else { // Invalid argument + assert(false); + return LENGTH_OVERFLOW; + } + assert(result >= 0); + if (result > INT16_MAX) + return LENGTH_OVERFLOW; + return (int)result; +} + + +// Public function - see documentation comment in header file. +struct qrcodegen_Segment qrcodegen_makeBytes(const uint8_t data[], size_t len, uint8_t buf[]) { + assert(data != NULL || len == 0); + struct qrcodegen_Segment result; + result.mode = qrcodegen_Mode_BYTE; + result.bitLength = calcSegmentBitLength(result.mode, len); + assert(result.bitLength != LENGTH_OVERFLOW); + result.numChars = (int)len; + if (len > 0) + memcpy(buf, data, len * sizeof(buf[0])); + result.data = buf; + return result; +} + + +// Public function - see documentation comment in header file. +struct qrcodegen_Segment qrcodegen_makeNumeric(const char *digits, uint8_t buf[]) { + assert(digits != NULL); + struct qrcodegen_Segment result; + size_t len = strlen(digits); + result.mode = qrcodegen_Mode_NUMERIC; + int bitLen = calcSegmentBitLength(result.mode, len); + assert(bitLen != LENGTH_OVERFLOW); + result.numChars = (int)len; + if (bitLen > 0) + memset(buf, 0, ((size_t)bitLen + 7) / 8 * sizeof(buf[0])); + result.bitLength = 0; + + unsigned int accumData = 0; + int accumCount = 0; + for (; *digits != '\0'; digits++) { + char c = *digits; + assert('0' <= c && c <= '9'); + accumData = accumData * 10 + (unsigned int)(c - '0'); + accumCount++; + if (accumCount == 3) { + appendBitsToBuffer(accumData, 10, buf, &result.bitLength); + accumData = 0; + accumCount = 0; + } + } + if (accumCount > 0) // 1 or 2 digits remaining + appendBitsToBuffer(accumData, accumCount * 3 + 1, buf, &result.bitLength); + assert(result.bitLength == bitLen); + result.data = buf; + return result; +} + + +// Public function - see documentation comment in header file. +struct qrcodegen_Segment qrcodegen_makeAlphanumeric(const char *text, uint8_t buf[]) { + assert(text != NULL); + struct qrcodegen_Segment result; + size_t len = strlen(text); + result.mode = qrcodegen_Mode_ALPHANUMERIC; + int bitLen = calcSegmentBitLength(result.mode, len); + assert(bitLen != LENGTH_OVERFLOW); + result.numChars = (int)len; + if (bitLen > 0) + memset(buf, 0, ((size_t)bitLen + 7) / 8 * sizeof(buf[0])); + result.bitLength = 0; + + unsigned int accumData = 0; + int accumCount = 0; + for (; *text != '\0'; text++) { + const char *temp = strchr(ALPHANUMERIC_CHARSET, *text); + assert(temp != NULL); + accumData = accumData * 45 + (unsigned int)(temp - ALPHANUMERIC_CHARSET); + accumCount++; + if (accumCount == 2) { + appendBitsToBuffer(accumData, 11, buf, &result.bitLength); + accumData = 0; + accumCount = 0; + } + } + if (accumCount > 0) // 1 character remaining + appendBitsToBuffer(accumData, 6, buf, &result.bitLength); + assert(result.bitLength == bitLen); + result.data = buf; + return result; +} + + +// Public function - see documentation comment in header file. +struct qrcodegen_Segment qrcodegen_makeEci(long assignVal, uint8_t buf[]) { + struct qrcodegen_Segment result; + result.mode = qrcodegen_Mode_ECI; + result.numChars = 0; + result.bitLength = 0; + if (assignVal < 0) + assert(false); + else if (assignVal < (1 << 7)) { + memset(buf, 0, 1 * sizeof(buf[0])); + appendBitsToBuffer((unsigned int)assignVal, 8, buf, &result.bitLength); + } else if (assignVal < (1 << 14)) { + memset(buf, 0, 2 * sizeof(buf[0])); + appendBitsToBuffer(2, 2, buf, &result.bitLength); + appendBitsToBuffer((unsigned int)assignVal, 14, buf, &result.bitLength); + } else if (assignVal < 1000000L) { + memset(buf, 0, 3 * sizeof(buf[0])); + appendBitsToBuffer(6, 3, buf, &result.bitLength); + appendBitsToBuffer((unsigned int)(assignVal >> 10), 11, buf, &result.bitLength); + appendBitsToBuffer((unsigned int)(assignVal & 0x3FF), 10, buf, &result.bitLength); + } else + assert(false); + result.data = buf; + return result; +} + + +// Calculates the number of bits needed to encode the given segments at the given version. +// Returns a non-negative number if successful. Otherwise returns LENGTH_OVERFLOW if a segment +// has too many characters to fit its length field, or the total bits exceeds INT16_MAX. +testable int getTotalBits(const struct qrcodegen_Segment segs[], size_t len, int version) { + assert(segs != NULL || len == 0); + long result = 0; + for (size_t i = 0; i < len; i++) { + int numChars = segs[i].numChars; + int bitLength = segs[i].bitLength; + assert(0 <= numChars && numChars <= INT16_MAX); + assert(0 <= bitLength && bitLength <= INT16_MAX); + int ccbits = numCharCountBits(segs[i].mode, version); + assert(0 <= ccbits && ccbits <= 16); + if (numChars >= (1L << ccbits)) + return LENGTH_OVERFLOW; // The segment's length doesn't fit the field's bit width + result += 4L + ccbits + bitLength; + if (result > INT16_MAX) + return LENGTH_OVERFLOW; // The sum might overflow an int type + } + assert(0 <= result && result <= INT16_MAX); + return (int)result; +} + + +// Returns the bit width of the character count field for a segment in the given mode +// in a QR Code at the given version number. The result is in the range [0, 16]. +static int numCharCountBits(enum qrcodegen_Mode mode, int version) { + assert(qrcodegen_VERSION_MIN <= version && version <= qrcodegen_VERSION_MAX); + int i = (version + 7) / 17; + switch (mode) { + case qrcodegen_Mode_NUMERIC : { static const int temp[] = {10, 12, 14}; return temp[i]; } + case qrcodegen_Mode_ALPHANUMERIC: { static const int temp[] = { 9, 11, 13}; return temp[i]; } + case qrcodegen_Mode_BYTE : { static const int temp[] = { 8, 16, 16}; return temp[i]; } + case qrcodegen_Mode_KANJI : { static const int temp[] = { 8, 10, 12}; return temp[i]; } + case qrcodegen_Mode_ECI : return 0; + default: assert(false); return -1; // Dummy value + } +} + + +#undef LENGTH_OVERFLOW diff --git a/components/domain/third_party/qrcodegen.h b/components/domain/third_party/qrcodegen.h new file mode 100644 index 0000000..6bbc157 --- /dev/null +++ b/components/domain/third_party/qrcodegen.h @@ -0,0 +1,385 @@ +/* + * QR Code generator library (C) + * + * Copyright (c) Project Nayuki. (MIT License) + * https://www.nayuki.io/page/qr-code-generator-library + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * - The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * - The Software is provided "as is", without warranty of any kind, express or + * implied, including but not limited to the warranties of merchantability, + * fitness for a particular purpose and noninfringement. In no event shall the + * authors or copyright holders be liable for any claim, damages or other + * liability, whether in an action of contract, tort or otherwise, arising from, + * out of or in connection with the Software or the use or other dealings in the + * Software. + */ + +#pragma once + +#include +#include +#include + + +#ifdef __cplusplus +extern "C" { +#endif + + +/* + * This library creates QR Code symbols, which is a type of two-dimension barcode. + * Invented by Denso Wave and described in the ISO/IEC 18004 standard. + * A QR Code structure is an immutable square grid of dark and light cells. + * The library provides functions to create a QR Code from text or binary data. + * The library covers the QR Code Model 2 specification, supporting all versions (sizes) + * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. + * + * Ways to create a QR Code object: + * - High level: Take the payload data and call qrcodegen_encodeText() or qrcodegen_encodeBinary(). + * - Low level: Custom-make the list of segments and call + * qrcodegen_encodeSegments() or qrcodegen_encodeSegmentsAdvanced(). + * (Note that all ways require supplying the desired error correction level and various byte buffers.) + */ + + +/*---- Enum and struct types----*/ + +/* + * The error correction level in a QR Code symbol. + */ +enum qrcodegen_Ecc { + // Must be declared in ascending order of error protection + // so that an internal qrcodegen function works properly + qrcodegen_Ecc_LOW = 0 , // The QR Code can tolerate about 7% erroneous codewords + qrcodegen_Ecc_MEDIUM , // The QR Code can tolerate about 15% erroneous codewords + qrcodegen_Ecc_QUARTILE, // The QR Code can tolerate about 25% erroneous codewords + qrcodegen_Ecc_HIGH , // The QR Code can tolerate about 30% erroneous codewords +}; + + +/* + * The mask pattern used in a QR Code symbol. + */ +enum qrcodegen_Mask { + // A special value to tell the QR Code encoder to + // automatically select an appropriate mask pattern + qrcodegen_Mask_AUTO = -1, + // The eight actual mask patterns + qrcodegen_Mask_0 = 0, + qrcodegen_Mask_1, + qrcodegen_Mask_2, + qrcodegen_Mask_3, + qrcodegen_Mask_4, + qrcodegen_Mask_5, + qrcodegen_Mask_6, + qrcodegen_Mask_7, +}; + + +/* + * Describes how a segment's data bits are interpreted. + */ +enum qrcodegen_Mode { + qrcodegen_Mode_NUMERIC = 0x1, + qrcodegen_Mode_ALPHANUMERIC = 0x2, + qrcodegen_Mode_BYTE = 0x4, + qrcodegen_Mode_KANJI = 0x8, + qrcodegen_Mode_ECI = 0x7, +}; + + +/* + * A segment of character/binary/control data in a QR Code symbol. + * The mid-level way to create a segment is to take the payload data + * and call a factory function such as qrcodegen_makeNumeric(). + * The low-level way to create a segment is to custom-make the bit buffer + * and initialize a qrcodegen_Segment struct with appropriate values. + * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. + * Any segment longer than this is meaningless for the purpose of generating QR Codes. + * Moreover, the maximum allowed bit length is 32767 because + * the largest QR Code (version 40) has 31329 modules. + */ +struct qrcodegen_Segment { + // The mode indicator of this segment. + enum qrcodegen_Mode mode; + + // The length of this segment's unencoded data. Measured in characters for + // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. + // Always zero or positive. Not the same as the data's bit length. + int numChars; + + // The data bits of this segment, packed in bitwise big endian. + // Can be null if the bit length is zero. + uint8_t *data; + + // The number of valid data bits used in the buffer. Requires + // 0 <= bitLength <= 32767, and bitLength <= (capacity of data array) * 8. + // The character count (numChars) must agree with the mode and the bit buffer length. + int bitLength; +}; + + + +/*---- Macro constants and functions ----*/ + +#define qrcodegen_VERSION_MIN 1 // The minimum version number supported in the QR Code Model 2 standard +#define qrcodegen_VERSION_MAX 40 // The maximum version number supported in the QR Code Model 2 standard + +// Calculates the number of bytes needed to store any QR Code up to and including the given version number, +// as a compile-time constant. For example, 'uint8_t buffer[qrcodegen_BUFFER_LEN_FOR_VERSION(25)];' +// can store any single QR Code from version 1 to 25 (inclusive). The result fits in an int (or int16). +// Requires qrcodegen_VERSION_MIN <= n <= qrcodegen_VERSION_MAX. +#define qrcodegen_BUFFER_LEN_FOR_VERSION(n) ((((n) * 4 + 17) * ((n) * 4 + 17) + 7) / 8 + 1) + +// The worst-case number of bytes needed to store one QR Code, up to and including +// version 40. This value equals 3918, which is just under 4 kilobytes. +// Use this more convenient value to avoid calculating tighter memory bounds for buffers. +#define qrcodegen_BUFFER_LEN_MAX qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX) + + + +/*---- Functions (high level) to generate QR Codes ----*/ + +/* + * Encodes the given text string to a QR Code, returning true if successful. + * If the data is too long to fit in any version in the given range + * at the given ECC level, then false is returned. + * + * The input text must be encoded in UTF-8 and contain no NULs. + * Requires 1 <= minVersion <= maxVersion <= 40. + * + * The smallest possible QR Code version within the given range is automatically + * chosen for the output. Iff boostEcl is true, then the ECC level of the result + * may be higher than the ecl argument if it can be done without increasing the + * version. The mask is either between qrcodegen_Mask_0 to 7 to force that mask, or + * qrcodegen_Mask_AUTO to automatically choose an appropriate mask (which may be slow). + * + * About the arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion): + * - Before calling the function: + * - The array ranges tempBuffer[0 : len] and qrcode[0 : len] must allow + * reading and writing; hence each array must have a length of at least len. + * - The two ranges must not overlap (aliasing). + * - The initial state of both ranges can be uninitialized + * because the function always writes before reading. + * - After the function returns: + * - Both ranges have no guarantee on which elements are initialized and what values are stored. + * - tempBuffer contains no useful data and should be treated as entirely uninitialized. + * - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule(). + * + * If successful, the resulting QR Code may use numeric, + * alphanumeric, or byte mode to encode the text. + * + * In the most optimistic case, a QR Code at version 40 with low ECC + * can hold any UTF-8 string up to 2953 bytes, or any alphanumeric string + * up to 4296 characters, or any digit string up to 7089 characters. + * These numbers represent the hard upper limit of the QR Code standard. + * + * Please consult the QR Code specification for information on + * data capacities per version, ECC level, and text encoding mode. + */ +bool qrcodegen_encodeText(const char *text, uint8_t tempBuffer[], uint8_t qrcode[], + enum qrcodegen_Ecc ecl, int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl); + + +/* + * Encodes the given binary data to a QR Code, returning true if successful. + * If the data is too long to fit in any version in the given range + * at the given ECC level, then false is returned. + * + * Requires 1 <= minVersion <= maxVersion <= 40. + * + * The smallest possible QR Code version within the given range is automatically + * chosen for the output. Iff boostEcl is true, then the ECC level of the result + * may be higher than the ecl argument if it can be done without increasing the + * version. The mask is either between qrcodegen_Mask_0 to 7 to force that mask, or + * qrcodegen_Mask_AUTO to automatically choose an appropriate mask (which may be slow). + * + * About the arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion): + * - Before calling the function: + * - The array ranges dataAndTemp[0 : len] and qrcode[0 : len] must allow + * reading and writing; hence each array must have a length of at least len. + * - The two ranges must not overlap (aliasing). + * - The input array range dataAndTemp[0 : dataLen] should normally be + * valid UTF-8 text, but is not required by the QR Code standard. + * - The initial state of dataAndTemp[dataLen : len] and qrcode[0 : len] + * can be uninitialized because the function always writes before reading. + * - After the function returns: + * - Both ranges have no guarantee on which elements are initialized and what values are stored. + * - dataAndTemp contains no useful data and should be treated as entirely uninitialized. + * - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule(). + * + * If successful, the resulting QR Code will use byte mode to encode the data. + * + * In the most optimistic case, a QR Code at version 40 with low ECC can hold any byte + * sequence up to length 2953. This is the hard upper limit of the QR Code standard. + * + * Please consult the QR Code specification for information on + * data capacities per version, ECC level, and text encoding mode. + */ +bool qrcodegen_encodeBinary(uint8_t dataAndTemp[], size_t dataLen, uint8_t qrcode[], + enum qrcodegen_Ecc ecl, int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl); + + +/*---- Functions (low level) to generate QR Codes ----*/ + +/* + * Encodes the given segments to a QR Code, returning true if successful. + * If the data is too long to fit in any version at the given ECC level, + * then false is returned. + * + * The smallest possible QR Code version is automatically chosen for + * the output. The ECC level of the result may be higher than the + * ecl argument if it can be done without increasing the version. + * + * About the byte arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX): + * - Before calling the function: + * - The array ranges tempBuffer[0 : len] and qrcode[0 : len] must allow + * reading and writing; hence each array must have a length of at least len. + * - The two ranges must not overlap (aliasing). + * - The initial state of both ranges can be uninitialized + * because the function always writes before reading. + * - The input array segs can contain segments whose data buffers overlap with tempBuffer. + * - After the function returns: + * - Both ranges have no guarantee on which elements are initialized and what values are stored. + * - tempBuffer contains no useful data and should be treated as entirely uninitialized. + * - Any segment whose data buffer overlaps with tempBuffer[0 : len] + * must be treated as having invalid values in that array. + * - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule(). + * + * Please consult the QR Code specification for information on + * data capacities per version, ECC level, and text encoding mode. + * + * This function allows the user to create a custom sequence of segments that switches + * between modes (such as alphanumeric and byte) to encode text in less space. + * This is a low-level API; the high-level API is qrcodegen_encodeText() and qrcodegen_encodeBinary(). + */ +bool qrcodegen_encodeSegments(const struct qrcodegen_Segment segs[], size_t len, + enum qrcodegen_Ecc ecl, uint8_t tempBuffer[], uint8_t qrcode[]); + + +/* + * Encodes the given segments to a QR Code, returning true if successful. + * If the data is too long to fit in any version in the given range + * at the given ECC level, then false is returned. + * + * Requires 1 <= minVersion <= maxVersion <= 40. + * + * The smallest possible QR Code version within the given range is automatically + * chosen for the output. Iff boostEcl is true, then the ECC level of the result + * may be higher than the ecl argument if it can be done without increasing the + * version. The mask is either between qrcodegen_Mask_0 to 7 to force that mask, or + * qrcodegen_Mask_AUTO to automatically choose an appropriate mask (which may be slow). + * + * About the byte arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX): + * - Before calling the function: + * - The array ranges tempBuffer[0 : len] and qrcode[0 : len] must allow + * reading and writing; hence each array must have a length of at least len. + * - The two ranges must not overlap (aliasing). + * - The initial state of both ranges can be uninitialized + * because the function always writes before reading. + * - The input array segs can contain segments whose data buffers overlap with tempBuffer. + * - After the function returns: + * - Both ranges have no guarantee on which elements are initialized and what values are stored. + * - tempBuffer contains no useful data and should be treated as entirely uninitialized. + * - Any segment whose data buffer overlaps with tempBuffer[0 : len] + * must be treated as having invalid values in that array. + * - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule(). + * + * Please consult the QR Code specification for information on + * data capacities per version, ECC level, and text encoding mode. + * + * This function allows the user to create a custom sequence of segments that switches + * between modes (such as alphanumeric and byte) to encode text in less space. + * This is a low-level API; the high-level API is qrcodegen_encodeText() and qrcodegen_encodeBinary(). + */ +bool qrcodegen_encodeSegmentsAdvanced(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl, + int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl, uint8_t tempBuffer[], uint8_t qrcode[]); + + +/* + * Tests whether the given string can be encoded as a segment in numeric mode. + * A string is encodable iff each character is in the range 0 to 9. + */ +bool qrcodegen_isNumeric(const char *text); + + +/* + * Tests whether the given string can be encoded as a segment in alphanumeric mode. + * A string is encodable iff each character is in the following set: 0 to 9, A to Z + * (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + */ +bool qrcodegen_isAlphanumeric(const char *text); + + +/* + * Returns the number of bytes (uint8_t) needed for the data buffer of a segment + * containing the given number of characters using the given mode. Notes: + * - Returns SIZE_MAX on failure, i.e. numChars > INT16_MAX or the internal + * calculation of the number of needed bits exceeds INT16_MAX (i.e. 32767). + * - Otherwise, all valid results are in the range [0, ceil(INT16_MAX / 8)], i.e. at most 4096. + * - It is okay for the user to allocate more bytes for the buffer than needed. + * - For byte mode, numChars measures the number of bytes, not Unicode code points. + * - For ECI mode, numChars must be 0, and the worst-case number of bytes is returned. + * An actual ECI segment can have shorter data. For non-ECI modes, the result is exact. + */ +size_t qrcodegen_calcSegmentBufferSize(enum qrcodegen_Mode mode, size_t numChars); + + +/* + * Returns a segment representing the given binary data encoded in + * byte mode. All input byte arrays are acceptable. Any text string + * can be converted to UTF-8 bytes and encoded as a byte mode segment. + */ +struct qrcodegen_Segment qrcodegen_makeBytes(const uint8_t data[], size_t len, uint8_t buf[]); + + +/* + * Returns a segment representing the given string of decimal digits encoded in numeric mode. + */ +struct qrcodegen_Segment qrcodegen_makeNumeric(const char *digits, uint8_t buf[]); + + +/* + * Returns a segment representing the given text string encoded in alphanumeric mode. + * The characters allowed are: 0 to 9, A to Z (uppercase only), space, + * dollar, percent, asterisk, plus, hyphen, period, slash, colon. + */ +struct qrcodegen_Segment qrcodegen_makeAlphanumeric(const char *text, uint8_t buf[]); + + +/* + * Returns a segment representing an Extended Channel Interpretation + * (ECI) designator with the given assignment value. + */ +struct qrcodegen_Segment qrcodegen_makeEci(long assignVal, uint8_t buf[]); + + +/*---- Functions to extract raw data from QR Codes ----*/ + +/* + * Returns the side length of the given QR Code, assuming that encoding succeeded. + * The result is in the range [21, 177]. Note that the length of the array buffer + * is related to the side length - every 'uint8_t qrcode[]' must have length at least + * qrcodegen_BUFFER_LEN_FOR_VERSION(version), which equals ceil(size^2 / 8 + 1). + */ +int qrcodegen_getSize(const uint8_t qrcode[]); + + +/* + * Returns the color of the module (pixel) at the given coordinates, which is false + * for light or true for dark. The top left corner has the coordinates (x=0, y=0). + * If the given coordinates are out of bounds, then false (light) is returned. + */ +bool qrcodegen_getModule(const uint8_t qrcode[], int x, int y); + + +#ifdef __cplusplus +} +#endif diff --git a/components/platform/CMakeLists.txt b/components/platform/CMakeLists.txt new file mode 100644 index 0000000..a9d6d02 --- /dev/null +++ b/components/platform/CMakeLists.txt @@ -0,0 +1,36 @@ +idf_component_register( + SRCS + "src/platform_bootstrap.c" + "src/wifi_manager.c" + "src/ble_printer_client.c" + "src/direct_thermal_printer.c" + "src/display_st7789.c" + "src/display_lcd_module.c" + "src/display_lcd_decode_image.c" + "src/display_st7789p3.c" + "src/voice_audio.c" + "src/runtime_policy.c" + "src/runtime_diagnostics.c" + INCLUDE_DIRS + "include" + PRIV_INCLUDE_DIRS + "internal" + REQUIRES + bt + esp_coex + esp_wifi + esp_netif + esp_event + nvs_flash + mbedtls + esp_adc + esp_driver_gpio + esp_driver_spi + lvgl + espressif__libpng + esp_codec_dev + esp_audio_codec + driver + EMBED_FILES + "assets/lcd_demo.png" +) diff --git a/components/platform/README.md b/components/platform/README.md new file mode 100644 index 0000000..b571305 --- /dev/null +++ b/components/platform/README.md @@ -0,0 +1,13 @@ +# Platform Layer + +Purpose: hardware and system resource abstraction only. + +Allowed examples: +- Wi-Fi/BLE driver wrappers +- NVS/time/RTOS/system adapters +- board-specific IO and transport bindings + +Rules: +- Expose stable APIs in `include/`. +- Keep private headers in `internal/`. +- Do not depend on `domain`, `control_plane`, or `app_composition`. diff --git a/components/platform/assets/lcd_demo.png b/components/platform/assets/lcd_demo.png new file mode 100644 index 0000000..5f481b0 Binary files /dev/null and b/components/platform/assets/lcd_demo.png differ diff --git a/components/platform/include/.gitkeep b/components/platform/include/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/platform/include/platform.h b/components/platform/include/platform.h new file mode 100644 index 0000000..d573a1c --- /dev/null +++ b/components/platform/include/platform.h @@ -0,0 +1,240 @@ +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// ---------- platform_bootstrap ---------- +esp_err_t platform_bootstrap_init(void); + +// ---------- wifi_manager ---------- +esp_err_t wifi_manager_start(void); +esp_err_t wifi_manager_stop(void); + +bool wifi_manager_is_ready(void); +void wifi_manager_get_ip(char *buf, size_t buf_len); + +// ---------- ble_printer_client ---------- +typedef void (*ble_frame_rx_cb_t)(const uint8_t *data, size_t len); + +typedef struct { + bool connected; + bool notify_ready; + uint16_t mtu; +} ble_link_state_t; + +esp_err_t ble_printer_client_init(ble_frame_rx_cb_t rx_cb); +esp_err_t ble_printer_client_connect(const char *target_name, uint32_t timeout_ms); +void ble_printer_client_disconnect(void); + +bool ble_printer_client_is_connected(void); +void ble_printer_client_get_link_state(ble_link_state_t *out_state); +esp_err_t ble_printer_client_write(const uint8_t *data, size_t len); + +// ---------- direct_thermal_printer ---------- +typedef struct { + bool has_paper; + int8_t paper_gpio_level; + uint8_t paper_present_level; + uint8_t battery_percent; + float temperature_c; + int64_t updated_ms; +} platform_printer_sensors_t; + +typedef struct { + uint16_t width; + uint16_t height; + const uint8_t *raster; + size_t raster_len; + uint16_t strobe_on_time_us; + uint16_t strobe_interval_us; + uint16_t motor_step_delay_us; + uint8_t motor_steps_per_line; + uint32_t timeout_ms; + bool ignore_precheck; + const volatile bool *cancel_flag; +} platform_direct_print_request_t; + +esp_err_t platform_direct_printer_init(void); +void platform_direct_printer_deinit(void); +esp_err_t platform_direct_printer_connect(uint32_t timeout_ms); +void platform_direct_printer_disconnect(void); +bool platform_direct_printer_is_connected(void); +esp_err_t platform_direct_printer_get_sensors(platform_printer_sensors_t *out_sensors); +esp_err_t platform_direct_printer_get_debug_config(uint16_t *out_shift_clock_high_us, + uint16_t *out_shift_clock_low_us, + uint16_t *out_latch_pulse_us, + bool *out_strobe_active_high, + bool *out_boost_active_high, + uint16_t *out_override_strobe_on_us, + uint16_t *out_override_strobe_interval_us, + uint16_t *out_override_motor_step_us, + uint8_t *out_override_steps_per_line); +esp_err_t platform_direct_printer_set_debug_config(bool has_config, + uint16_t shift_clock_high_us, + uint16_t shift_clock_low_us, + uint16_t latch_pulse_us, + bool strobe_active_high, + bool boost_active_high, + uint16_t override_strobe_on_us, + uint16_t override_strobe_interval_us, + uint16_t override_motor_step_us, + uint8_t override_steps_per_line, + bool reset_defaults, + char *err, + size_t err_len); +esp_err_t platform_direct_printer_print(const platform_direct_print_request_t *request, + char *err, + size_t err_len); +esp_err_t platform_direct_printer_gap_move(uint32_t timeout_ms, char *err, size_t err_len); + +// ---------- display_st7789 ---------- +esp_err_t platform_display_init(void); +esp_err_t platform_display_show_test_pattern(void); +esp_err_t platform_display_show_raster_1bpp(const uint8_t *raster, + size_t raster_len, + uint16_t width, + uint16_t height, + uint32_t timeout_ms, + char *err, + size_t err_len); +esp_err_t platform_display_show_png(const uint8_t *png, + size_t png_len, + uint32_t timeout_ms, + char *err, + size_t err_len); +void platform_display_mark_shared_spi_dirty(void); +void platform_display_deinit(void); + +// ---------- voice_audio ---------- +typedef struct { + uint32_t sample_rate; + uint8_t channels; + uint8_t bits_per_sample; + int output_volume; + float input_gain_db; +} voice_audio_open_config_t; + +esp_err_t voice_audio_open(const voice_audio_open_config_t *cfg, char *err, size_t err_len); +void voice_audio_close(void); + +bool voice_audio_is_open(void); + +// timeout_ms applies to voice-audio mutex acquisition. +// Underlying codec driver I/O duration depends on driver/clocking state. +esp_err_t voice_audio_read_pcm(int16_t *pcm, + size_t samples, + uint32_t timeout_ms, + char *err, + size_t err_len); +// timeout_ms applies to voice-audio mutex acquisition. +// Underlying codec driver I/O duration depends on driver/clocking state. +esp_err_t voice_audio_write_pcm(const int16_t *pcm, + size_t samples, + uint32_t timeout_ms, + char *err, + size_t err_len); + +// ---------- runtime_policy ---------- +uint32_t runtime_policy_wifi_connect_timeout_ms(void); + +uint32_t runtime_policy_lifecycle_start_retry_count(void); +uint32_t runtime_policy_lifecycle_retry_backoff_ms(uint32_t attempt); +bool runtime_policy_is_retryable_error(esp_err_t err); + +uint32_t runtime_policy_printer_control_lock_timeout_ms(void); +uint32_t runtime_policy_printer_worker_queue_wait_ms(void); +uint32_t runtime_policy_printer_queue_retry_delay_ms(void); +uint32_t runtime_policy_printer_status_poll_interval_ms(void); +uint32_t runtime_policy_printer_stop_timeout_ms(void); +uint8_t runtime_policy_printer_default_backend(void); + +bool runtime_policy_direct_printer_enabled(void); +uint32_t runtime_policy_direct_printer_operation_timeout_ms(void); +uint16_t runtime_policy_direct_printer_strobe_on_us(void); +uint16_t runtime_policy_direct_printer_strobe_interval_us(void); +uint16_t runtime_policy_direct_printer_motor_step_us(void); +bool runtime_policy_direct_printer_motor_reverse(void); +uint8_t runtime_policy_direct_printer_steps_per_line(void); +uint16_t runtime_policy_direct_printer_gap_steps(void); +float runtime_policy_direct_printer_temp_min_c(void); +float runtime_policy_direct_printer_temp_max_c(void); +uint8_t runtime_policy_direct_printer_battery_min_percent(void); +uint8_t runtime_policy_direct_printer_paper_present_level(void); +uint16_t runtime_policy_direct_printer_battery_empty_mv(void); +uint16_t runtime_policy_direct_printer_battery_full_mv(void); +uint16_t runtime_policy_direct_printer_battery_divider_ratio_x1000(void); +uint32_t runtime_policy_direct_printer_ntc_pullup_ohms(void); +uint32_t runtime_policy_direct_printer_ntc_r25_ohms(void); +uint32_t runtime_policy_direct_printer_ntc_beta(void); + +uint32_t runtime_policy_rest_printer_connect_timeout_ms(void); +uint32_t runtime_policy_rest_label_timeout_ms(void); +uint32_t runtime_policy_rest_ota_timeout_ms(void); + +uint32_t runtime_policy_image_generation_timeout_default_ms(void); +uint32_t runtime_policy_image_generation_timeout_min_ms(void); +uint32_t runtime_policy_image_generation_timeout_max_ms(void); + +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); + +// ---------- runtime_diagnostics ---------- +typedef enum { + RUNTIME_DIAG_COUNTER_LIFECYCLE_START_ATTEMPT = 0, + RUNTIME_DIAG_COUNTER_LIFECYCLE_START_SUCCESS, + RUNTIME_DIAG_COUNTER_LIFECYCLE_START_FAILED, + RUNTIME_DIAG_COUNTER_LIFECYCLE_START_RETRY, + RUNTIME_DIAG_COUNTER_LIFECYCLE_STOP_ATTEMPT, + RUNTIME_DIAG_COUNTER_LIFECYCLE_STOP_SUCCESS, + RUNTIME_DIAG_COUNTER_LIFECYCLE_STOP_FAILED, + RUNTIME_DIAG_COUNTER_WIFI_CONNECT_SUCCESS, + RUNTIME_DIAG_COUNTER_WIFI_CONNECT_FAILED, + RUNTIME_DIAG_COUNTER_WIFI_CONNECT_TIMEOUT, + RUNTIME_DIAG_COUNTER_PRINTER_JOB_SUBMITTED, + RUNTIME_DIAG_COUNTER_PRINTER_JOB_SUCCESS, + RUNTIME_DIAG_COUNTER_PRINTER_JOB_FAILED, + RUNTIME_DIAG_COUNTER_PRINTER_JOB_CANCELED, + RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_ATTEMPT, + RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_SUCCESS, + RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_FAILED, + RUNTIME_DIAG_COUNTER_IMAGE_GENERATE_TIMEOUT, + RUNTIME_DIAG_COUNTER_REST_RESPONSES_TOTAL, + RUNTIME_DIAG_COUNTER_REST_ERRORS_TOTAL, + RUNTIME_DIAG_COUNTER_MAX, +} runtime_diag_counter_t; + +typedef enum { + RUNTIME_DIAG_GAUGE_LIFECYCLE_STATE = 0, + RUNTIME_DIAG_GAUGE_STATUS_POLL_PAUSE_DEPTH, + RUNTIME_DIAG_GAUGE_PRINTER_QUEUE_DEPTH, + RUNTIME_DIAG_GAUGE_MAX, +} runtime_diag_gauge_t; + +typedef struct { + uint64_t counters[RUNTIME_DIAG_COUNTER_MAX]; + int32_t gauges[RUNTIME_DIAG_GAUGE_MAX]; + int64_t last_error_ms; + esp_err_t last_error_code; + char last_error_source[32]; + char last_error_message[96]; +} runtime_diag_snapshot_t; + +void runtime_diag_counter_add(runtime_diag_counter_t counter, uint32_t delta); +void runtime_diag_set_gauge(runtime_diag_gauge_t gauge, int32_t value); +void runtime_diag_record_error(const char *source, esp_err_t code, const char *message); +void runtime_diag_get_snapshot(runtime_diag_snapshot_t *out_snapshot); + +const char *runtime_diag_counter_name(runtime_diag_counter_t counter); +const char *runtime_diag_gauge_name(runtime_diag_gauge_t gauge); + +#ifdef __cplusplus +} +#endif diff --git a/components/platform/internal/.gitkeep b/components/platform/internal/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/platform/internal/display_lcd_decode_image.h b/components/platform/internal/display_lcd_decode_image.h new file mode 100644 index 0000000..421ec6f --- /dev/null +++ b/components/platform/internal/display_lcd_decode_image.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +#include "esp_err.h" + +esp_err_t platform_lcd_decode_png_rgb565(const uint8_t *png, + size_t png_len, + uint16_t **out_pixels, + uint16_t *out_width, + uint16_t *out_height, + char *err, + size_t err_len); + +esp_err_t platform_lcd_decode_demo_png(uint16_t **out_pixels, + uint16_t *out_width, + uint16_t *out_height, + char *err, + size_t err_len); diff --git a/components/platform/internal/display_lcd_module.h b/components/platform/internal/display_lcd_module.h new file mode 100644 index 0000000..e1a265f --- /dev/null +++ b/components/platform/internal/display_lcd_module.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t lcd_module_init(void); +esp_err_t lcd_module_start_demo(void); +esp_err_t lcd_module_show_raster_1bpp(const uint8_t *raster, + size_t raster_len, + uint16_t width, + uint16_t height, + uint32_t timeout_ms, + char *err, + size_t err_len); +esp_err_t lcd_module_show_png(const uint8_t *png, + size_t png_len, + uint32_t timeout_ms, + char *err, + size_t err_len); +void lcd_module_mark_shared_spi_dirty(void); +void lcd_module_deinit(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/platform/internal/display_st7789p3.h b/components/platform/internal/display_st7789p3.h new file mode 100644 index 0000000..def681b --- /dev/null +++ b/components/platform/internal/display_st7789p3.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include +#include + +#include "driver/spi_master.h" +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct st7789p3_panel_t *st7789p3_handle_t; + +#define ST7789P3_MADCTL_MY 0x80 +#define ST7789P3_MADCTL_MX 0x40 +#define ST7789P3_MADCTL_MV 0x20 +#define ST7789P3_MADCTL_ML 0x10 +#define ST7789P3_MADCTL_BGR 0x08 +#define ST7789P3_MADCTL_MH 0x04 + +typedef struct { + spi_host_device_t spi_host; + int pin_mosi; + int pin_miso; + int pin_sclk; + int pin_cs; + int pin_dc; + int pin_rst; + int pin_bckl; + uint32_t pclk_hz; + uint8_t spi_mode; + int queue_size; + size_t max_transfer_sz; + uint16_t h_res; + uint16_t v_res; + uint16_t x_gap; + uint16_t y_gap; + uint8_t madctl_val; + bool invert_color; + bool swap_color_bytes; + int backlight_on_level; + bool init_spi_bus; +} st7789p3_config_t; + +#define ST7789P3_CONFIG_DEFAULT() \ + { \ + .spi_host = SPI2_HOST, \ + .pin_mosi = -1, \ + .pin_miso = -1, \ + .pin_sclk = -1, \ + .pin_cs = -1, \ + .pin_dc = -1, \ + .pin_rst = -1, \ + .pin_bckl = -1, \ + .pclk_hz = 10 * 1000 * 1000, \ + .spi_mode = 0, \ + .queue_size = 7, \ + .max_transfer_sz = 0, \ + .h_res = 320, \ + .v_res = 240, \ + .x_gap = 0, \ + .y_gap = 0, \ + .madctl_val = ST7789P3_MADCTL_MX | ST7789P3_MADCTL_MV, \ + .invert_color = false, \ + .swap_color_bytes = false, \ + .backlight_on_level = 1, \ + .init_spi_bus = true, \ + } + +esp_err_t st7789p3_new_panel(const st7789p3_config_t *config, st7789p3_handle_t *ret_panel); +esp_err_t st7789p3_del(st7789p3_handle_t panel); + +esp_err_t st7789p3_reset(st7789p3_handle_t panel); +esp_err_t st7789p3_init(st7789p3_handle_t panel); + +esp_err_t st7789p3_set_backlight(st7789p3_handle_t panel, bool on); +esp_err_t st7789p3_set_madctl(st7789p3_handle_t panel, uint8_t madctl_val); +esp_err_t st7789p3_set_invert(st7789p3_handle_t panel, bool invert_color); +esp_err_t st7789p3_set_swap_color_bytes(st7789p3_handle_t panel, bool swap_color_bytes); +esp_err_t st7789p3_recover_spi_bus(st7789p3_handle_t panel); + +esp_err_t st7789p3_set_window( + st7789p3_handle_t panel, uint16_t x_start, uint16_t y_start, uint16_t x_end, uint16_t y_end); +esp_err_t st7789p3_draw_bitmap(st7789p3_handle_t panel, + uint16_t x_start, + uint16_t y_start, + uint16_t x_end, + uint16_t y_end, + const uint16_t *color_data); + +esp_err_t st7789p3_fill_color(st7789p3_handle_t panel, uint16_t color); + +#ifdef __cplusplus +} +#endif diff --git a/components/platform/src/.gitkeep b/components/platform/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/components/platform/src/ble_printer_client.c b/components/platform/src/ble_printer_client.c new file mode 100644 index 0000000..eb3af89 --- /dev/null +++ b/components/platform/src/ble_printer_client.c @@ -0,0 +1,706 @@ +#include "platform.h" + +#include +#include + +#include "esp_err.h" +#include "esp_log.h" +#include "esp_nimble_hci.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/semphr.h" +#include "host/ble_gatt.h" +#include "host/ble_hs.h" +#include "nimble/nimble_port.h" +#include "nimble/nimble_port_freertos.h" +#include "os/os_mbuf.h" +#include "services/gap/ble_svc_gap.h" +#include "services/gatt/ble_svc_gatt.h" + +#define PRINTER_SERVICE_UUID16 0xFFF0 +#define PRINTER_NOTIFY_UUID16 0xFFF1 +#define PRINTER_WRITE_UUID16 0xFFF2 +#define CCCD_UUID16 0x2902 +#define PRINTER_NAME_PRIMARY "TQPrinter" +#define PRINTER_NAME_LEGACY "lyfPrinter" + +#define EVT_CONNECTED BIT0 +#define EVT_READY BIT1 +#define EVT_FAILED BIT2 + +static const char *TAG = "ble_client"; + +static EventGroupHandle_t s_evt_group; +static SemaphoreHandle_t s_lock; + +static ble_frame_rx_cb_t s_rx_cb; + +static uint8_t s_addr_type; +static ble_addr_t s_target_addr; +static char s_target_name[32] = PRINTER_NAME_PRIMARY; +static bool s_match_any_compatible; + +static uint16_t s_conn_handle = BLE_HS_CONN_HANDLE_NONE; +static uint16_t s_service_start; +static uint16_t s_service_end; +static uint16_t s_notify_val_handle; +static uint16_t s_write_val_handle; +static uint16_t s_cccd_handle; +static uint16_t s_mtu = 23; + +static bool s_scanning; +static bool s_notify_ready; +static bool s_host_synced; +static bool s_initialized; +static bool s_connecting; +static uint32_t s_connect_timeout_ms = 15000; + +static uint16_t uuid16(const ble_uuid_t *uuid) { + if (uuid == NULL || uuid->type != BLE_UUID_TYPE_16) { + return 0; + } + return BLE_UUID16(uuid)->value; +} + +static bool adv_has_uuid16(const struct ble_hs_adv_fields *fields, uint16_t target_uuid) { + if (fields == NULL || fields->uuids16 == NULL || fields->num_uuids16 == 0) { + return false; + } + for (uint8_t i = 0; i < fields->num_uuids16; ++i) { + if (fields->uuids16[i].value == target_uuid) { + return true; + } + } + return false; +} + +static size_t normalize_name(const char *in, char *out, size_t out_cap) { + if (in == NULL || out == NULL || out_cap == 0) { + return 0; + } + + const char *start = in; + while (*start != '\0' && isspace((unsigned char)*start)) { + ++start; + } + if (*start == '"' || *start == '\'') { + ++start; + } + + size_t len = strlen(start); + while (len > 0 && isspace((unsigned char)start[len - 1])) { + --len; + } + if (len > 0 && (start[len - 1] == '"' || start[len - 1] == '\'')) { + --len; + } + + size_t out_len = 0; + for (size_t i = 0; i < len && out_len + 1 < out_cap; ++i) { + unsigned char c = (unsigned char)start[i]; + if (isupper(c)) { + c = (unsigned char)tolower(c); + } + out[out_len++] = (char)c; + } + out[out_len] = '\0'; + return out_len; +} + +static bool normalized_name_match(const char *adv_name, const char *target_name) { + char adv[40] = {0}; + char target[40] = {0}; + size_t adv_len = normalize_name(adv_name, adv, sizeof(adv)); + size_t target_len = normalize_name(target_name, target, sizeof(target)); + if (adv_len == 0 || target_len == 0) { + return false; + } + if (strcmp(adv, target) == 0) { + return true; + } + // Be tolerant of shortened or prefixed names. + return (strstr(adv, target) != NULL) || (strstr(target, adv) != NULL); +} + +static bool is_known_printer_name(const char *name) { + return normalized_name_match(name, PRINTER_NAME_PRIMARY) || + normalized_name_match(name, PRINTER_NAME_LEGACY); +} + +static bool target_name_match_with_alias(const char *adv_name, const char *target_name) { + if (normalized_name_match(adv_name, target_name)) { + return true; + } + if (!is_known_printer_name(target_name)) { + return false; + } + return is_known_printer_name(adv_name); +} + +static void reset_discovery_state(void) { + s_service_start = 0; + s_service_end = 0; + s_notify_val_handle = 0; + s_write_val_handle = 0; + s_cccd_handle = 0; + s_notify_ready = false; + s_mtu = 23; +} + +static void signal_failure(void) { + xEventGroupSetBits(s_evt_group, EVT_FAILED); +} + +static void signal_ready(void) { + xEventGroupSetBits(s_evt_group, EVT_READY); +} + +static int dsc_disc_cb(uint16_t conn_handle, + const struct ble_gatt_error *error, + uint16_t chr_val_handle, + const struct ble_gatt_dsc *dsc, + void *arg); + +static int chr_disc_cb(uint16_t conn_handle, + const struct ble_gatt_error *error, + const struct ble_gatt_chr *chr, + void *arg); + +static int svc_disc_cb(uint16_t conn_handle, + const struct ble_gatt_error *error, + const struct ble_gatt_svc *service, + void *arg); + +static int cccd_write_cb(uint16_t conn_handle, + const struct ble_gatt_error *error, + struct ble_gatt_attr *attr, + void *arg) { + (void)conn_handle; + (void)attr; + (void)arg; + + if (error->status != 0) { + ESP_LOGE(TAG, "CCCD write failed status=%d", error->status); + signal_failure(); + return 0; + } + + s_notify_ready = true; + ESP_LOGI(TAG, "Notify subscription enabled"); + signal_ready(); + return 0; +} + +static int dsc_disc_cb(uint16_t conn_handle, + const struct ble_gatt_error *error, + uint16_t chr_val_handle, + const struct ble_gatt_dsc *dsc, + void *arg) { + (void)chr_val_handle; + (void)arg; + + if (error->status == 0 && dsc != NULL) { + if (uuid16(&dsc->uuid.u) == CCCD_UUID16) { + s_cccd_handle = dsc->handle; + ESP_LOGI(TAG, "Found CCCD handle=%u", s_cccd_handle); + } + return 0; + } + + if (error->status == BLE_HS_EDONE) { + if (s_cccd_handle == 0) { + ESP_LOGW(TAG, "CCCD not found, fallback to val_handle+1"); + s_cccd_handle = s_notify_val_handle + 1; + } + + uint8_t cccd[2] = {0x01, 0x00}; + int rc = ble_gattc_write_flat(conn_handle, s_cccd_handle, cccd, sizeof(cccd), cccd_write_cb, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "ble_gattc_write_flat CCCD failed rc=%d", rc); + signal_failure(); + } + return 0; + } + + ESP_LOGE(TAG, "Descriptor discovery failed status=%d", error->status); + signal_failure(); + return 0; +} + +static int chr_disc_cb(uint16_t conn_handle, + const struct ble_gatt_error *error, + const struct ble_gatt_chr *chr, + void *arg) { + (void)arg; + + if (error->status == 0 && chr != NULL) { + uint16_t id = uuid16(&chr->uuid.u); + if (id == PRINTER_NOTIFY_UUID16) { + s_notify_val_handle = chr->val_handle; + ESP_LOGI(TAG, "Found notify char handle=%u", s_notify_val_handle); + } else if (id == PRINTER_WRITE_UUID16) { + s_write_val_handle = chr->val_handle; + ESP_LOGI(TAG, "Found write char handle=%u", s_write_val_handle); + } + return 0; + } + + if (error->status == BLE_HS_EDONE) { + if (s_notify_val_handle == 0 || s_write_val_handle == 0) { + ESP_LOGE(TAG, + "Characteristic discovery incomplete notify=%u write=%u", + s_notify_val_handle, + s_write_val_handle); + signal_failure(); + return 0; + } + + uint16_t end_handle = (s_write_val_handle > s_notify_val_handle) + ? (uint16_t)(s_write_val_handle - 1) + : s_service_end; + + if (end_handle <= s_notify_val_handle) { + end_handle = s_service_end; + } + + int rc = ble_gattc_disc_all_dscs(conn_handle, + s_notify_val_handle, + end_handle, + dsc_disc_cb, + NULL); + if (rc != 0) { + ESP_LOGE(TAG, "Descriptor discovery start failed rc=%d", rc); + signal_failure(); + } + return 0; + } + + ESP_LOGE(TAG, "Characteristic discovery failed status=%d", error->status); + signal_failure(); + return 0; +} + +static int svc_disc_cb(uint16_t conn_handle, + const struct ble_gatt_error *error, + const struct ble_gatt_svc *service, + void *arg) { + (void)arg; + + if (error->status == 0 && service != NULL) { + if (uuid16(&service->uuid.u) == PRINTER_SERVICE_UUID16) { + s_service_start = service->start_handle; + s_service_end = service->end_handle; + ESP_LOGI(TAG, "Found printer service [%u, %u]", s_service_start, s_service_end); + } + return 0; + } + + if (error->status == BLE_HS_EDONE) { + uint16_t start = s_service_start; + uint16_t end = s_service_end; + if (start == 0 || end == 0) { + // Android app searches all services/chars for FFF1/FFF2. + // Use a global fallback range for broader compatibility. + start = 1; + end = 0xFFFF; + ESP_LOGW(TAG, "Printer service 0x%04x not found; fallback char discovery in full handle range", PRINTER_SERVICE_UUID16); + } + + int rc = ble_gattc_disc_all_chrs(conn_handle, + start, + end, + chr_disc_cb, + NULL); + if (rc != 0) { + ESP_LOGE(TAG, "Start characteristic discovery failed rc=%d", rc); + signal_failure(); + } + return 0; + } + + ESP_LOGE(TAG, "Service discovery failed status=%d", error->status); + signal_failure(); + return 0; +} + +static void start_service_discovery(void) { + int rc = ble_gattc_disc_all_svcs(s_conn_handle, svc_disc_cb, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "ble_gattc_disc_all_svcs failed rc=%d", rc); + signal_failure(); + } +} + +static void start_scan(void); + +static void stop_scan_if_running(void) { + if (s_scanning) { + ble_gap_disc_cancel(); + s_scanning = false; + } +} + +static int gap_event_cb(struct ble_gap_event *event, void *arg) { + (void)arg; + + switch (event->type) { + case BLE_GAP_EVENT_DISC: { + if (!s_scanning) { + return 0; + } + struct ble_hs_adv_fields fields; + memset(&fields, 0, sizeof(fields)); + + int rc = ble_hs_adv_parse_fields(&fields, + event->disc.data, + event->disc.length_data); + if (rc != 0) { + return 0; + } + + char name[32] = {0}; + bool has_name = (fields.name_len > 0 && fields.name != NULL); + if (has_name) { + size_t n = fields.name_len < sizeof(name) - 1 ? fields.name_len : sizeof(name) - 1; + memcpy(name, fields.name, n); + } + + if (s_match_any_compatible) { + bool uuid_match = adv_has_uuid16(&fields, PRINTER_SERVICE_UUID16); + bool name_match = has_name && is_known_printer_name(name); + if (!uuid_match && !name_match) { + return 0; + } + ESP_LOGI(TAG, + "Found compatible device '%s' RSSI=%d (uuid_match=%d)", + has_name ? name : "", + event->disc.rssi, + uuid_match ? 1 : 0); + } else { + if (!has_name || !target_name_match_with_alias(name, s_target_name)) { + return 0; + } + ESP_LOGI(TAG, "Found target device '%s' RSSI=%d", name, event->disc.rssi); + } + + s_target_addr = event->disc.addr; + stop_scan_if_running(); + + struct ble_gap_conn_params conn_params; + memset(&conn_params, 0, sizeof(conn_params)); + conn_params.scan_itvl = 0x0010; + conn_params.scan_window = 0x0010; + conn_params.itvl_min = 0x0018; + conn_params.itvl_max = 0x0028; + conn_params.latency = 0; + conn_params.supervision_timeout = 0x0100; + conn_params.min_ce_len = 0x0010; + conn_params.max_ce_len = 0x0300; + + rc = ble_gap_connect(s_addr_type, + &s_target_addr, + s_connect_timeout_ms > 30000 ? 30000 : s_connect_timeout_ms, + &conn_params, + gap_event_cb, + NULL); + if (rc != 0) { + ESP_LOGE(TAG, "ble_gap_connect failed rc=%d", rc); + s_connecting = false; + signal_failure(); + } else { + ESP_LOGI(TAG, "Connecting..."); + } + + return 0; + } + + case BLE_GAP_EVENT_DISC_COMPLETE: + s_scanning = false; + return 0; + + case BLE_GAP_EVENT_CONNECT: + if (event->connect.status == 0) { + if (!s_connecting) { + (void)ble_gap_terminate(event->connect.conn_handle, BLE_ERR_REM_USER_CONN_TERM); + return 0; + } + s_connecting = false; + s_conn_handle = event->connect.conn_handle; + xEventGroupSetBits(s_evt_group, EVT_CONNECTED); + ESP_LOGI(TAG, "Connected handle=%u", s_conn_handle); + + ble_gattc_exchange_mtu(s_conn_handle, NULL, NULL); + start_service_discovery(); + } else { + s_connecting = false; + ESP_LOGE(TAG, "Connect failed status=%d", event->connect.status); + signal_failure(); + } + return 0; + + case BLE_GAP_EVENT_DISCONNECT: + ESP_LOGW(TAG, "Disconnected reason=%d", event->disconnect.reason); + s_connecting = false; + s_conn_handle = BLE_HS_CONN_HANDLE_NONE; + s_notify_ready = false; + reset_discovery_state(); + return 0; + + case BLE_GAP_EVENT_MTU: + s_mtu = event->mtu.value; + ESP_LOGI(TAG, "MTU updated to %u", s_mtu); + return 0; + + case BLE_GAP_EVENT_NOTIFY_RX: { + if (event->notify_rx.om == NULL || s_rx_cb == NULL) { + return 0; + } + + uint16_t data_len = OS_MBUF_PKTLEN(event->notify_rx.om); + if (data_len == 0) { + return 0; + } + + uint8_t buf[256]; + uint16_t out_len = 0; + if (data_len > sizeof(buf)) { + ESP_LOGW(TAG, "Notify packet too large len=%u", data_len); + return 0; + } + + int rc = ble_hs_mbuf_to_flat(event->notify_rx.om, buf, sizeof(buf), &out_len); + if (rc != 0) { + ESP_LOGW(TAG, "ble_hs_mbuf_to_flat notify failed rc=%d", rc); + return 0; + } + + s_rx_cb(buf, out_len); + return 0; + } + + default: + return 0; + } +} + +static void start_scan(void) { + struct ble_gap_disc_params params; + memset(¶ms, 0, sizeof(params)); + // Keep duplicates so we can receive scan response updates carrying full local name. + params.filter_duplicates = 0; + params.passive = 0; + params.itvl = 0x0010; + params.window = 0x0010; + + stop_scan_if_running(); + + int rc = ble_gap_disc(s_addr_type, BLE_HS_FOREVER, ¶ms, gap_event_cb, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "ble_gap_disc failed rc=%d", rc); + signal_failure(); + return; + } + + s_scanning = true; + if (s_match_any_compatible) { + ESP_LOGI(TAG, "Scanning for compatible printer (service 0x%04x)", PRINTER_SERVICE_UUID16); + } else { + ESP_LOGI(TAG, "Scanning for %s", s_target_name); + } +} + +static void ble_on_reset(int reason) { + ESP_LOGE(TAG, "BLE reset reason=%d", reason); +} + +static void ble_on_sync(void) { + int rc = ble_hs_id_infer_auto(0, &s_addr_type); + if (rc != 0) { + ESP_LOGE(TAG, "ble_hs_id_infer_auto failed rc=%d", rc); + } + s_host_synced = true; +} + +static void nimble_host_task(void *param) { + (void)param; + nimble_port_run(); + nimble_port_freertos_deinit(); +} + +esp_err_t ble_printer_client_init(ble_frame_rx_cb_t rx_cb) { + s_rx_cb = rx_cb; + + if (s_initialized) { + reset_discovery_state(); + return ESP_OK; + } + + // NimBLE emits very chatty INFO logs during each chunk write. + // Lowering these logs reduces serial I/O overhead and improves runtime smoothness. + esp_log_level_set("NimBLE", ESP_LOG_WARN); + esp_log_level_set("BLE_INIT", ESP_LOG_WARN); + + s_evt_group = xEventGroupCreate(); + if (s_evt_group == NULL) { + return ESP_ERR_NO_MEM; + } + + s_lock = xSemaphoreCreateMutex(); + if (s_lock == NULL) { + vEventGroupDelete(s_evt_group); + s_evt_group = NULL; + return ESP_ERR_NO_MEM; + } + + esp_err_t err = nimble_port_init(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + return err; + } + ble_hs_cfg.reset_cb = ble_on_reset; + ble_hs_cfg.sync_cb = ble_on_sync; + + ble_svc_gap_init(); + ble_svc_gatt_init(); + + nimble_port_freertos_init(nimble_host_task); + + reset_discovery_state(); + s_initialized = true; + return ESP_OK; +} + +esp_err_t ble_printer_client_connect(const char *target_name, uint32_t timeout_ms) { + if (!s_initialized || s_lock == NULL || s_evt_group == NULL) { + return ESP_ERR_INVALID_STATE; + } + + uint32_t effective_timeout_ms = timeout_ms; + if (effective_timeout_ms < 1000) { + effective_timeout_ms = 1000; + } else if (effective_timeout_ms > 60000) { + effective_timeout_ms = 60000; + } + + s_match_any_compatible = false; + if (target_name != NULL && target_name[0] != '\0') { + if (strcmp(target_name, "*") == 0) { + s_match_any_compatible = true; + } else { + strlcpy(s_target_name, target_name, sizeof(s_target_name)); + } + } + + if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(3000)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + if (s_conn_handle != BLE_HS_CONN_HANDLE_NONE && s_notify_ready) { + xSemaphoreGive(s_lock); + return ESP_OK; + } + if (s_connecting) { + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_STATE; + } + + xEventGroupClearBits(s_evt_group, EVT_CONNECTED | EVT_READY | EVT_FAILED); + reset_discovery_state(); + s_connect_timeout_ms = effective_timeout_ms; + s_connecting = true; + + int wait_sync_ms = 3000; + while (!s_host_synced && wait_sync_ms > 0) { + vTaskDelay(pdMS_TO_TICKS(20)); + wait_sync_ms -= 20; + } + if (!s_host_synced) { + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_STATE; + } + + start_scan(); + xSemaphoreGive(s_lock); + + EventBits_t bits = xEventGroupWaitBits(s_evt_group, + EVT_READY | EVT_FAILED, + pdFALSE, + pdFALSE, + pdMS_TO_TICKS(effective_timeout_ms)); + + if (bits & EVT_READY) { + return ESP_OK; + } + if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(1000)) == pdTRUE) { + stop_scan_if_running(); + if (s_conn_handle != BLE_HS_CONN_HANDLE_NONE) { + (void)ble_gap_terminate(s_conn_handle, BLE_ERR_REM_USER_CONN_TERM); + } + s_connecting = false; + reset_discovery_state(); + xSemaphoreGive(s_lock); + } + + if (bits & EVT_FAILED) { + return ESP_FAIL; + } + return ESP_ERR_TIMEOUT; +} + +void ble_printer_client_disconnect(void) { + if (!s_initialized || s_lock == NULL) { + return; + } + + if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(2000)) != pdTRUE) { + return; + } + + stop_scan_if_running(); + + if (s_conn_handle != BLE_HS_CONN_HANDLE_NONE) { + ble_gap_terminate(s_conn_handle, BLE_ERR_REM_USER_CONN_TERM); + } + + s_connecting = false; + s_conn_handle = BLE_HS_CONN_HANDLE_NONE; + reset_discovery_state(); + xSemaphoreGive(s_lock); +} + +bool ble_printer_client_is_connected(void) { + if (!s_initialized) { + return false; + } + return (s_conn_handle != BLE_HS_CONN_HANDLE_NONE) && s_notify_ready; +} + +void ble_printer_client_get_link_state(ble_link_state_t *out_state) { + if (out_state == NULL) { + return; + } + + out_state->connected = (s_conn_handle != BLE_HS_CONN_HANDLE_NONE); + out_state->notify_ready = s_notify_ready; + out_state->mtu = s_mtu; +} + +esp_err_t ble_printer_client_write(const uint8_t *data, size_t len) { + if (data == NULL || len == 0) { + return ESP_ERR_INVALID_ARG; + } + + if (!ble_printer_client_is_connected() || s_write_val_handle == 0) { + return ESP_ERR_INVALID_STATE; + } + + int rc = ble_gattc_write_no_rsp_flat(s_conn_handle, + s_write_val_handle, + data, + len); + if (rc != 0) { + ESP_LOGW(TAG, "write_no_rsp failed rc=%d", rc); + return ESP_FAIL; + } + + return ESP_OK; +} diff --git a/components/platform/src/direct_thermal_printer.c b/components/platform/src/direct_thermal_printer.c new file mode 100644 index 0000000..c79f170 --- /dev/null +++ b/components/platform/src/direct_thermal_printer.c @@ -0,0 +1,1020 @@ +#include "platform.h" + +#include +#include +#include + +#include "driver/gpio.h" +#include "esp_adc/adc_oneshot.h" +#include "esp_log.h" +#include "esp_rom_sys.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "sdkconfig.h" + +#ifndef CONFIG_TQ_DIRECT_PRINTER_ENABLE +#define CONFIG_TQ_DIRECT_PRINTER_ENABLE 1 +#endif + +#ifndef CONFIG_TQ_KEY_PRINT_BOOST_ACTIVE_HIGH +#define CONFIG_TQ_KEY_PRINT_BOOST_ACTIVE_HIGH 1 +#endif + +#if CONFIG_TQ_KEY_PRINT_BOOST_ACTIVE_HIGH +#define BOOST_DEFAULT_ACTIVE_HIGH 1 +#else +#define BOOST_DEFAULT_ACTIVE_HIGH 0 +#endif + +#define ADC_RAW_MAX 4095 +#define DEBUG_SHIFT_CLOCK_US_MIN 1 +#define DEBUG_SHIFT_CLOCK_US_MAX 50 +#define DEBUG_LATCH_PULSE_US_MIN 1 +#define DEBUG_LATCH_PULSE_US_MAX 50 + +static const uint16_t k_default_shift_clock_high_us = 5; +static const uint16_t k_default_shift_clock_low_us = 5; +static const uint16_t k_default_latch_pulse_us = 1; +static const bool k_default_strobe_active_high = true; + +static const char *TAG = "direct_printer"; +static const uint8_t k_print_yield_lines = 8; +static const uint16_t k_gap_yield_steps = 32; + +typedef struct { + bool ready; + adc_unit_t unit; + adc_channel_t channel; +} adc_pin_t; + +typedef struct { + uint16_t shift_clock_high_us; + uint16_t shift_clock_low_us; + uint16_t latch_pulse_us; + bool strobe_active_high; + bool boost_active_high; + uint16_t override_strobe_on_us; + uint16_t override_strobe_interval_us; + uint16_t override_motor_step_us; + uint8_t override_steps_per_line; +} direct_debug_config_t; + +typedef struct { + bool initialized; + bool connected; + uint8_t motor_phase; + direct_debug_config_t debug; + SemaphoreHandle_t lock; + adc_oneshot_unit_handle_t adc1_handle; + adc_oneshot_unit_handle_t adc2_handle; + adc_pin_t battery_adc; + adc_pin_t ntc_adc; +} direct_printer_state_t; + +static direct_printer_state_t s_state; + +static const uint8_t k_motor_step_table[4] = {0x05, 0x09, 0x0A, 0x06}; + +static int boost_on_level_locked(void) { + return s_state.debug.boost_active_high ? 1 : 0; +} + +static int boost_off_level_locked(void) { + return s_state.debug.boost_active_high ? 0 : 1; +} + +static int strobe_active_level_locked(void) { + return s_state.debug.strobe_active_high ? 1 : 0; +} + +static int strobe_idle_level_locked(void) { + return s_state.debug.strobe_active_high ? 0 : 1; +} + +static void set_debug_defaults_locked(void) { + memset(&s_state.debug, 0, sizeof(s_state.debug)); + s_state.debug.shift_clock_high_us = k_default_shift_clock_high_us; + s_state.debug.shift_clock_low_us = k_default_shift_clock_low_us; + s_state.debug.latch_pulse_us = k_default_latch_pulse_us; + s_state.debug.strobe_active_high = k_default_strobe_active_high; + s_state.debug.boost_active_high = BOOST_DEFAULT_ACTIVE_HIGH != 0; +} + +static bool is_gpio_valid(int gpio_num) { + return gpio_num >= 0; +} + +static void write_err(char *err, size_t err_len, const char *text) { + if (err != NULL && err_len > 0) { + strlcpy(err, text, err_len); + } +} + +static void set_gpio_level_if_valid(int gpio_num, int level) { + if (!is_gpio_valid(gpio_num)) { + return; + } + (void)gpio_set_level(gpio_num, level); +} + +static esp_err_t config_output_pin(int gpio_num, int level, const char *name) { + if (!is_gpio_valid(gpio_num)) { + return ESP_OK; + } + + gpio_config_t cfg = { + .pin_bit_mask = 1ULL << gpio_num, + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + esp_err_t err = gpio_config(&cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "gpio_config output failed: %s pin=%d err=%s", name, gpio_num, esp_err_to_name(err)); + return err; + } + err = gpio_set_level(gpio_num, level); + if (err != ESP_OK) { + ESP_LOGE(TAG, "gpio_set_level failed: %s pin=%d level=%d err=%s", name, gpio_num, level, esp_err_to_name(err)); + } + return err; +} + +static esp_err_t config_input_pin(int gpio_num, bool pull_up, const char *name) { + if (!is_gpio_valid(gpio_num)) { + return ESP_OK; + } + + gpio_config_t cfg = { + .pin_bit_mask = 1ULL << gpio_num, + .mode = GPIO_MODE_INPUT, + .pull_up_en = pull_up ? GPIO_PULLUP_ENABLE : GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + esp_err_t err = gpio_config(&cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "gpio_config input failed: %s pin=%d err=%s", name, gpio_num, esp_err_to_name(err)); + } + return err; +} + +static esp_err_t ensure_shared_shift_pins_gpio_locked(void) { + platform_display_mark_shared_spi_dirty(); + + esp_err_t err = config_output_pin(CONFIG_TQ_SPI_SHARED_MOSI_PIN, 0, "print_mosi"); + if (err != ESP_OK) { + return err; + } + return config_output_pin(CONFIG_TQ_SPI_SHARED_CLK_PIN, 0, "print_clk"); +} + +static void set_stb_level_locked(int level) { + int out_level = level ? strobe_active_level_locked() : strobe_idle_level_locked(); + set_gpio_level_if_valid(CONFIG_TQ_PRINT_STB_12_PIN, out_level); + set_gpio_level_if_valid(CONFIG_TQ_PRINT_STB_34_PIN, out_level); + set_gpio_level_if_valid(CONFIG_TQ_PRINT_STB_56_PIN, 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); + set_gpio_level_if_valid(CONFIG_TQ_PRINT_OUTB_P_PIN, (pattern >> 2) & 0x01); + set_gpio_level_if_valid(CONFIG_TQ_PRINT_OUTB_N_PIN, (pattern >> 3) & 0x01); +} + +static void motor_off_locked(void) { + motor_apply_pattern_locked(0x00); +} + +static void safe_drive_off_locked(bool keep_boost_on) { + set_stb_level_locked(0); + set_gpio_level_if_valid(CONFIG_TQ_SPI_SHARED_MOSI_PIN, 0); + set_gpio_level_if_valid(CONFIG_TQ_SPI_SHARED_CLK_PIN, 0); + set_gpio_level_if_valid(CONFIG_TQ_PRINT_LAT_PIN, 1); + motor_off_locked(); + if (!keep_boost_on) { + set_gpio_level_if_valid(CONFIG_TQ_KEY_PRINT_PIN, boost_off_level_locked()); + } +} + +static void pulse_latch_locked(void) { + uint16_t latch_us = s_state.debug.latch_pulse_us; + if (latch_us < DEBUG_LATCH_PULSE_US_MIN) { + latch_us = DEBUG_LATCH_PULSE_US_MIN; + } else if (latch_us > DEBUG_LATCH_PULSE_US_MAX) { + latch_us = DEBUG_LATCH_PULSE_US_MAX; + } + set_gpio_level_if_valid(CONFIG_TQ_PRINT_LAT_PIN, 0); + esp_rom_delay_us(latch_us); + 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); + esp_rom_delay_us(on_us); + set_stb_level_locked(0); + if (interval_us > 0) { + esp_rom_delay_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); + if (step_delay_us > 0) { + esp_rom_delay_us(step_delay_us); + } + if (runtime_policy_direct_printer_motor_reverse()) { + s_state.motor_phase = (uint8_t)((s_state.motor_phase + 3) & 0x03); + } else { + s_state.motor_phase = (uint8_t)((s_state.motor_phase + 1) & 0x03); + } +} + +static void write_line_bits_locked(const uint8_t *line, size_t line_bytes) { + uint16_t high_us = s_state.debug.shift_clock_high_us; + uint16_t low_us = s_state.debug.shift_clock_low_us; + if (high_us < DEBUG_SHIFT_CLOCK_US_MIN) { + high_us = DEBUG_SHIFT_CLOCK_US_MIN; + } else if (high_us > DEBUG_SHIFT_CLOCK_US_MAX) { + high_us = DEBUG_SHIFT_CLOCK_US_MAX; + } + if (low_us < DEBUG_SHIFT_CLOCK_US_MIN) { + low_us = DEBUG_SHIFT_CLOCK_US_MIN; + } else if (low_us > DEBUG_SHIFT_CLOCK_US_MAX) { + low_us = DEBUG_SHIFT_CLOCK_US_MAX; + } + + for (size_t i = 0; i < line_bytes; ++i) { + uint8_t byte = line[i]; + for (int bit = 7; bit >= 0; --bit) { + int pixel = ((byte >> bit) & 0x01) ? 1 : 0; + set_gpio_level_if_valid(CONFIG_TQ_SPI_SHARED_MOSI_PIN, pixel); + set_gpio_level_if_valid(CONFIG_TQ_SPI_SHARED_CLK_PIN, 1); + esp_rom_delay_us(high_us); + set_gpio_level_if_valid(CONFIG_TQ_SPI_SHARED_CLK_PIN, 0); + esp_rom_delay_us(low_us); + } + } +} + +static esp_err_t ensure_adc_unit(adc_unit_t unit, adc_oneshot_unit_handle_t *out_handle) { + if (out_handle == NULL) { + return ESP_ERR_INVALID_ARG; + } + + adc_oneshot_unit_handle_t *slot = NULL; + if (unit == ADC_UNIT_1) { + slot = &s_state.adc1_handle; + } else if (unit == ADC_UNIT_2) { + slot = &s_state.adc2_handle; + } else { + return ESP_ERR_NOT_SUPPORTED; + } + + if (*slot == NULL) { + adc_oneshot_unit_init_cfg_t cfg = { + .unit_id = unit, + .clk_src = ADC_RTC_CLK_SRC_DEFAULT, + .ulp_mode = ADC_ULP_MODE_DISABLE, + }; + esp_err_t err = adc_oneshot_new_unit(&cfg, slot); + if (err != ESP_OK) { + ESP_LOGW(TAG, "adc unit init failed: unit=%d err=%s", (int)unit, esp_err_to_name(err)); + return err; + } + } + + *out_handle = *slot; + return ESP_OK; +} + +static void init_adc_pin_if_possible(int gpio_num, const char *name, adc_pin_t *out_pin) { + if (out_pin == NULL) { + return; + } + + memset(out_pin, 0, sizeof(*out_pin)); + if (!is_gpio_valid(gpio_num)) { + return; + } + + adc_unit_t unit = ADC_UNIT_1; + adc_channel_t channel = ADC_CHANNEL_0; + esp_err_t err = adc_oneshot_io_to_channel(gpio_num, &unit, &channel); + if (err != ESP_OK) { + ESP_LOGW(TAG, "adc gpio unsupported: %s pin=%d err=%s", name, gpio_num, esp_err_to_name(err)); + return; + } + + adc_oneshot_unit_handle_t handle = NULL; + err = ensure_adc_unit(unit, &handle); + if (err != ESP_OK) { + return; + } + + adc_oneshot_chan_cfg_t chan_cfg = { + .atten = ADC_ATTEN_DB_12, + .bitwidth = ADC_BITWIDTH_DEFAULT, + }; + err = adc_oneshot_config_channel(handle, channel, &chan_cfg); + if (err != ESP_OK) { + ESP_LOGW(TAG, "adc channel config failed: %s pin=%d err=%s", name, gpio_num, esp_err_to_name(err)); + return; + } + + out_pin->ready = true; + out_pin->unit = unit; + out_pin->channel = channel; +} + +static esp_err_t read_adc_raw_locked(const adc_pin_t *pin, int *out_raw) { + if (pin == NULL || out_raw == NULL || !pin->ready) { + return ESP_ERR_INVALID_STATE; + } + + adc_oneshot_unit_handle_t handle = NULL; + if (pin->unit == ADC_UNIT_1) { + handle = s_state.adc1_handle; + } else if (pin->unit == ADC_UNIT_2) { + handle = s_state.adc2_handle; + } + + if (handle == NULL) { + return ESP_ERR_INVALID_STATE; + } + + return adc_oneshot_read(handle, pin->channel, out_raw); +} + +static bool read_paper_present_locked(int *out_level) { + if (!is_gpio_valid(CONFIG_TQ_PRINT_PAPER_PIN)) { + if (out_level != NULL) { + *out_level = -1; + } + return true; + } + int level = gpio_get_level(CONFIG_TQ_PRINT_PAPER_PIN); + if (out_level != NULL) { + *out_level = level; + } + return level == (int)runtime_policy_direct_printer_paper_present_level(); +} + +static uint16_t battery_raw_to_mv(int raw) { + if (raw < 0) { + raw = 0; + } + if (raw > ADC_RAW_MAX) { + raw = ADC_RAW_MAX; + } + float adc_mv = ((float)raw * 3300.0f) / (float)ADC_RAW_MAX; + float ratio = (float)runtime_policy_direct_printer_battery_divider_ratio_x1000() / 1000.0f; + float batt_mv = adc_mv * ratio; + if (batt_mv < 0.0f) { + batt_mv = 0.0f; + } + if (batt_mv > 65535.0f) { + batt_mv = 65535.0f; + } + return (uint16_t)batt_mv; +} + +static uint8_t battery_mv_to_percent(uint16_t batt_mv) { + uint16_t empty_mv = runtime_policy_direct_printer_battery_empty_mv(); + uint16_t full_mv = runtime_policy_direct_printer_battery_full_mv(); + if (full_mv <= empty_mv) { + return 100; + } + if (batt_mv <= empty_mv) { + return 0; + } + if (batt_mv >= full_mv) { + return 100; + } + return (uint8_t)(((uint32_t)(batt_mv - empty_mv) * 100u) / (uint32_t)(full_mv - empty_mv)); +} + +static float ntc_raw_to_temp_c(int raw) { + if (raw <= 0 || raw >= ADC_RAW_MAX) { + return 25.0f; + } + + float pullup = (float)runtime_policy_direct_printer_ntc_pullup_ohms(); + float r25 = (float)runtime_policy_direct_printer_ntc_r25_ohms(); + float beta = (float)runtime_policy_direct_printer_ntc_beta(); + if (pullup <= 0.0f || r25 <= 0.0f || beta <= 0.0f) { + return 25.0f; + } + + float ratio = (float)raw / (float)(ADC_RAW_MAX - raw); + float r_ntc = pullup * ratio; + if (r_ntc <= 1.0f) { + return 25.0f; + } + + float inv_t = (1.0f / (273.15f + 25.0f)) + (logf(r_ntc / r25) / beta); + if (inv_t <= 0.0f) { + return 25.0f; + } + return (1.0f / inv_t) - 273.15f; +} + +static void sample_sensors_locked(platform_printer_sensors_t *out_sensors) { + if (out_sensors == NULL) { + return; + } + + memset(out_sensors, 0, sizeof(*out_sensors)); + int paper_level = -1; + 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_direct_printer_paper_present_level(); + 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) { + uint16_t batt_mv = battery_raw_to_mv(battery_raw); + 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->temperature_c = ntc_raw_to_temp_c(ntc_raw); + } +} + +static esp_err_t precheck_before_print_locked(char *err, size_t err_len) { + platform_printer_sensors_t sensors = {0}; + sample_sensors_locked(&sensors); + + if (!sensors.has_paper) { + write_err(err, err_len, "printer out of paper"); + return ESP_ERR_INVALID_STATE; + } + + float temp_min = runtime_policy_direct_printer_temp_min_c(); + float temp_max = runtime_policy_direct_printer_temp_max_c(); + if (temp_max <= temp_min) { + temp_max = temp_min + 1.0f; + } + if (sensors.temperature_c < temp_min || sensors.temperature_c > temp_max) { + write_err(err, err_len, "temperature out of range"); + return ESP_ERR_INVALID_STATE; + } + + uint8_t battery_min = runtime_policy_direct_printer_battery_min_percent(); + if (sensors.battery_percent < battery_min) { + write_err(err, err_len, "battery too low"); + return ESP_ERR_INVALID_STATE; + } + + return ESP_OK; +} + +static bool has_timed_out(int64_t deadline_ms) { + if (deadline_ms <= 0) { + return false; + } + return (esp_timer_get_time() / 1000) > deadline_ms; +} + +esp_err_t platform_direct_printer_init(void) { +#if !CONFIG_TQ_DIRECT_PRINTER_ENABLE + return ESP_ERR_NOT_SUPPORTED; +#else + if (s_state.initialized) { + return ESP_OK; + } + + memset(&s_state, 0, sizeof(s_state)); + s_state.lock = xSemaphoreCreateMutex(); + if (s_state.lock == NULL) { + return ESP_ERR_NO_MEM; + } + set_debug_defaults_locked(); + + esp_err_t err = ESP_OK; + err = config_output_pin(CONFIG_TQ_PRINT_STB_12_PIN, strobe_idle_level_locked(), "print_stb12"); + if (err != ESP_OK) { + return err; + } + err = config_output_pin(CONFIG_TQ_PRINT_STB_34_PIN, strobe_idle_level_locked(), "print_stb34"); + if (err != ESP_OK) { + return err; + } + err = config_output_pin(CONFIG_TQ_PRINT_STB_56_PIN, strobe_idle_level_locked(), "print_stb56"); + if (err != ESP_OK) { + return err; + } + err = config_output_pin(CONFIG_TQ_PRINT_LAT_PIN, 1, "print_lat"); + if (err != ESP_OK) { + return err; + } + err = ensure_shared_shift_pins_gpio_locked(); + if (err != ESP_OK) { + return err; + } + + err = config_output_pin(CONFIG_TQ_PRINT_OUTA_P_PIN, 0, "print_outa_p"); + if (err != ESP_OK) { + return err; + } + err = config_output_pin(CONFIG_TQ_PRINT_OUTA_N_PIN, 0, "print_outa_n"); + if (err != ESP_OK) { + return err; + } + err = config_output_pin(CONFIG_TQ_PRINT_OUTB_P_PIN, 0, "print_outb_p"); + if (err != ESP_OK) { + return err; + } + err = config_output_pin(CONFIG_TQ_PRINT_OUTB_N_PIN, 0, "print_outb_n"); + if (err != ESP_OK) { + return err; + } + err = config_output_pin(CONFIG_TQ_KEY_PRINT_PIN, boost_off_level_locked(), "print_boost"); + if (err != ESP_OK) { + return err; + } + + err = config_input_pin(CONFIG_TQ_PRINT_PAPER_PIN, true, "print_paper"); + if (err != ESP_OK) { + return err; + } + + init_adc_pin_if_possible(CONFIG_TQ_BATTERY_ADC_PIN, "battery_adc", &s_state.battery_adc); + init_adc_pin_if_possible(CONFIG_TQ_NTC_ADC_PIN, "ntc_adc", &s_state.ntc_adc); + + s_state.motor_phase = 0; + s_state.connected = false; + s_state.initialized = true; + ESP_LOGI(TAG, "direct printer initialized"); + return ESP_OK; +#endif +} + +void platform_direct_printer_deinit(void) { + if (!s_state.initialized) { + return; + } + + if (s_state.lock != NULL && xSemaphoreTake(s_state.lock, pdMS_TO_TICKS(100)) == pdTRUE) { + safe_drive_off_locked(false); + s_state.connected = false; + xSemaphoreGive(s_state.lock); + } + + if (s_state.adc1_handle != NULL) { + (void)adc_oneshot_del_unit(s_state.adc1_handle); + s_state.adc1_handle = NULL; + } + if (s_state.adc2_handle != NULL) { + (void)adc_oneshot_del_unit(s_state.adc2_handle); + s_state.adc2_handle = NULL; + } + if (s_state.lock != NULL) { + vSemaphoreDelete(s_state.lock); + } + memset(&s_state, 0, sizeof(s_state)); +} + +esp_err_t platform_direct_printer_connect(uint32_t timeout_ms) { +#if !CONFIG_TQ_DIRECT_PRINTER_ENABLE + (void)timeout_ms; + return ESP_ERR_NOT_SUPPORTED; +#else + esp_err_t err = platform_direct_printer_init(); + if (err != ESP_OK) { + return err; + } + + uint32_t lock_timeout_ms = timeout_ms == 0 ? 500 : timeout_ms; + if (lock_timeout_ms > 5000) { + lock_timeout_ms = 5000; + } + + if (xSemaphoreTake(s_state.lock, pdMS_TO_TICKS(lock_timeout_ms)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + if (!s_state.connected) { + err = ensure_shared_shift_pins_gpio_locked(); + if (err != ESP_OK) { + xSemaphoreGive(s_state.lock); + return err; + } + set_gpio_level_if_valid(CONFIG_TQ_KEY_PRINT_PIN, boost_on_level_locked()); + vTaskDelay(pdMS_TO_TICKS(10)); + safe_drive_off_locked(true); + s_state.connected = true; + } + + xSemaphoreGive(s_state.lock); + return ESP_OK; +#endif +} + +void platform_direct_printer_disconnect(void) { + if (!s_state.initialized || s_state.lock == NULL) { + return; + } + if (xSemaphoreTake(s_state.lock, pdMS_TO_TICKS(500)) != pdTRUE) { + return; + } + safe_drive_off_locked(false); + s_state.connected = false; + xSemaphoreGive(s_state.lock); +} + +bool platform_direct_printer_is_connected(void) { + if (!s_state.initialized || s_state.lock == NULL) { + return false; + } + if (xSemaphoreTake(s_state.lock, pdMS_TO_TICKS(100)) != pdTRUE) { + return false; + } + bool connected = s_state.connected; + xSemaphoreGive(s_state.lock); + return connected; +} + +esp_err_t platform_direct_printer_get_sensors(platform_printer_sensors_t *out_sensors) { + if (out_sensors == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (!s_state.initialized || s_state.lock == NULL) { + return ESP_ERR_INVALID_STATE; + } + if (xSemaphoreTake(s_state.lock, pdMS_TO_TICKS(500)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + sample_sensors_locked(out_sensors); + xSemaphoreGive(s_state.lock); + return ESP_OK; +} + +esp_err_t platform_direct_printer_get_debug_config(uint16_t *out_shift_clock_high_us, + uint16_t *out_shift_clock_low_us, + uint16_t *out_latch_pulse_us, + bool *out_strobe_active_high, + bool *out_boost_active_high, + uint16_t *out_override_strobe_on_us, + uint16_t *out_override_strobe_interval_us, + uint16_t *out_override_motor_step_us, + uint8_t *out_override_steps_per_line) { +#if !CONFIG_TQ_DIRECT_PRINTER_ENABLE + (void)out_shift_clock_high_us; + (void)out_shift_clock_low_us; + (void)out_latch_pulse_us; + (void)out_strobe_active_high; + (void)out_boost_active_high; + (void)out_override_strobe_on_us; + (void)out_override_strobe_interval_us; + (void)out_override_motor_step_us; + (void)out_override_steps_per_line; + return ESP_ERR_NOT_SUPPORTED; +#else + if (out_shift_clock_high_us == NULL || out_shift_clock_low_us == NULL || out_latch_pulse_us == NULL || + out_strobe_active_high == NULL || out_boost_active_high == NULL || out_override_strobe_on_us == NULL || + out_override_strobe_interval_us == NULL || out_override_motor_step_us == NULL || + out_override_steps_per_line == NULL) { + return ESP_ERR_INVALID_ARG; + } + esp_err_t init_rc = platform_direct_printer_init(); + if (init_rc != ESP_OK) { + return init_rc; + } + if (xSemaphoreTake(s_state.lock, pdMS_TO_TICKS(500)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + *out_shift_clock_high_us = s_state.debug.shift_clock_high_us; + *out_shift_clock_low_us = s_state.debug.shift_clock_low_us; + *out_latch_pulse_us = s_state.debug.latch_pulse_us; + *out_strobe_active_high = s_state.debug.strobe_active_high; + *out_boost_active_high = s_state.debug.boost_active_high; + *out_override_strobe_on_us = s_state.debug.override_strobe_on_us; + *out_override_strobe_interval_us = s_state.debug.override_strobe_interval_us; + *out_override_motor_step_us = s_state.debug.override_motor_step_us; + *out_override_steps_per_line = s_state.debug.override_steps_per_line; + xSemaphoreGive(s_state.lock); + return ESP_OK; +#endif +} + +esp_err_t platform_direct_printer_set_debug_config(bool has_config, + uint16_t shift_clock_high_us, + uint16_t shift_clock_low_us, + uint16_t latch_pulse_us, + bool strobe_active_high, + bool boost_active_high, + uint16_t override_strobe_on_us, + uint16_t override_strobe_interval_us, + uint16_t override_motor_step_us, + uint8_t override_steps_per_line, + bool reset_defaults, + char *err, + size_t err_len) { +#if !CONFIG_TQ_DIRECT_PRINTER_ENABLE + (void)has_config; + (void)shift_clock_high_us; + (void)shift_clock_low_us; + (void)latch_pulse_us; + (void)strobe_active_high; + (void)boost_active_high; + (void)override_strobe_on_us; + (void)override_strobe_interval_us; + (void)override_motor_step_us; + (void)override_steps_per_line; + (void)reset_defaults; + write_err(err, err_len, "direct backend disabled"); + return ESP_ERR_NOT_SUPPORTED; +#else + if (!has_config && !reset_defaults) { + write_err(err, err_len, "invalid args"); + return ESP_ERR_INVALID_ARG; + } + + esp_err_t init_rc = platform_direct_printer_init(); + if (init_rc != ESP_OK) { + write_err(err, err_len, "direct backend not initialized"); + return init_rc; + } + if (xSemaphoreTake(s_state.lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + write_err(err, err_len, "direct printer lock timeout"); + return ESP_ERR_TIMEOUT; + } + + if (reset_defaults) { + set_debug_defaults_locked(); + } + + if (has_config) { + if (shift_clock_high_us < DEBUG_SHIFT_CLOCK_US_MIN || shift_clock_high_us > DEBUG_SHIFT_CLOCK_US_MAX || + shift_clock_low_us < DEBUG_SHIFT_CLOCK_US_MIN || shift_clock_low_us > DEBUG_SHIFT_CLOCK_US_MAX) { + xSemaphoreGive(s_state.lock); + write_err(err, err_len, "shift clock us out of range (1..50)"); + return ESP_ERR_INVALID_ARG; + } + if (latch_pulse_us < DEBUG_LATCH_PULSE_US_MIN || latch_pulse_us > DEBUG_LATCH_PULSE_US_MAX) { + xSemaphoreGive(s_state.lock); + write_err(err, err_len, "latch pulse us out of range (1..50)"); + return ESP_ERR_INVALID_ARG; + } + if (override_strobe_on_us != 0 && (override_strobe_on_us < 100 || override_strobe_on_us > 10000)) { + xSemaphoreGive(s_state.lock); + write_err(err, err_len, "strobe_on_us must be 0 or 100..10000"); + return ESP_ERR_INVALID_ARG; + } + if (override_strobe_interval_us > 10000) { + xSemaphoreGive(s_state.lock); + write_err(err, err_len, "strobe_interval_us must be 0..10000"); + return ESP_ERR_INVALID_ARG; + } + if (override_motor_step_us != 0 && (override_motor_step_us < 100 || override_motor_step_us > 20000)) { + xSemaphoreGive(s_state.lock); + write_err(err, err_len, "motor_step_us must be 0 or 100..20000"); + return ESP_ERR_INVALID_ARG; + } + if (override_steps_per_line > 8) { + xSemaphoreGive(s_state.lock); + write_err(err, err_len, "steps_per_line must be 0..8"); + return ESP_ERR_INVALID_ARG; + } + + s_state.debug.shift_clock_high_us = shift_clock_high_us; + s_state.debug.shift_clock_low_us = shift_clock_low_us; + s_state.debug.latch_pulse_us = latch_pulse_us; + s_state.debug.strobe_active_high = strobe_active_high; + s_state.debug.boost_active_high = boost_active_high; + s_state.debug.override_strobe_on_us = override_strobe_on_us; + s_state.debug.override_strobe_interval_us = override_strobe_interval_us; + s_state.debug.override_motor_step_us = override_motor_step_us; + s_state.debug.override_steps_per_line = override_steps_per_line; + } + + safe_drive_off_locked(s_state.connected); + xSemaphoreGive(s_state.lock); + return ESP_OK; +#endif +} + +esp_err_t platform_direct_printer_print(const platform_direct_print_request_t *request, + char *err, + size_t err_len) { +#if !CONFIG_TQ_DIRECT_PRINTER_ENABLE + write_err(err, err_len, "direct backend disabled"); + return ESP_ERR_NOT_SUPPORTED; +#else + if (request == NULL || request->raster == NULL || request->width == 0 || request->height == 0) { + write_err(err, err_len, "invalid args"); + return ESP_ERR_INVALID_ARG; + } + if (request->width != 384) { + write_err(err, err_len, "width must be 384"); + return ESP_ERR_INVALID_ARG; + } + + const size_t line_bytes = (size_t)request->width / 8u; + const size_t expected_len = line_bytes * (size_t)request->height; + if (request->raster_len != expected_len) { + write_err(err, err_len, "raster size mismatch"); + return ESP_ERR_INVALID_SIZE; + } + + if (!s_state.initialized || s_state.lock == NULL) { + write_err(err, err_len, "direct backend not initialized"); + return ESP_ERR_INVALID_STATE; + } + + uint32_t timeout_ms = request->timeout_ms; + if (timeout_ms == 0) { + timeout_ms = runtime_policy_direct_printer_operation_timeout_ms(); + } + + if (xSemaphoreTake(s_state.lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + write_err(err, err_len, "direct printer lock timeout"); + return ESP_ERR_TIMEOUT; + } + + if (!s_state.connected) { + xSemaphoreGive(s_state.lock); + write_err(err, err_len, "direct printer not connected"); + return ESP_ERR_INVALID_STATE; + } + + esp_err_t rc = ensure_shared_shift_pins_gpio_locked(); + if (rc != ESP_OK) { + safe_drive_off_locked(true); + xSemaphoreGive(s_state.lock); + write_err(err, err_len, "direct printer gpio setup failed"); + return rc; + } + + uint16_t strobe_on_us = (request->strobe_on_time_us > 0) + ? request->strobe_on_time_us + : runtime_policy_direct_printer_strobe_on_us(); + uint16_t strobe_interval_us = (request->strobe_interval_us > 0) + ? request->strobe_interval_us + : runtime_policy_direct_printer_strobe_interval_us(); + uint16_t motor_step_us = (request->motor_step_delay_us > 0) + ? request->motor_step_delay_us + : runtime_policy_direct_printer_motor_step_us(); + uint8_t steps_per_line = (request->motor_steps_per_line > 0) + ? request->motor_steps_per_line + : runtime_policy_direct_printer_steps_per_line(); + + if (s_state.debug.override_strobe_on_us > 0) { + strobe_on_us = s_state.debug.override_strobe_on_us; + } + if (s_state.debug.override_strobe_interval_us > 0) { + strobe_interval_us = s_state.debug.override_strobe_interval_us; + } + if (s_state.debug.override_motor_step_us > 0) { + motor_step_us = s_state.debug.override_motor_step_us; + } + if (s_state.debug.override_steps_per_line > 0) { + steps_per_line = s_state.debug.override_steps_per_line; + } + + rc = ESP_OK; + if (!request->ignore_precheck) { + rc = precheck_before_print_locked(err, err_len); + if (rc != ESP_OK) { + safe_drive_off_locked(true); + xSemaphoreGive(s_state.lock); + return rc; + } + } + + int64_t deadline_ms = (int64_t)(esp_timer_get_time() / 1000) + (int64_t)timeout_ms; + + for (uint16_t line = 0; line < request->height; ++line) { + if (request->cancel_flag != NULL && *request->cancel_flag) { + write_err(err, err_len, "job canceled"); + rc = ESP_ERR_INVALID_STATE; + break; + } + if (has_timed_out(deadline_ms)) { + write_err(err, err_len, "print timeout"); + rc = ESP_ERR_TIMEOUT; + break; + } + + if (!request->ignore_precheck && (line & 0x0F) == 0) { + rc = precheck_before_print_locked(err, err_len); + if (rc != ESP_OK) { + break; + } + } + + const uint8_t *line_ptr = &request->raster[(size_t)line * line_bytes]; + write_line_bits_locked(line_ptr, line_bytes); + pulse_latch_locked(); + fire_strobe_locked(strobe_on_us, strobe_interval_us); + + for (uint8_t i = 0; i < steps_per_line; ++i) { + if (request->cancel_flag != NULL && *request->cancel_flag) { + write_err(err, err_len, "job canceled"); + rc = ESP_ERR_INVALID_STATE; + break; + } + if (has_timed_out(deadline_ms)) { + write_err(err, err_len, "print timeout"); + rc = ESP_ERR_TIMEOUT; + break; + } + motor_step_once_locked(motor_step_us); + } + if (rc != ESP_OK) { + break; + } + + // Yield periodically so IDLE task can run and service task WDT on the same CPU. + if (((line + 1u) % k_print_yield_lines) == 0u) { + vTaskDelay(1); + } + } + + safe_drive_off_locked(true); + xSemaphoreGive(s_state.lock); + return rc; +#endif +} + +esp_err_t platform_direct_printer_gap_move(uint32_t timeout_ms, char *err, size_t err_len) { +#if !CONFIG_TQ_DIRECT_PRINTER_ENABLE + write_err(err, err_len, "direct backend disabled"); + return ESP_ERR_NOT_SUPPORTED; +#else + if (!s_state.initialized || s_state.lock == NULL) { + write_err(err, err_len, "direct backend not initialized"); + return ESP_ERR_INVALID_STATE; + } + if (timeout_ms == 0) { + timeout_ms = runtime_policy_direct_printer_operation_timeout_ms(); + } + + if (xSemaphoreTake(s_state.lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + write_err(err, err_len, "direct printer lock timeout"); + return ESP_ERR_TIMEOUT; + } + if (!s_state.connected) { + xSemaphoreGive(s_state.lock); + write_err(err, err_len, "direct printer not connected"); + return ESP_ERR_INVALID_STATE; + } + + esp_err_t rc = ESP_OK; + uint16_t steps = runtime_policy_direct_printer_gap_steps(); + uint16_t step_delay_us = runtime_policy_direct_printer_motor_step_us(); + if (s_state.debug.override_motor_step_us > 0) { + step_delay_us = s_state.debug.override_motor_step_us; + } + platform_printer_sensors_t sensors = {0}; + sample_sensors_locked(&sensors); + + uint8_t battery_min = runtime_policy_direct_printer_battery_min_percent(); + if (sensors.battery_percent < battery_min) { + write_err(err, err_len, "battery too low"); + safe_drive_off_locked(true); + xSemaphoreGive(s_state.lock); + return ESP_ERR_INVALID_STATE; + } + + // Per JX-2R-01 guidance: out-of-paper feed should run at reduced speed (roughly <=600 PPS). + if (!sensors.has_paper && step_delay_us < 1667) { + step_delay_us = 1667; + } + if (!sensors.has_paper) { + ESP_LOGW(TAG, + "gap move while out-of-paper, gpio_level=%d expect=%u step_us=%u", + sensors.paper_gpio_level, + sensors.paper_present_level, + step_delay_us); + } + + int64_t deadline_ms = (int64_t)(esp_timer_get_time() / 1000) + (int64_t)timeout_ms; + + for (uint16_t i = 0; i < steps; ++i) { + if (has_timed_out(deadline_ms)) { + write_err(err, err_len, "gap move timeout"); + rc = ESP_ERR_TIMEOUT; + break; + } + motor_step_once_locked(step_delay_us); + if (((i + 1u) % k_gap_yield_steps) == 0u) { + vTaskDelay(1); + } + } + + safe_drive_off_locked(true); + xSemaphoreGive(s_state.lock); + return rc; +#endif +} diff --git a/components/platform/src/display_lcd_decode_image.c b/components/platform/src/display_lcd_decode_image.c new file mode 100644 index 0000000..25c3eb4 --- /dev/null +++ b/components/platform/src/display_lcd_decode_image.c @@ -0,0 +1,139 @@ +#include "display_lcd_decode_image.h" + +#include +#include +#include +#include + +#include "esp_check.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "lvgl.h" +#include "png.h" + +static const char *TAG = "lcd_png_dec"; + +extern const uint8_t lcd_demo_png_start[] asm("_binary_lcd_demo_png_start"); +extern const uint8_t lcd_demo_png_end[] asm("_binary_lcd_demo_png_end"); + +static void lcd_fill_err(char *err, size_t err_len, const char *msg) +{ + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "%s", msg != NULL ? msg : "unknown"); + } +} + +esp_err_t platform_lcd_decode_png_rgb565(const uint8_t *png, + size_t png_len, + uint16_t **out_pixels, + uint16_t *out_width, + uint16_t *out_height, + char *err, + size_t err_len) +{ + esp_err_t ret = ESP_OK; + + ESP_RETURN_ON_FALSE(out_pixels != NULL && out_width != NULL && out_height != NULL, + ESP_ERR_INVALID_ARG, + TAG, + "invalid output args"); + ESP_RETURN_ON_FALSE(png != NULL && png_len > 0, ESP_ERR_INVALID_ARG, TAG, "invalid png data"); + *out_pixels = NULL; + *out_width = 0; + *out_height = 0; + + png_image image; + memset(&image, 0, sizeof(image)); + image.version = PNG_IMAGE_VERSION; + + if (!png_image_begin_read_from_memory(&image, png, png_len)) { + const char *png_err = image.message[0] != '\0' ? image.message : "png header decode failed"; + lcd_fill_err(err, err_len, png_err); + return ESP_FAIL; + } + ESP_GOTO_ON_FALSE(image.width > 0 && image.height > 0, ESP_ERR_INVALID_SIZE, fail, TAG, "invalid png size"); + ESP_GOTO_ON_FALSE(image.width <= UINT16_MAX && image.height <= UINT16_MAX, + ESP_ERR_INVALID_SIZE, + fail, + TAG, + "png dimensions too large"); + ESP_GOTO_ON_FALSE(image.width <= SIZE_MAX / image.height, ESP_ERR_INVALID_SIZE, fail, TAG, "png too large"); + + image.format = PNG_FORMAT_RGBA; + png_alloc_size_t rgba_size = PNG_IMAGE_SIZE(image); + ESP_GOTO_ON_FALSE(rgba_size > 0, ESP_ERR_INVALID_SIZE, fail, TAG, "png output size invalid"); + + uint8_t *rgba = (uint8_t *)heap_caps_malloc((size_t)rgba_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (rgba == NULL) { + rgba = (uint8_t *)heap_caps_malloc((size_t)rgba_size, MALLOC_CAP_8BIT); + } + ESP_GOTO_ON_FALSE(rgba != NULL, ESP_ERR_NO_MEM, fail, TAG, "alloc rgba buffer failed"); + + png_alloc_size_t stride = PNG_IMAGE_ROW_STRIDE(image); + if (!png_image_finish_read(&image, NULL, rgba, stride, NULL)) { + const char *png_err = image.message[0] != '\0' ? image.message : "png decode failed"; + lcd_fill_err(err, err_len, png_err); + heap_caps_free(rgba); + png_image_free(&image); + return ESP_FAIL; + } + png_image_free(&image); + + size_t pixel_count = (size_t)image.width * (size_t)image.height; + uint16_t *pixels = (uint16_t *)heap_caps_malloc(pixel_count * sizeof(uint16_t), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (pixels == NULL) { + pixels = (uint16_t *)heap_caps_malloc(pixel_count * sizeof(uint16_t), MALLOC_CAP_8BIT); + } + if (pixels == NULL) { + lcd_fill_err(err, err_len, "alloc rgb565 buffer failed"); + heap_caps_free(rgba); + return ESP_ERR_NO_MEM; + } + + for (size_t i = 0; i < pixel_count; ++i) { + const size_t base = i * 4u; + uint8_t r = rgba[base]; + uint8_t g = rgba[base + 1u]; + uint8_t b = rgba[base + 2u]; + uint8_t a = rgba[base + 3u]; + if (a < 255u) { + r = (uint8_t)((((uint32_t)r * (uint32_t)a) + (255u * (255u - (uint32_t)a)) + 127u) / 255u); + g = (uint8_t)((((uint32_t)g * (uint32_t)a) + (255u * (255u - (uint32_t)a)) + 127u) / 255u); + b = (uint8_t)((((uint32_t)b * (uint32_t)a) + (255u * (255u - (uint32_t)a)) + 127u) / 255u); + } + lv_color_t color = lv_color_make(r, g, b); + pixels[i] = (uint16_t)color.full; + } + heap_caps_free(rgba); + + *out_pixels = pixels; + *out_width = (uint16_t)image.width; + *out_height = (uint16_t)image.height; + return ESP_OK; + +fail: + lcd_fill_err(err, err_len, "invalid png"); + png_image_free(&image); + if (ret == ESP_OK) { + ret = ESP_FAIL; + } + return ret; +} + +esp_err_t platform_lcd_decode_demo_png(uint16_t **out_pixels, + uint16_t *out_width, + uint16_t *out_height, + char *err, + size_t err_len) +{ + const size_t png_len = (size_t)(lcd_demo_png_end - lcd_demo_png_start); + ESP_RETURN_ON_FALSE(png_len > 0, ESP_ERR_INVALID_SIZE, TAG, "empty png data"); + return platform_lcd_decode_png_rgb565(lcd_demo_png_start, + png_len, + out_pixels, + out_width, + out_height, + err, + err_len); +} diff --git a/components/platform/src/display_lcd_module.c b/components/platform/src/display_lcd_module.c new file mode 100644 index 0000000..b106f52 --- /dev/null +++ b/components/platform/src/display_lcd_module.c @@ -0,0 +1,904 @@ +#include "display_lcd_module.h" + +#include +#include +#include +#include +#include + +#include "esp_check.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "lvgl.h" +#include "sdkconfig.h" +#include "display_lcd_decode_image.h" +#include "display_st7789p3.h" + +static const char *TAG = "lcd_module"; + +#define ST7789_SPI_HOST SPI2_HOST +#define LCD_PREVIEW_TIMEOUT_MS_DEFAULT 200 +#define LCD_LOCK_TIMEOUT_MS 1000 +#define LCD_DEFAULT_DRAW_LINES 20 +#define LCD_PREVIEW_FADE_DURATION_MS 150 +#define LCD_PREVIEW_FADE_STEPS 3 + +#ifndef CONFIG_TQ_SCREEN_PIXEL_CLOCK_HZ +#define CONFIG_TQ_SCREEN_PIXEL_CLOCK_HZ (10 * 1000 * 1000) +#endif + +#ifndef CONFIG_TQ_SCREEN_H_RES +#define CONFIG_TQ_SCREEN_H_RES 320 +#endif + +#ifndef CONFIG_TQ_SCREEN_V_RES +#define CONFIG_TQ_SCREEN_V_RES 240 +#endif + +#ifndef CONFIG_TQ_SCREEN_SPI_MODE +#define CONFIG_TQ_SCREEN_SPI_MODE 0 +#endif + +#ifndef CONFIG_TQ_SCREEN_DRAW_LINES +#define CONFIG_TQ_SCREEN_DRAW_LINES LCD_DEFAULT_DRAW_LINES +#endif + +#ifndef CONFIG_TQ_SCREEN_COLOR_ORDER_BGR +#define CONFIG_TQ_SCREEN_COLOR_ORDER_BGR 0 +#endif + +#ifndef CONFIG_TQ_SCREEN_BACKLIGHT_ACTIVE_HIGH +#define CONFIG_TQ_SCREEN_BACKLIGHT_ACTIVE_HIGH 1 +#endif + +#ifndef CONFIG_TQ_SCREEN_MIRROR_X +#define CONFIG_TQ_SCREEN_MIRROR_X 1 +#endif + +#ifndef CONFIG_TQ_SCREEN_MIRROR_Y +#define CONFIG_TQ_SCREEN_MIRROR_Y 0 +#endif + +#ifndef CONFIG_TQ_SCREEN_SWAP_XY +#define CONFIG_TQ_SCREEN_SWAP_XY 1 +#endif + +#ifndef CONFIG_TQ_SCREEN_X_GAP +#define CONFIG_TQ_SCREEN_X_GAP 0 +#endif + +#ifndef CONFIG_TQ_SCREEN_Y_GAP +#define CONFIG_TQ_SCREEN_Y_GAP 0 +#endif + +#ifndef CONFIG_TQ_SCREEN_INVERT_COLOR +#define CONFIG_TQ_SCREEN_INVERT_COLOR 1 +#endif + +#ifndef CONFIG_TQ_SCREEN_SWAP_COLOR_BYTES +#define CONFIG_TQ_SCREEN_SWAP_COLOR_BYTES 1 +#endif + +#ifndef CONFIG_TQ_SCREEN_RESET_PIN +#define CONFIG_TQ_SCREEN_RESET_PIN -1 +#endif + +#ifndef CONFIG_TQ_SCREEN_BACKLIGHT_PIN +#define CONFIG_TQ_SCREEN_BACKLIGHT_PIN -1 +#endif + +#define LCD_PANEL_H_RES ((uint16_t)CONFIG_TQ_SCREEN_H_RES) +#define LCD_PANEL_V_RES ((uint16_t)CONFIG_TQ_SCREEN_V_RES) + +#if CONFIG_TQ_SCREEN_BACKLIGHT_ACTIVE_HIGH +#define ST7789_BACKLIGHT_ON_LEVEL 1 +#else +#define ST7789_BACKLIGHT_ON_LEVEL 0 +#endif + +typedef struct { + st7789p3_handle_t panel; + SemaphoreHandle_t lock; + lv_disp_draw_buf_t lv_draw_buf; + lv_disp_drv_t lv_drv; + lv_disp_t *lv_disp; + lv_obj_t *preview_img; + lv_color_t *draw_buf_1; + lv_color_t *draw_buf_2; + uint16_t *preview_pixels; + lv_img_dsc_t preview_dsc; + bool lvgl_inited; + bool lvgl_ready; + bool initialized; + bool shared_spi_dirty; +} lcd_state_t; + +static lcd_state_t s_lcd; + +void lcd_module_mark_shared_spi_dirty(void) +{ + s_lcd.shared_spi_dirty = true; +} + +static int resolve_reset_pin(void) +{ + if (CONFIG_TQ_SCREEN_RESET_PIN >= 0) { + return CONFIG_TQ_SCREEN_RESET_PIN; + } + return CONFIG_TQ_SCREEN_BACKLIGHT_PIN; +} + +static uint8_t lcd_build_madctl(void) +{ + uint8_t madctl = 0; +#if CONFIG_TQ_SCREEN_MIRROR_X + madctl |= ST7789P3_MADCTL_MX; +#endif +#if CONFIG_TQ_SCREEN_MIRROR_Y + madctl |= ST7789P3_MADCTL_MY; +#endif +#if CONFIG_TQ_SCREEN_SWAP_XY + madctl |= ST7789P3_MADCTL_MV; +#endif +#if CONFIG_TQ_SCREEN_COLOR_ORDER_BGR + madctl |= ST7789P3_MADCTL_BGR; +#endif + return madctl; +} + +static void lcd_fill_err(char *err, size_t err_len, const char *msg) +{ + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "%s", msg != NULL ? msg : "unknown"); + } +} + +static bool lcd_lock_take(uint32_t timeout_ms) +{ + if (s_lcd.lock == NULL) { + return false; + } + TickType_t ticks = pdMS_TO_TICKS(timeout_ms); + if (timeout_ms > 0 && ticks == 0) { + ticks = 1; + } + return xSemaphoreTake(s_lcd.lock, ticks) == pdTRUE; +} + +static void lcd_lock_give(void) +{ + if (s_lcd.lock != NULL) { + xSemaphoreGive(s_lcd.lock); + } +} + +static void *lcd_alloc_fb(size_t bytes) +{ + void *buf = heap_caps_malloc(bytes, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); + if (buf == NULL) { + buf = heap_caps_malloc(bytes, MALLOC_CAP_8BIT); + } + return buf; +} + +static void lcd_free_lvgl_buffers(void) +{ + if (s_lcd.draw_buf_1 != NULL) { + heap_caps_free(s_lcd.draw_buf_1); + s_lcd.draw_buf_1 = NULL; + } + if (s_lcd.draw_buf_2 != NULL) { + heap_caps_free(s_lcd.draw_buf_2); + s_lcd.draw_buf_2 = NULL; + } +} + +static esp_err_t lcd_recover_panel_for_shared_spi(void) +{ + if (!s_lcd.initialized || s_lcd.panel == NULL) { + return lcd_module_init(); + } + + if (!s_lcd.shared_spi_dirty) { + return ESP_OK; + } + + /* + * Direct-printer path temporarily takes over shared MOSI/CLK as GPIO. + * Recover LCD SPI routing only when printer touched shared pins. + */ + esp_err_t recover_rc = st7789p3_recover_spi_bus(s_lcd.panel); + if (recover_rc == ESP_OK) { + s_lcd.shared_spi_dirty = false; + return ESP_OK; + } + + ESP_LOGW(TAG, + "fast SPI recover failed: rc=0x%x, fallback to full panel reinit", + (unsigned)recover_rc); + + lcd_module_deinit(); + return lcd_module_init(); +} + +static void lcd_lvgl_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_map) +{ + st7789p3_handle_t panel = (st7789p3_handle_t)disp_drv->user_data; + if (panel == NULL || area == NULL || color_map == NULL) { + lv_disp_flush_ready(disp_drv); + return; + } + if (area->x1 < 0 || area->y1 < 0 || area->x2 < area->x1 || area->y2 < area->y1) { + lv_disp_flush_ready(disp_drv); + return; + } + + esp_err_t err = st7789p3_draw_bitmap(panel, + (uint16_t)area->x1, + (uint16_t)area->y1, + (uint16_t)(area->x2 + 1), + (uint16_t)(area->y2 + 1), + (const uint16_t *)color_map); + if (err != ESP_OK) { + ESP_LOGW(TAG, "lvgl flush failed: %s", esp_err_to_name(err)); + } + lv_disp_flush_ready(disp_drv); +} + +static esp_err_t lcd_lvgl_init_locked(void) +{ + if (s_lcd.lvgl_ready) { + return ESP_OK; + } + + if (!s_lcd.lvgl_inited) { + lv_init(); + s_lcd.lvgl_inited = true; + } + + const size_t draw_buf_pixels = LCD_PANEL_H_RES * CONFIG_TQ_SCREEN_DRAW_LINES; + const size_t draw_buf_bytes = draw_buf_pixels * sizeof(lv_color_t); + s_lcd.draw_buf_1 = (lv_color_t *)lcd_alloc_fb(draw_buf_bytes); + if (s_lcd.draw_buf_1 == NULL) { + return ESP_ERR_NO_MEM; + } + s_lcd.draw_buf_2 = (lv_color_t *)lcd_alloc_fb(draw_buf_bytes); + if (s_lcd.draw_buf_2 == NULL) { + lcd_free_lvgl_buffers(); + return ESP_ERR_NO_MEM; + } + + lv_disp_draw_buf_init(&s_lcd.lv_draw_buf, s_lcd.draw_buf_1, s_lcd.draw_buf_2, draw_buf_pixels); + + lv_disp_drv_init(&s_lcd.lv_drv); + s_lcd.lv_drv.hor_res = LCD_PANEL_H_RES; + s_lcd.lv_drv.ver_res = LCD_PANEL_V_RES; + s_lcd.lv_drv.flush_cb = lcd_lvgl_flush_cb; + s_lcd.lv_drv.draw_buf = &s_lcd.lv_draw_buf; + s_lcd.lv_drv.user_data = s_lcd.panel; + s_lcd.lv_disp = lv_disp_drv_register(&s_lcd.lv_drv); + if (s_lcd.lv_disp == NULL) { + lcd_free_lvgl_buffers(); + ESP_LOGE(TAG, "lvgl display register failed"); + return ESP_FAIL; + } + + lv_obj_t *screen = lv_scr_act(); + lv_obj_set_style_bg_color(screen, lv_color_black(), LV_PART_MAIN); + lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_clear_flag(screen, LV_OBJ_FLAG_SCROLLABLE); + + s_lcd.preview_img = lv_img_create(screen); + lv_obj_add_flag(s_lcd.preview_img, LV_OBJ_FLAG_HIDDEN); + lv_obj_center(s_lcd.preview_img); + + s_lcd.lvgl_ready = true; + lv_refr_now(s_lcd.lv_disp); + return ESP_OK; +} + +static void lcd_calc_preview_size(uint16_t src_w, uint16_t src_h, uint16_t *out_w, uint16_t *out_h) +{ + if (src_w == 0 || src_h == 0 || out_w == NULL || out_h == NULL) { + return; + } + + if (src_w <= LCD_PANEL_H_RES && src_h <= LCD_PANEL_V_RES) { + *out_w = src_w; + *out_h = src_h; + return; + } + + if ((uint64_t)src_w * LCD_PANEL_V_RES > (uint64_t)src_h * LCD_PANEL_H_RES) { + *out_w = LCD_PANEL_H_RES; + *out_h = (uint16_t)(((uint32_t)src_h * LCD_PANEL_H_RES) / src_w); + } else { + *out_h = LCD_PANEL_V_RES; + *out_w = (uint16_t)(((uint32_t)src_w * LCD_PANEL_V_RES) / src_h); + } + if (*out_w == 0) { + *out_w = 1; + } + if (*out_h == 0) { + *out_h = 1; + } +} + +static esp_err_t lcd_render_raster_to_rgb565(const uint8_t *raster, + size_t raster_len, + uint16_t src_w, + uint16_t src_h, + uint16_t **out_pixels, + uint16_t *out_w, + uint16_t *out_h, + char *err, + size_t err_len) +{ + if (raster == NULL || src_w == 0 || src_h == 0 || out_pixels == NULL || out_w == NULL || out_h == NULL) { + lcd_fill_err(err, err_len, "invalid raster"); + return ESP_ERR_INVALID_ARG; + } + + size_t bytes_per_row = ((size_t)src_w + 7u) / 8u; + size_t needed_len = bytes_per_row * src_h; + if (raster_len < needed_len) { + lcd_fill_err(err, err_len, "raster length mismatch"); + return ESP_ERR_INVALID_SIZE; + } + + lcd_calc_preview_size(src_w, src_h, out_w, out_h); + size_t pixel_count = (size_t)(*out_w) * (size_t)(*out_h); + if (pixel_count == 0 || pixel_count > SIZE_MAX / sizeof(uint16_t)) { + lcd_fill_err(err, err_len, "preview size invalid"); + return ESP_ERR_INVALID_SIZE; + } + + uint16_t *pixels = (uint16_t *)heap_caps_malloc(pixel_count * sizeof(uint16_t), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (pixels == NULL) { + pixels = (uint16_t *)heap_caps_malloc(pixel_count * sizeof(uint16_t), MALLOC_CAP_8BIT); + } + if (pixels == NULL) { + lcd_fill_err(err, err_len, "no memory"); + return ESP_ERR_NO_MEM; + } + + for (uint16_t y = 0; y < *out_h; ++y) { + uint16_t src_y = (uint16_t)(((uint32_t)y * src_h) / (*out_h)); + size_t row_base = (size_t)src_y * bytes_per_row; + size_t out_row = (size_t)y * (*out_w); + for (uint16_t x = 0; x < *out_w; ++x) { + uint16_t src_x = (uint16_t)(((uint32_t)x * src_w) / (*out_w)); + size_t byte_idx = row_base + (src_x / 8u); + uint8_t bit = (uint8_t)(7u - (src_x % 8u)); + bool is_black = (raster[byte_idx] & (uint8_t)(1u << bit)) != 0; + pixels[out_row + x] = is_black ? 0x0000 : 0xFFFF; + } + } + + *out_pixels = pixels; + return ESP_OK; +} + +static esp_err_t lcd_scale_rgb565_to_preview(const uint16_t *src_pixels, + uint16_t src_w, + uint16_t src_h, + uint16_t **out_pixels, + uint16_t *out_w, + uint16_t *out_h, + char *err, + size_t err_len) +{ + if (src_pixels == NULL || src_w == 0 || src_h == 0 || out_pixels == NULL || out_w == NULL || out_h == NULL) { + lcd_fill_err(err, err_len, "invalid rgb565 image"); + return ESP_ERR_INVALID_ARG; + } + + *out_pixels = NULL; + *out_w = 0; + *out_h = 0; + lcd_calc_preview_size(src_w, src_h, out_w, out_h); + size_t pixel_count = (size_t)(*out_w) * (size_t)(*out_h); + if (pixel_count == 0 || pixel_count > SIZE_MAX / sizeof(uint16_t)) { + lcd_fill_err(err, err_len, "preview size invalid"); + return ESP_ERR_INVALID_SIZE; + } + + uint16_t *preview = (uint16_t *)heap_caps_malloc(pixel_count * sizeof(uint16_t), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (preview == NULL) { + preview = (uint16_t *)heap_caps_malloc(pixel_count * sizeof(uint16_t), MALLOC_CAP_8BIT); + } + if (preview == NULL) { + lcd_fill_err(err, err_len, "no memory"); + return ESP_ERR_NO_MEM; + } + + if (*out_w == src_w && *out_h == src_h) { + memcpy(preview, src_pixels, pixel_count * sizeof(uint16_t)); + *out_pixels = preview; + return ESP_OK; + } + + for (uint16_t y = 0; y < *out_h; ++y) { + uint16_t src_y = (uint16_t)(((uint32_t)y * src_h) / (*out_h)); + size_t src_row = (size_t)src_y * src_w; + size_t dst_row = (size_t)y * (*out_w); + for (uint16_t x = 0; x < *out_w; ++x) { + uint16_t src_x = (uint16_t)(((uint32_t)x * src_w) / (*out_w)); + preview[dst_row + x] = src_pixels[src_row + src_x]; + } + } + + *out_pixels = preview; + return ESP_OK; +} + +static uint16_t lcd_blend_rgb565(uint16_t from, uint16_t to, uint8_t alpha_255) +{ + uint32_t inv = (uint32_t)(255u - alpha_255); + + uint32_t fr = (from >> 11) & 0x1Fu; + uint32_t fg = (from >> 5) & 0x3Fu; + uint32_t fb = from & 0x1Fu; + + uint32_t tr = (to >> 11) & 0x1Fu; + uint32_t tg = (to >> 5) & 0x3Fu; + uint32_t tb = to & 0x1Fu; + + uint32_t r = (fr * inv + tr * alpha_255 + 127u) / 255u; + uint32_t g = (fg * inv + tg * alpha_255 + 127u) / 255u; + uint32_t b = (fb * inv + tb * alpha_255 + 127u) / 255u; + + return (uint16_t)((r << 11) | (g << 5) | b); +} + +static esp_err_t lcd_draw_preview_pixels_locked(const uint16_t *pixels, + uint16_t preview_w, + uint16_t preview_h, + char *err, + size_t err_len) +{ + if (s_lcd.panel == NULL || pixels == NULL) { + lcd_fill_err(err, err_len, "display panel not ready"); + return ESP_ERR_INVALID_STATE; + } + if (preview_w == 0 || preview_h == 0 || preview_w > LCD_PANEL_H_RES || preview_h > LCD_PANEL_V_RES) { + lcd_fill_err(err, err_len, "invalid preview size"); + return ESP_ERR_INVALID_SIZE; + } + + uint16_t x = (uint16_t)((LCD_PANEL_H_RES - preview_w) / 2u); + uint16_t y = (uint16_t)((LCD_PANEL_V_RES - preview_h) / 2u); + esp_err_t draw_rc = st7789p3_draw_bitmap(s_lcd.panel, + x, + y, + (uint16_t)(x + preview_w), + (uint16_t)(y + preview_h), + pixels); + if (draw_rc != ESP_OK) { + lcd_fill_err(err, err_len, "display draw failed"); + return draw_rc; + } + return ESP_OK; +} + +static esp_err_t lcd_apply_preview_locked(const uint16_t *prev_pixels, + uint16_t prev_w, + uint16_t prev_h, + bool clear_before_draw, + lv_color_t bg_color, + char *err, + size_t err_len) +{ + if (s_lcd.panel == NULL || s_lcd.preview_pixels == NULL) { + lcd_fill_err(err, err_len, "display panel not ready"); + return ESP_ERR_INVALID_STATE; + } + + uint16_t preview_w = s_lcd.preview_dsc.header.w; + uint16_t preview_h = s_lcd.preview_dsc.header.h; + if (preview_w == 0 || preview_h == 0 || preview_w > LCD_PANEL_H_RES || preview_h > LCD_PANEL_V_RES) { + lcd_fill_err(err, err_len, "invalid preview size"); + return ESP_ERR_INVALID_SIZE; + } + + if (clear_before_draw) { + esp_err_t clear_rc = st7789p3_fill_color(s_lcd.panel, (uint16_t)bg_color.full); + if (clear_rc != ESP_OK) { + lcd_fill_err(err, err_len, "display clear failed"); + return clear_rc; + } + esp_err_t draw_rc = lcd_draw_preview_pixels_locked(s_lcd.preview_pixels, preview_w, preview_h, err, err_len); + if (draw_rc == ESP_OK) { + ESP_LOGI(TAG, "preview drawn directly: size=%ux%u", (unsigned)preview_w, (unsigned)preview_h); + } + return draw_rc; + } + + bool can_fade = (prev_pixels != NULL && prev_w == preview_w && prev_h == preview_h); + if (!can_fade) { + esp_err_t draw_rc = lcd_draw_preview_pixels_locked(s_lcd.preview_pixels, preview_w, preview_h, err, err_len); + if (draw_rc == ESP_OK) { + ESP_LOGI(TAG, "preview drawn directly: size=%ux%u", (unsigned)preview_w, (unsigned)preview_h); + } + return draw_rc; + } + + size_t pixel_count = (size_t)preview_w * (size_t)preview_h; + uint16_t *fade_pixels = (uint16_t *)heap_caps_malloc(pixel_count * sizeof(uint16_t), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (fade_pixels == NULL) { + fade_pixels = (uint16_t *)heap_caps_malloc(pixel_count * sizeof(uint16_t), MALLOC_CAP_8BIT); + } + if (fade_pixels == NULL) { + // Fallback to direct draw when memory is tight. + esp_err_t draw_rc = lcd_draw_preview_pixels_locked(s_lcd.preview_pixels, preview_w, preview_h, err, err_len); + if (draw_rc == ESP_OK) { + ESP_LOGI(TAG, "preview drawn directly (fade fallback): size=%ux%u", (unsigned)preview_w, (unsigned)preview_h); + } + return draw_rc; + } + + const uint32_t frame_count = LCD_PREVIEW_FADE_STEPS + 1u; + uint32_t frame_delay_ms = LCD_PREVIEW_FADE_DURATION_MS / frame_count; + if (frame_delay_ms == 0) { + frame_delay_ms = 1; + } + + for (uint32_t step = 1; step <= LCD_PREVIEW_FADE_STEPS; ++step) { + uint8_t alpha = (uint8_t)((step * 255u) / frame_count); + for (size_t i = 0; i < pixel_count; ++i) { + fade_pixels[i] = lcd_blend_rgb565(prev_pixels[i], s_lcd.preview_pixels[i], alpha); + } + + esp_err_t draw_rc = lcd_draw_preview_pixels_locked(fade_pixels, preview_w, preview_h, err, err_len); + if (draw_rc != ESP_OK) { + heap_caps_free(fade_pixels); + return draw_rc; + } + vTaskDelay(pdMS_TO_TICKS(frame_delay_ms)); + } + + heap_caps_free(fade_pixels); + esp_err_t draw_rc = lcd_draw_preview_pixels_locked(s_lcd.preview_pixels, preview_w, preview_h, err, err_len); + if (draw_rc == ESP_OK) { + ESP_LOGI(TAG, "preview drawn with fade: size=%ux%u fade_ms=%u", + (unsigned)preview_w, + (unsigned)preview_h, + (unsigned)LCD_PREVIEW_FADE_DURATION_MS); + } + return draw_rc; +} + +void lcd_module_deinit(void) +{ +#if CONFIG_TQ_SCREEN_ENABLE + if (s_lcd.lock != NULL) { + if (xSemaphoreTake(s_lcd.lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + return; + } + } + + if (s_lcd.lv_disp != NULL) { + lv_disp_remove(s_lcd.lv_disp); + s_lcd.lv_disp = NULL; + } + if (s_lcd.preview_pixels != NULL) { + heap_caps_free(s_lcd.preview_pixels); + s_lcd.preview_pixels = NULL; + } + lcd_free_lvgl_buffers(); + s_lcd.preview_img = NULL; + s_lcd.lvgl_ready = false; + + if (s_lcd.lock != NULL) { + xSemaphoreGive(s_lcd.lock); + vSemaphoreDelete(s_lcd.lock); + s_lcd.lock = NULL; + } + + if (s_lcd.panel != NULL) { + (void)st7789p3_del(s_lcd.panel); + s_lcd.panel = NULL; + } + s_lcd.initialized = false; +#endif +} + +esp_err_t lcd_module_init(void) +{ +#if !CONFIG_TQ_SCREEN_ENABLE + return ESP_ERR_NOT_SUPPORTED; +#else + if (s_lcd.initialized) { + return ESP_OK; + } + + st7789p3_config_t lcd_cfg = ST7789P3_CONFIG_DEFAULT(); + lcd_cfg.spi_host = ST7789_SPI_HOST; + lcd_cfg.pin_miso = CONFIG_TQ_PRINTER_MISO_PIN; + lcd_cfg.pin_mosi = CONFIG_TQ_SPI_SHARED_MOSI_PIN; + lcd_cfg.pin_sclk = CONFIG_TQ_SPI_SHARED_CLK_PIN; + lcd_cfg.pin_cs = CONFIG_TQ_SCREEN_CS_PIN; + lcd_cfg.pin_dc = CONFIG_TQ_SCREEN_DC_PIN; + lcd_cfg.pin_rst = resolve_reset_pin(); + lcd_cfg.pin_bckl = CONFIG_TQ_SCREEN_BACKLIGHT_PIN; + lcd_cfg.backlight_on_level = ST7789_BACKLIGHT_ON_LEVEL; + lcd_cfg.pclk_hz = CONFIG_TQ_SCREEN_PIXEL_CLOCK_HZ; + lcd_cfg.spi_mode = CONFIG_TQ_SCREEN_SPI_MODE; + lcd_cfg.h_res = LCD_PANEL_H_RES; + lcd_cfg.v_res = LCD_PANEL_V_RES; + lcd_cfg.x_gap = (uint16_t)CONFIG_TQ_SCREEN_X_GAP; + lcd_cfg.y_gap = (uint16_t)CONFIG_TQ_SCREEN_Y_GAP; + lcd_cfg.madctl_val = lcd_build_madctl(); + lcd_cfg.invert_color = CONFIG_TQ_SCREEN_INVERT_COLOR; + lcd_cfg.swap_color_bytes = CONFIG_TQ_SCREEN_SWAP_COLOR_BYTES; + lcd_cfg.max_transfer_sz = CONFIG_TQ_SCREEN_DRAW_LINES * LCD_PANEL_H_RES * sizeof(uint16_t) + 8; + + esp_err_t err = st7789p3_new_panel(&lcd_cfg, &s_lcd.panel); + if (err != ESP_OK) { + return err; + } + + err = st7789p3_init(s_lcd.panel); + if (err != ESP_OK) { + lcd_module_deinit(); + return err; + } + + s_lcd.lock = xSemaphoreCreateMutex(); + if (s_lcd.lock == NULL) { + lcd_module_deinit(); + return ESP_ERR_NO_MEM; + } + + if (!lcd_lock_take(LCD_LOCK_TIMEOUT_MS)) { + lcd_module_deinit(); + return ESP_ERR_TIMEOUT; + } + err = lcd_lvgl_init_locked(); + lcd_lock_give(); + if (err != ESP_OK) { + lcd_module_deinit(); + return err; + } + + s_lcd.initialized = true; + s_lcd.shared_spi_dirty = false; + ESP_LOGI(TAG, + "st7789p3 ready: spi_host=%d mosi=%d clk=%d miso=%d dc=%d cs=%d rst=%d bk=%d pclk=%u mode=%u res=%ux%u madctl=0x%02x gap=%u,%u invert=%d swap_bytes=%d", + ST7789_SPI_HOST, + CONFIG_TQ_SPI_SHARED_MOSI_PIN, + CONFIG_TQ_SPI_SHARED_CLK_PIN, + CONFIG_TQ_PRINTER_MISO_PIN, + CONFIG_TQ_SCREEN_DC_PIN, + CONFIG_TQ_SCREEN_CS_PIN, + lcd_cfg.pin_rst, + CONFIG_TQ_SCREEN_BACKLIGHT_PIN, + (unsigned)lcd_cfg.pclk_hz, + (unsigned)lcd_cfg.spi_mode, + (unsigned)lcd_cfg.h_res, + (unsigned)lcd_cfg.v_res, + (unsigned)lcd_cfg.madctl_val, + (unsigned)lcd_cfg.x_gap, + (unsigned)lcd_cfg.y_gap, + lcd_cfg.invert_color ? 1 : 0, + lcd_cfg.swap_color_bytes ? 1 : 0); + return ESP_OK; +#endif +} + +esp_err_t lcd_module_show_raster_1bpp(const uint8_t *raster, + size_t raster_len, + uint16_t width, + uint16_t height, + uint32_t timeout_ms, + char *err, + size_t err_len) +{ +#if !CONFIG_TQ_SCREEN_ENABLE + return ESP_ERR_NOT_SUPPORTED; +#else + ESP_RETURN_ON_ERROR(lcd_recover_panel_for_shared_spi(), TAG, "display init failed"); + + if (timeout_ms == 0) { + timeout_ms = LCD_PREVIEW_TIMEOUT_MS_DEFAULT; + } + + uint16_t *preview_pixels = NULL; + uint16_t preview_w = 0; + uint16_t preview_h = 0; + esp_err_t convert_err = lcd_render_raster_to_rgb565(raster, + raster_len, + width, + height, + &preview_pixels, + &preview_w, + &preview_h, + err, + err_len); + if (convert_err != ESP_OK) { + return convert_err; + } + + if (!lcd_lock_take(timeout_ms)) { + heap_caps_free(preview_pixels); + lcd_fill_err(err, err_len, "display lock timeout"); + return ESP_ERR_TIMEOUT; + } + + uint16_t *prev_pixels = s_lcd.preview_pixels; + uint16_t prev_w = s_lcd.preview_dsc.header.w; + uint16_t prev_h = s_lcd.preview_dsc.header.h; + bool clear_before_draw = (s_lcd.preview_pixels == NULL || + s_lcd.preview_dsc.header.w != preview_w || + s_lcd.preview_dsc.header.h != preview_h); + s_lcd.preview_pixels = preview_pixels; + + memset(&s_lcd.preview_dsc, 0, sizeof(s_lcd.preview_dsc)); + s_lcd.preview_dsc.header.always_zero = 0; + s_lcd.preview_dsc.header.w = preview_w; + s_lcd.preview_dsc.header.h = preview_h; + s_lcd.preview_dsc.header.cf = LV_IMG_CF_TRUE_COLOR; + s_lcd.preview_dsc.data_size = (uint32_t)((size_t)preview_w * preview_h * sizeof(uint16_t)); + s_lcd.preview_dsc.data = (const uint8_t *)s_lcd.preview_pixels; + + esp_err_t draw_err = lcd_apply_preview_locked(prev_pixels, + prev_w, + prev_h, + clear_before_draw, + lv_color_white(), + err, + err_len); + if (prev_pixels != NULL) { + heap_caps_free(prev_pixels); + } + lcd_lock_give(); + return draw_err; +#endif +} + +esp_err_t lcd_module_show_png(const uint8_t *png, + size_t png_len, + uint32_t timeout_ms, + char *err, + size_t err_len) +{ +#if !CONFIG_TQ_SCREEN_ENABLE + return ESP_ERR_NOT_SUPPORTED; +#else + ESP_RETURN_ON_ERROR(lcd_recover_panel_for_shared_spi(), TAG, "display init failed"); + + if (timeout_ms == 0) { + timeout_ms = LCD_PREVIEW_TIMEOUT_MS_DEFAULT; + } + + uint16_t *src_pixels = NULL; + uint16_t src_w = 0; + uint16_t src_h = 0; + esp_err_t decode_err = platform_lcd_decode_png_rgb565(png, + png_len, + &src_pixels, + &src_w, + &src_h, + err, + err_len); + if (decode_err != ESP_OK) { + return decode_err; + } + + uint16_t *preview_pixels = NULL; + uint16_t preview_w = 0; + uint16_t preview_h = 0; + esp_err_t scale_err = lcd_scale_rgb565_to_preview(src_pixels, + src_w, + src_h, + &preview_pixels, + &preview_w, + &preview_h, + err, + err_len); + heap_caps_free(src_pixels); + if (scale_err != ESP_OK) { + return scale_err; + } + + if (!lcd_lock_take(timeout_ms)) { + heap_caps_free(preview_pixels); + lcd_fill_err(err, err_len, "display lock timeout"); + return ESP_ERR_TIMEOUT; + } + + uint16_t *prev_pixels = s_lcd.preview_pixels; + uint16_t prev_w = s_lcd.preview_dsc.header.w; + uint16_t prev_h = s_lcd.preview_dsc.header.h; + bool clear_before_draw = (s_lcd.preview_pixels == NULL || + s_lcd.preview_dsc.header.w != preview_w || + s_lcd.preview_dsc.header.h != preview_h); + s_lcd.preview_pixels = preview_pixels; + + memset(&s_lcd.preview_dsc, 0, sizeof(s_lcd.preview_dsc)); + s_lcd.preview_dsc.header.always_zero = 0; + s_lcd.preview_dsc.header.w = preview_w; + s_lcd.preview_dsc.header.h = preview_h; + s_lcd.preview_dsc.header.cf = LV_IMG_CF_TRUE_COLOR; + s_lcd.preview_dsc.data_size = (uint32_t)((size_t)preview_w * preview_h * sizeof(uint16_t)); + s_lcd.preview_dsc.data = (const uint8_t *)s_lcd.preview_pixels; + + esp_err_t draw_err = lcd_apply_preview_locked(prev_pixels, + prev_w, + prev_h, + clear_before_draw, + lv_color_white(), + err, + err_len); + if (prev_pixels != NULL) { + heap_caps_free(prev_pixels); + } + lcd_lock_give(); + return draw_err; +#endif +} + +esp_err_t lcd_module_start_demo(void) +{ +#if !CONFIG_TQ_SCREEN_ENABLE + return ESP_ERR_NOT_SUPPORTED; +#else + ESP_RETURN_ON_ERROR(lcd_recover_panel_for_shared_spi(), TAG, "display init failed"); + if (!lcd_lock_take(LCD_LOCK_TIMEOUT_MS)) { + return ESP_ERR_TIMEOUT; + } + + uint16_t *demo_pixels = NULL; + uint16_t demo_w = 0; + uint16_t demo_h = 0; + char decode_err[96] = {0}; + esp_err_t decode_rc = platform_lcd_decode_demo_png(&demo_pixels, + &demo_w, + &demo_h, + decode_err, + sizeof(decode_err)); + if (decode_rc != ESP_OK) { + ESP_LOGW(TAG, "demo png decode failed: rc=0x%x msg=%s", (unsigned)decode_rc, decode_err); + lcd_lock_give(); + return decode_rc; + } + + uint16_t *prev_pixels = s_lcd.preview_pixels; + uint16_t prev_w = s_lcd.preview_dsc.header.w; + uint16_t prev_h = s_lcd.preview_dsc.header.h; + bool clear_before_draw = (s_lcd.preview_pixels == NULL || + s_lcd.preview_dsc.header.w != demo_w || + s_lcd.preview_dsc.header.h != demo_h); + s_lcd.preview_pixels = demo_pixels; + + memset(&s_lcd.preview_dsc, 0, sizeof(s_lcd.preview_dsc)); + s_lcd.preview_dsc.header.always_zero = 0; + s_lcd.preview_dsc.header.w = demo_w; + s_lcd.preview_dsc.header.h = demo_h; + s_lcd.preview_dsc.header.cf = LV_IMG_CF_TRUE_COLOR; + s_lcd.preview_dsc.data_size = (uint32_t)((size_t)demo_w * demo_h * sizeof(uint16_t)); + s_lcd.preview_dsc.data = (const uint8_t *)s_lcd.preview_pixels; + + esp_err_t draw_err = lcd_apply_preview_locked(prev_pixels, + prev_w, + prev_h, + clear_before_draw, + lv_color_black(), + decode_err, + sizeof(decode_err)); + if (prev_pixels != NULL) { + heap_caps_free(prev_pixels); + } + lcd_lock_give(); + if (draw_err != ESP_OK) { + ESP_LOGW(TAG, "demo draw failed: rc=0x%x msg=%s", (unsigned)draw_err, decode_err); + return draw_err; + } + return ESP_OK; +#endif +} diff --git a/components/platform/src/display_st7789.c b/components/platform/src/display_st7789.c new file mode 100644 index 0000000..88ff964 --- /dev/null +++ b/components/platform/src/display_st7789.c @@ -0,0 +1,43 @@ +#include "platform.h" + +#include "display_lcd_module.h" + +void platform_display_deinit(void) +{ + lcd_module_deinit(); +} + +esp_err_t platform_display_init(void) +{ + return lcd_module_init(); +} + +esp_err_t platform_display_show_test_pattern(void) +{ + return lcd_module_start_demo(); +} + +esp_err_t platform_display_show_raster_1bpp(const uint8_t *raster, + size_t raster_len, + uint16_t width, + uint16_t height, + uint32_t timeout_ms, + char *err, + size_t err_len) +{ + return lcd_module_show_raster_1bpp(raster, raster_len, width, height, timeout_ms, err, err_len); +} + +esp_err_t platform_display_show_png(const uint8_t *png, + size_t png_len, + uint32_t timeout_ms, + char *err, + size_t err_len) +{ + return lcd_module_show_png(png, png_len, timeout_ms, err, err_len); +} + +void platform_display_mark_shared_spi_dirty(void) +{ + lcd_module_mark_shared_spi_dirty(); +} diff --git a/components/platform/src/display_st7789p3.c b/components/platform/src/display_st7789p3.c new file mode 100644 index 0000000..09aec65 --- /dev/null +++ b/components/platform/src/display_st7789p3.c @@ -0,0 +1,575 @@ +#include "display_st7789p3.h" + +#include +#include + +#include "driver/gpio.h" +#include "esp_attr.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define ST7789P3_CMD_DELAY_FLAG 0x80 +#define ST7789P3_CMD_DATA_LEN_MASK 0x1F +#define ST7789P3_TX_MAX_CHUNK_BYTES 1024 +#define ST7789P3_SWAP_BUF_PIXELS 256 + +typedef struct { + uint8_t cmd; + uint8_t data[16]; + uint8_t data_bytes; +} st7789p3_lcd_init_cmd_t; + +struct st7789p3_panel_t { + st7789p3_config_t cfg; + spi_device_handle_t spi; + bool bus_owned; +}; + +static const char *TAG = "st7789p3"; + +// Sequence verified on the LCD used by docs/lcd_verification. +DRAM_ATTR static const st7789p3_lcd_init_cmd_t s_st7789p3_init_cmds[] = { + {0x36, {0x00}, 1}, + {0x3A, {0x55}, 1}, + {0xB2, {0x0C, 0x0C, 0x00, 0x33, 0x33}, 5}, + {0xB7, {0x45}, 1}, + {0xBB, {0x2B}, 1}, + {0xC0, {0x2C}, 1}, + {0xC2, {0x01, 0xFF}, 2}, + {0xC3, {0x11}, 1}, + {0xC4, {0x20}, 1}, + {0xC6, {0x0F}, 1}, + {0xD0, {0xA4, 0xA1}, 2}, + {0xE0, {0xD0, 0x00, 0x05, 0x0E, 0x15, 0x0D, 0x37, 0x43, 0x47, 0x09, 0x15, 0x12, 0x16, 0x19}, 14}, + {0xE1, {0xD0, 0x00, 0x05, 0x0D, 0x0C, 0x06, 0x2D, 0x44, 0x40, 0x0E, 0x1C, 0x18, 0x16, 0x19}, 14}, + {0x11, {0}, ST7789P3_CMD_DELAY_FLAG}, + {0, {0}, 0xFF}, +}; + +static esp_err_t st7789p3_validate_config(const st7789p3_config_t *config) +{ + if (config == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (config->pin_mosi < 0 || config->pin_sclk < 0 || config->pin_cs < 0 || config->pin_dc < 0) { + return ESP_ERR_INVALID_ARG; + } + if (config->h_res == 0 || config->v_res == 0) { + return ESP_ERR_INVALID_ARG; + } + if (config->spi_mode > 3) { + return ESP_ERR_INVALID_ARG; + } + return ESP_OK; +} + +static esp_err_t st7789p3_configure_gpio(st7789p3_handle_t panel) +{ + uint64_t pin_mask = 0; + + pin_mask |= (1ULL << panel->cfg.pin_dc); + if (panel->cfg.pin_rst >= 0) { + pin_mask |= (1ULL << panel->cfg.pin_rst); + } + if (panel->cfg.pin_bckl >= 0) { + pin_mask |= (1ULL << panel->cfg.pin_bckl); + } + + gpio_config_t io_conf = { + .pin_bit_mask = pin_mask, + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + + return gpio_config(&io_conf); +} + +static esp_err_t st7789p3_spi_write(st7789p3_handle_t panel, int dc_level, const void *data, size_t data_size) +{ + if (data_size == 0) { + return ESP_OK; + } + + esp_err_t ret = gpio_set_level(panel->cfg.pin_dc, dc_level); + if (ret != ESP_OK) { + return ret; + } + + spi_transaction_t t = {0}; + t.length = data_size * 8; + t.tx_buffer = data; + + return spi_device_transmit(panel->spi, &t); +} + +static esp_err_t st7789p3_send_cmd(st7789p3_handle_t panel, uint8_t cmd) +{ + return st7789p3_spi_write(panel, 0, &cmd, 1); +} + +static esp_err_t st7789p3_send_data(st7789p3_handle_t panel, const void *data, size_t data_size) +{ + const uint8_t *data_ptr = (const uint8_t *)data; + size_t remaining = data_size; + size_t tx_chunk_size = ST7789P3_TX_MAX_CHUNK_BYTES; + + if (panel->cfg.max_transfer_sz > 0 && panel->cfg.max_transfer_sz < tx_chunk_size) { + tx_chunk_size = panel->cfg.max_transfer_sz; + } + + while (remaining > 0) { + size_t chunk = (remaining > tx_chunk_size) ? tx_chunk_size : remaining; + esp_err_t ret = st7789p3_spi_write(panel, 1, data_ptr, chunk); + if (ret != ESP_OK) { + return ret; + } + data_ptr += chunk; + remaining -= chunk; + } + return ESP_OK; +} + +static esp_err_t st7789p3_send_color_data(st7789p3_handle_t panel, const uint16_t *color_data, size_t pixel_count) +{ + if (color_data == NULL || pixel_count == 0) { + return ESP_OK; + } + + if (!panel->cfg.swap_color_bytes) { + return st7789p3_send_data(panel, color_data, pixel_count * sizeof(uint16_t)); + } + + uint8_t swap_buf[ST7789P3_SWAP_BUF_PIXELS * sizeof(uint16_t)]; + size_t offset = 0; + while (offset < pixel_count) { + size_t chunk = pixel_count - offset; + if (chunk > ST7789P3_SWAP_BUF_PIXELS) { + chunk = ST7789P3_SWAP_BUF_PIXELS; + } + + for (size_t i = 0; i < chunk; ++i) { + uint16_t color = color_data[offset + i]; + swap_buf[i * 2] = (uint8_t)(color >> 8); + swap_buf[i * 2 + 1] = (uint8_t)(color & 0xFF); + } + + esp_err_t ret = st7789p3_spi_write(panel, 1, swap_buf, chunk * sizeof(uint16_t)); + if (ret != ESP_OK) { + return ret; + } + offset += chunk; + } + + return ESP_OK; +} + +static esp_err_t st7789p3_run_init_cmds(st7789p3_handle_t panel) +{ + for (size_t i = 0; s_st7789p3_init_cmds[i].data_bytes != 0xFF; i++) { + const st7789p3_lcd_init_cmd_t *init_cmd = &s_st7789p3_init_cmds[i]; + uint8_t data_bytes = init_cmd->data_bytes & ST7789P3_CMD_DATA_LEN_MASK; + uint8_t data[16] = {0}; + + if (data_bytes > 0) { + memcpy(data, init_cmd->data, data_bytes); + } + + if (init_cmd->cmd == 0x36 && data_bytes == 1) { + data[0] = panel->cfg.madctl_val; + } + + esp_err_t ret = st7789p3_send_cmd(panel, init_cmd->cmd); + if (ret != ESP_OK) { + return ret; + } + + ret = st7789p3_send_data(panel, data, data_bytes); + if (ret != ESP_OK) { + return ret; + } + + if (init_cmd->data_bytes & ST7789P3_CMD_DELAY_FLAG) { + vTaskDelay(pdMS_TO_TICKS(100)); + } + } + return ESP_OK; +} + +static esp_err_t st7789p3_attach_spi_device(st7789p3_handle_t panel) +{ + if (panel == NULL) { + return ESP_ERR_INVALID_ARG; + } + + if (panel->cfg.init_spi_bus) { + spi_bus_config_t buscfg = { + .mosi_io_num = panel->cfg.pin_mosi, + .miso_io_num = panel->cfg.pin_miso, + .sclk_io_num = panel->cfg.pin_sclk, + .quadwp_io_num = -1, + .quadhd_io_num = -1, + .max_transfer_sz = panel->cfg.max_transfer_sz, + }; + + esp_err_t ret = spi_bus_initialize(panel->cfg.spi_host, &buscfg, SPI_DMA_CH_AUTO); + if (ret == ESP_OK) { + panel->bus_owned = true; + } else if (ret == ESP_ERR_INVALID_STATE) { + panel->bus_owned = false; + ESP_LOGW(TAG, "SPI host already initialized, skip bus init"); + } else { + return ret; + } + } else { + panel->bus_owned = false; + } + + spi_device_interface_config_t devcfg = { + .clock_speed_hz = panel->cfg.pclk_hz, + .mode = panel->cfg.spi_mode, + .spics_io_num = panel->cfg.pin_cs, + .queue_size = panel->cfg.queue_size, + }; + + esp_err_t ret = spi_bus_add_device(panel->cfg.spi_host, &devcfg, &panel->spi); + if (ret != ESP_OK) { + if (panel->bus_owned) { + (void)spi_bus_free(panel->cfg.spi_host); + panel->bus_owned = false; + } + return ret; + } + return ESP_OK; +} + +esp_err_t st7789p3_new_panel(const st7789p3_config_t *config, st7789p3_handle_t *ret_panel) +{ + if (ret_panel == NULL) { + return ESP_ERR_INVALID_ARG; + } + *ret_panel = NULL; + + esp_err_t ret = st7789p3_validate_config(config); + if (ret != ESP_OK) { + return ret; + } + + st7789p3_handle_t panel = calloc(1, sizeof(*panel)); + if (panel == NULL) { + return ESP_ERR_NO_MEM; + } + + panel->cfg = *config; + if (panel->cfg.pclk_hz == 0) { + panel->cfg.pclk_hz = 10 * 1000 * 1000; + } + if (panel->cfg.queue_size <= 0) { + panel->cfg.queue_size = 7; + } + if (panel->cfg.max_transfer_sz == 0) { + panel->cfg.max_transfer_sz = panel->cfg.h_res * 20 * sizeof(uint16_t) + 8; + } + + ret = st7789p3_configure_gpio(panel); + if (ret != ESP_OK) { + free(panel); + return ret; + } + + ret = st7789p3_attach_spi_device(panel); + if (ret != ESP_OK) { + free(panel); + return ret; + } + + *ret_panel = panel; + return ESP_OK; +} + +esp_err_t st7789p3_del(st7789p3_handle_t panel) +{ + if (panel == NULL) { + return ESP_ERR_INVALID_ARG; + } + + esp_err_t ret = ESP_OK; + esp_err_t local_ret = spi_bus_remove_device(panel->spi); + if (local_ret != ESP_OK) { + ret = local_ret; + } + + if (panel->bus_owned) { + local_ret = spi_bus_free(panel->cfg.spi_host); + if (ret == ESP_OK && local_ret != ESP_OK) { + ret = local_ret; + } + } + + free(panel); + return ret; +} + +esp_err_t st7789p3_recover_spi_bus(st7789p3_handle_t panel) +{ + if (panel == NULL) { + return ESP_ERR_INVALID_ARG; + } + + esp_err_t ret = st7789p3_configure_gpio(panel); + if (ret != ESP_OK) { + return ret; + } + + if (panel->spi != NULL) { + ret = spi_bus_remove_device(panel->spi); + if (ret != ESP_OK) { + return ret; + } + panel->spi = NULL; + } + + if (panel->bus_owned) { + ret = spi_bus_free(panel->cfg.spi_host); + if (ret != ESP_OK) { + return ret; + } + panel->bus_owned = false; + } + + ret = st7789p3_attach_spi_device(panel); + if (ret != ESP_OK) { + return ret; + } + + // Keep current frame on panel; just ensure display state remains on. + return st7789p3_send_cmd(panel, 0x29); +} + +esp_err_t st7789p3_set_backlight(st7789p3_handle_t panel, bool on) +{ + if (panel == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (panel->cfg.pin_bckl < 0) { + return ESP_OK; + } + + int on_level = panel->cfg.backlight_on_level ? 1 : 0; + int off_level = on_level ? 0 : 1; + return gpio_set_level(panel->cfg.pin_bckl, on ? on_level : off_level); +} + +esp_err_t st7789p3_set_madctl(st7789p3_handle_t panel, uint8_t madctl_val) +{ + if (panel == NULL) { + return ESP_ERR_INVALID_ARG; + } + + panel->cfg.madctl_val = madctl_val; + esp_err_t ret = st7789p3_send_cmd(panel, 0x36); + if (ret != ESP_OK) { + return ret; + } + return st7789p3_send_data(panel, &madctl_val, 1); +} + +esp_err_t st7789p3_set_invert(st7789p3_handle_t panel, bool invert_color) +{ + if (panel == NULL) { + return ESP_ERR_INVALID_ARG; + } + + panel->cfg.invert_color = invert_color; + return st7789p3_send_cmd(panel, invert_color ? 0x21 : 0x20); +} + +esp_err_t st7789p3_set_swap_color_bytes(st7789p3_handle_t panel, bool swap_color_bytes) +{ + if (panel == NULL) { + return ESP_ERR_INVALID_ARG; + } + panel->cfg.swap_color_bytes = swap_color_bytes; + return ESP_OK; +} + +esp_err_t st7789p3_reset(st7789p3_handle_t panel) +{ + if (panel == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (panel->cfg.pin_rst < 0) { + return ESP_OK; + } + + esp_err_t ret = gpio_set_level(panel->cfg.pin_rst, 0); + if (ret != ESP_OK) { + return ret; + } + vTaskDelay(pdMS_TO_TICKS(100)); + + ret = gpio_set_level(panel->cfg.pin_rst, 1); + if (ret != ESP_OK) { + return ret; + } + vTaskDelay(pdMS_TO_TICKS(100)); + return ESP_OK; +} + +esp_err_t st7789p3_init(st7789p3_handle_t panel) +{ + if (panel == NULL) { + return ESP_ERR_INVALID_ARG; + } + + esp_err_t ret = st7789p3_set_backlight(panel, false); + if (ret != ESP_OK) { + return ret; + } + + ret = st7789p3_reset(panel); + if (ret != ESP_OK) { + return ret; + } + + ret = st7789p3_run_init_cmds(panel); + if (ret != ESP_OK) { + return ret; + } + + // Re-assert MADCTL after init sequence while backlight is still off, + // to avoid a visible first-frame transition on some panels. + ret = st7789p3_set_madctl(panel, panel->cfg.madctl_val); + if (ret != ESP_OK) { + return ret; + } + + ret = st7789p3_set_invert(panel, panel->cfg.invert_color); + if (ret != ESP_OK) { + return ret; + } + + ret = st7789p3_send_cmd(panel, 0x29); + if (ret != ESP_OK) { + return ret; + } + vTaskDelay(pdMS_TO_TICKS(100)); + + return st7789p3_set_backlight(panel, true); +} + +esp_err_t st7789p3_set_window( + st7789p3_handle_t panel, uint16_t x_start, uint16_t y_start, uint16_t x_end, uint16_t y_end) +{ + if (panel == NULL || x_start >= x_end || y_start >= y_end) { + return ESP_ERR_INVALID_ARG; + } + if (x_end > panel->cfg.h_res || y_end > panel->cfg.v_res) { + return ESP_ERR_INVALID_ARG; + } + + uint32_t panel_x_start = (uint32_t)x_start + panel->cfg.x_gap; + uint32_t panel_y_start = (uint32_t)y_start + panel->cfg.y_gap; + uint32_t panel_x_end = (uint32_t)x_end + panel->cfg.x_gap; + uint32_t panel_y_end = (uint32_t)y_end + panel->cfg.y_gap; + if (panel_x_end == 0 || panel_y_end == 0 || panel_x_end - 1 > UINT16_MAX || panel_y_end - 1 > UINT16_MAX) { + return ESP_ERR_INVALID_ARG; + } + + uint8_t x_data[4] = { + (uint8_t)(panel_x_start >> 8), + (uint8_t)(panel_x_start & 0xFF), + (uint8_t)((panel_x_end - 1) >> 8), + (uint8_t)((panel_x_end - 1) & 0xFF), + }; + uint8_t y_data[4] = { + (uint8_t)(panel_y_start >> 8), + (uint8_t)(panel_y_start & 0xFF), + (uint8_t)((panel_y_end - 1) >> 8), + (uint8_t)((panel_y_end - 1) & 0xFF), + }; + + esp_err_t ret = st7789p3_send_cmd(panel, 0x2A); + if (ret != ESP_OK) { + return ret; + } + ret = st7789p3_send_data(panel, x_data, sizeof(x_data)); + if (ret != ESP_OK) { + return ret; + } + + ret = st7789p3_send_cmd(panel, 0x2B); + if (ret != ESP_OK) { + return ret; + } + ret = st7789p3_send_data(panel, y_data, sizeof(y_data)); + if (ret != ESP_OK) { + return ret; + } + + return st7789p3_send_cmd(panel, 0x2C); +} + +esp_err_t st7789p3_draw_bitmap(st7789p3_handle_t panel, + uint16_t x_start, + uint16_t y_start, + uint16_t x_end, + uint16_t y_end, + const uint16_t *color_data) +{ + if (panel == NULL || color_data == NULL) { + return ESP_ERR_INVALID_ARG; + } + + esp_err_t ret = st7789p3_set_window(panel, x_start, y_start, x_end, y_end); + if (ret != ESP_OK) { + return ret; + } + + size_t pixel_count = (size_t)(x_end - x_start) * (size_t)(y_end - y_start); + return st7789p3_send_color_data(panel, color_data, pixel_count); +} + +esp_err_t st7789p3_fill_color(st7789p3_handle_t panel, uint16_t color) +{ + if (panel == NULL) { + return ESP_ERR_INVALID_ARG; + } + + static const size_t fill_pixels = 256; + uint16_t *fill_buffer = heap_caps_malloc(fill_pixels * sizeof(uint16_t), MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); + if (fill_buffer == NULL) { + return ESP_ERR_NO_MEM; + } + + uint16_t fill_color = color; + if (panel->cfg.swap_color_bytes) { + fill_color = (uint16_t)((color >> 8) | (color << 8)); + } + for (size_t i = 0; i < fill_pixels; i++) { + fill_buffer[i] = fill_color; + } + + esp_err_t ret = st7789p3_set_window(panel, 0, 0, panel->cfg.h_res, panel->cfg.v_res); + if (ret != ESP_OK) { + free(fill_buffer); + return ret; + } + + size_t total_pixels = (size_t)panel->cfg.h_res * (size_t)panel->cfg.v_res; + while (total_pixels > 0) { + size_t pixels_to_send = (total_pixels > fill_pixels) ? fill_pixels : total_pixels; + ret = st7789p3_send_data(panel, fill_buffer, pixels_to_send * sizeof(uint16_t)); + if (ret != ESP_OK) { + free(fill_buffer); + return ret; + } + total_pixels -= pixels_to_send; + } + + free(fill_buffer); + return ESP_OK; +} diff --git a/components/platform/src/platform_bootstrap.c b/components/platform/src/platform_bootstrap.c new file mode 100644 index 0000000..4421198 --- /dev/null +++ b/components/platform/src/platform_bootstrap.c @@ -0,0 +1,244 @@ +#include "platform.h" + +#include + +#include "driver/gpio.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "mbedtls/platform.h" +#include "nvs_flash.h" +#include "sdkconfig.h" + +static bool s_initialized; +static bool s_board_gpio_initialized; +static const char *TAG = "platform_bootstrap"; + +#ifndef CONFIG_TQ_KEY_PRINT_BOOST_ENABLE_ON_BOOT +#define CONFIG_TQ_KEY_PRINT_BOOST_ENABLE_ON_BOOT 1 +#endif + +#ifndef CONFIG_TQ_KEY_PRINT_BOOST_ACTIVE_HIGH +#define CONFIG_TQ_KEY_PRINT_BOOST_ACTIVE_HIGH 1 +#endif + +#ifndef CONFIG_TQ_SCREEN_BACKLIGHT_ACTIVE_HIGH +#define CONFIG_TQ_SCREEN_BACKLIGHT_ACTIVE_HIGH 1 +#endif + +#if CONFIG_TQ_KEY_PRINT_BOOST_ENABLE_ON_BOOT +#if CONFIG_TQ_KEY_PRINT_BOOST_ACTIVE_HIGH +#define KEY_PRINT_BOOST_LEVEL 1 +#else +#define KEY_PRINT_BOOST_LEVEL 0 +#endif +#endif + +#if CONFIG_TQ_SCREEN_BACKLIGHT_ACTIVE_HIGH +#define BOOT_SCREEN_BACKLIGHT_ON_LEVEL 1 +#else +#define BOOT_SCREEN_BACKLIGHT_ON_LEVEL 0 +#endif + +static void *tls_calloc_prefer_psram(size_t n, size_t size) { + void *ptr = heap_caps_calloc(n, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (ptr == NULL) { + ptr = heap_caps_calloc(n, size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + } + return ptr; +} + +static void tls_free(void *ptr) { + heap_caps_free(ptr); +} + +static esp_err_t board_gpio_config_output(int gpio_num, int level, const char *name) { + if (gpio_num < 0) { + return ESP_OK; + } + + gpio_config_t cfg = { + .pin_bit_mask = 1ULL << gpio_num, + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + esp_err_t err = gpio_config(&cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "gpio_config output failed: %s pin=%d err=%s", name, gpio_num, esp_err_to_name(err)); + return err; + } + err = gpio_set_level(gpio_num, level); + if (err != ESP_OK) { + ESP_LOGE(TAG, "gpio_set_level failed: %s pin=%d level=%d err=%s", name, gpio_num, level, esp_err_to_name(err)); + return err; + } + return ESP_OK; +} + +static esp_err_t board_gpio_config_input(int gpio_num, bool pull_up, const char *name) { + if (gpio_num < 0) { + return ESP_OK; + } + + gpio_config_t cfg = { + .pin_bit_mask = 1ULL << gpio_num, + .mode = GPIO_MODE_INPUT, + .pull_up_en = pull_up ? GPIO_PULLUP_ENABLE : GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + esp_err_t err = gpio_config(&cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "gpio_config input failed: %s pin=%d err=%s", name, gpio_num, esp_err_to_name(err)); + return err; + } + return ESP_OK; +} + +static esp_err_t platform_board_gpio_init(void) { + if (s_board_gpio_initialized) { + return ESP_OK; + } + + // Must be asserted early to keep the board powered. + esp_err_t err = board_gpio_config_output(CONFIG_TQ_POWER_HOLD_PIN, 1, "power_hold"); + if (err != ESP_OK) { + return err; + } + + // Default UI GPIO states. + err = board_gpio_config_output(CONFIG_TQ_LED_R_PIN, 0, "led_r"); + if (err != ESP_OK) { + return err; + } + err = board_gpio_config_output(CONFIG_TQ_LED_G_PIN, 0, "led_g"); + if (err != ESP_OK) { + return err; + } + err = board_gpio_config_output(CONFIG_TQ_SCREEN_CS_PIN, 1, "screen_cs"); + if (err != ESP_OK) { + return err; + } + err = board_gpio_config_output(CONFIG_TQ_SCREEN_DC_PIN, 0, "screen_dc"); + if (err != ESP_OK) { + return err; + } + err = board_gpio_config_output(CONFIG_TQ_SCREEN_BACKLIGHT_PIN, + BOOT_SCREEN_BACKLIGHT_ON_LEVEL, + "screen_backlight"); + if (err != ESP_OK) { + return err; + } + + // Key/power/sensor GPIO input defaults. + err = board_gpio_config_input(CONFIG_TQ_POWER_KEY_PIN, true, "power_key"); + if (err != ESP_OK) { + return err; + } +#if CONFIG_TQ_KEY_PRINT_BOOST_ENABLE_ON_BOOT + err = board_gpio_config_output(CONFIG_TQ_KEY_PRINT_PIN, KEY_PRINT_BOOST_LEVEL, "key_print_boost"); + if (err != ESP_OK) { + return err; + } +#else + err = board_gpio_config_input(CONFIG_TQ_KEY_PRINT_PIN, true, "key_print"); + if (err != ESP_OK) { + return err; + } +#endif + err = board_gpio_config_input(CONFIG_TQ_MIC_KEY_PIN, true, "mic_key"); + if (err != ESP_OK) { + return err; + } + err = board_gpio_config_input(CONFIG_TQ_PRINT_PAPER_PIN, true, "print_paper"); + if (err != ESP_OK) { + return err; + } + err = board_gpio_config_input(CONFIG_TQ_BATTERY_ADC_PIN, false, "battery_adc"); + if (err != ESP_OK) { + return err; + } + err = board_gpio_config_input(CONFIG_TQ_NTC_ADC_PIN, false, "ntc_adc"); + if (err != ESP_OK) { + return err; + } + + ESP_LOGI(TAG, + "board gpio mapped: hold=%d pwr_key=%d key_print=%d boost_mode=%d led_r=%d led_g=%d spi(mosi=%d,miso=%d,clk=%d) screen(dc=%d,cs=%d,bk=%d) i2c(sda=%d,scl=%d) i2s(mclk=%d,bclk=%d,ws=%d,di=%d,do=%d) pa=%d", + CONFIG_TQ_POWER_HOLD_PIN, + CONFIG_TQ_POWER_KEY_PIN, + CONFIG_TQ_KEY_PRINT_PIN, +#if CONFIG_TQ_KEY_PRINT_BOOST_ENABLE_ON_BOOT + 1, +#else + 0, +#endif + CONFIG_TQ_LED_R_PIN, + CONFIG_TQ_LED_G_PIN, + CONFIG_TQ_SPI_SHARED_MOSI_PIN, + CONFIG_TQ_PRINTER_MISO_PIN, + CONFIG_TQ_SPI_SHARED_CLK_PIN, + CONFIG_TQ_SCREEN_DC_PIN, + CONFIG_TQ_SCREEN_CS_PIN, + CONFIG_TQ_SCREEN_BACKLIGHT_PIN, + CONFIG_TQ_VOICE_I2C_SDA_PIN, + CONFIG_TQ_VOICE_I2C_SCL_PIN, + CONFIG_TQ_VOICE_I2S_MCLK_PIN, + CONFIG_TQ_VOICE_I2S_BCLK_PIN, + CONFIG_TQ_VOICE_I2S_WS_PIN, + CONFIG_TQ_VOICE_I2S_DIN_PIN, + CONFIG_TQ_VOICE_I2S_DOUT_PIN, + CONFIG_TQ_VOICE_CODEC_PA_PIN); + + s_board_gpio_initialized = true; + return ESP_OK; +} + +esp_err_t platform_bootstrap_init(void) { + if (s_initialized) { + return ESP_OK; + } + + ESP_ERROR_CHECK(platform_board_gpio_init()); + + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + ESP_ERROR_CHECK(err); + +#if CONFIG_SPIRAM_USE_MALLOC + // Keep very small allocations on internal RAM, push larger generic mallocs to PSRAM. + heap_caps_malloc_extmem_enable(4096); + ESP_LOGI(TAG, "extmem malloc threshold set to 4096 bytes"); +#endif + +#if defined(MBEDTLS_PLATFORM_MEMORY) + int tls_rc = mbedtls_platform_set_calloc_free(tls_calloc_prefer_psram, tls_free); + if (tls_rc == 0) { + ESP_LOGI(TAG, "mbedtls calloc/free redirected to PSRAM-preferred allocator"); + } else { + ESP_LOGW(TAG, "mbedtls calloc/free redirect failed: rc=%d", tls_rc); + } +#endif + +#if CONFIG_TQ_SCREEN_ENABLE + err = platform_display_init(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "st7789 init failed: %s", esp_err_to_name(err)); + } +#if CONFIG_TQ_SCREEN_TEST_PATTERN_ON_BOOT + else { + err = platform_display_show_test_pattern(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "st7789 test pattern failed: %s", esp_err_to_name(err)); + } + } +#endif +#endif + + s_initialized = true; + return ESP_OK; +} diff --git a/components/platform/src/runtime_diagnostics.c b/components/platform/src/runtime_diagnostics.c new file mode 100644 index 0000000..1d8c844 --- /dev/null +++ b/components/platform/src/runtime_diagnostics.c @@ -0,0 +1,136 @@ +#include "platform.h" + +#include +#include + +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +static SemaphoreHandle_t s_lock; +static runtime_diag_snapshot_t s_snapshot; + +static const char *const s_counter_names[RUNTIME_DIAG_COUNTER_MAX] = { + "lifecycle_start_attempt", + "lifecycle_start_success", + "lifecycle_start_failed", + "lifecycle_start_retry", + "lifecycle_stop_attempt", + "lifecycle_stop_success", + "lifecycle_stop_failed", + "wifi_connect_success", + "wifi_connect_failed", + "wifi_connect_timeout", + "printer_job_submitted", + "printer_job_success", + "printer_job_failed", + "printer_job_canceled", + "image_generate_attempt", + "image_generate_success", + "image_generate_failed", + "image_generate_timeout", + "rest_responses_total", + "rest_errors_total", +}; + +static const char *const s_gauge_names[RUNTIME_DIAG_GAUGE_MAX] = { + "lifecycle_state", + "status_poll_pause_depth", + "printer_queue_depth", +}; + +static void runtime_diag_ensure_lock(void) { + if (s_lock == NULL) { + s_lock = xSemaphoreCreateMutex(); + } +} + +void runtime_diag_counter_add(runtime_diag_counter_t counter, uint32_t delta) { + if (counter < 0 || counter >= RUNTIME_DIAG_COUNTER_MAX || delta == 0) { + return; + } + + runtime_diag_ensure_lock(); + if (s_lock == NULL) { + return; + } + + if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(50)) != pdTRUE) { + return; + } + s_snapshot.counters[counter] += delta; + xSemaphoreGive(s_lock); +} + +void runtime_diag_set_gauge(runtime_diag_gauge_t gauge, int32_t value) { + if (gauge < 0 || gauge >= RUNTIME_DIAG_GAUGE_MAX) { + return; + } + + runtime_diag_ensure_lock(); + if (s_lock == NULL) { + return; + } + + if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(50)) != pdTRUE) { + return; + } + s_snapshot.gauges[gauge] = value; + xSemaphoreGive(s_lock); +} + +void runtime_diag_record_error(const char *source, esp_err_t code, const char *message) { + runtime_diag_ensure_lock(); + if (s_lock == NULL) { + return; + } + + if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(100)) != pdTRUE) { + return; + } + + s_snapshot.last_error_ms = esp_timer_get_time() / 1000; + s_snapshot.last_error_code = code; + strlcpy(s_snapshot.last_error_source, + source != NULL ? source : "unknown", + sizeof(s_snapshot.last_error_source)); + strlcpy(s_snapshot.last_error_message, + message != NULL ? message : "unknown", + sizeof(s_snapshot.last_error_message)); + + xSemaphoreGive(s_lock); +} + +void runtime_diag_get_snapshot(runtime_diag_snapshot_t *out_snapshot) { + if (out_snapshot == NULL) { + return; + } + + memset(out_snapshot, 0, sizeof(*out_snapshot)); + + runtime_diag_ensure_lock(); + if (s_lock == NULL) { + return; + } + + if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(100)) != pdTRUE) { + return; + } + + *out_snapshot = s_snapshot; + xSemaphoreGive(s_lock); +} + +const char *runtime_diag_counter_name(runtime_diag_counter_t counter) { + if (counter < 0 || counter >= RUNTIME_DIAG_COUNTER_MAX) { + return "unknown"; + } + return s_counter_names[counter]; +} + +const char *runtime_diag_gauge_name(runtime_diag_gauge_t gauge) { + if (gauge < 0 || gauge >= RUNTIME_DIAG_GAUGE_MAX) { + return "unknown"; + } + return s_gauge_names[gauge]; +} diff --git a/components/platform/src/runtime_policy.c b/components/platform/src/runtime_policy.c new file mode 100644 index 0000000..eeb6d30 --- /dev/null +++ b/components/platform/src/runtime_policy.c @@ -0,0 +1,319 @@ +#include "platform.h" + +#include + +#ifndef CONFIG_TQ_WIFI_CONNECT_TIMEOUT_MS +#define CONFIG_TQ_WIFI_CONNECT_TIMEOUT_MS 15000 +#endif + +#ifndef CONFIG_TQ_LIFECYCLE_START_RETRY_COUNT +#define CONFIG_TQ_LIFECYCLE_START_RETRY_COUNT 2 +#endif + +#ifndef CONFIG_TQ_LIFECYCLE_RETRY_BACKOFF_MS +#define CONFIG_TQ_LIFECYCLE_RETRY_BACKOFF_MS 800 +#endif + +#ifndef CONFIG_TQ_PRINTER_CONTROL_LOCK_TIMEOUT_MS +#define CONFIG_TQ_PRINTER_CONTROL_LOCK_TIMEOUT_MS 1000 +#endif + +#ifndef CONFIG_TQ_PRINTER_WORKER_QUEUE_WAIT_MS +#define CONFIG_TQ_PRINTER_WORKER_QUEUE_WAIT_MS 250 +#endif + +#ifndef CONFIG_TQ_PRINTER_QUEUE_RETRY_DELAY_MS +#define CONFIG_TQ_PRINTER_QUEUE_RETRY_DELAY_MS 20 +#endif + +#ifndef CONFIG_TQ_PRINTER_STATUS_POLL_INTERVAL_MS +#define CONFIG_TQ_PRINTER_STATUS_POLL_INTERVAL_MS 5000 +#endif + +#ifndef CONFIG_TQ_PRINTER_STOP_TIMEOUT_MS +#define CONFIG_TQ_PRINTER_STOP_TIMEOUT_MS 8000 +#endif + +#ifndef CONFIG_TQ_PRINTER_DEFAULT_BACKEND +#define CONFIG_TQ_PRINTER_DEFAULT_BACKEND 0 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_ENABLE +#define CONFIG_TQ_DIRECT_PRINTER_ENABLE 1 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_OPERATION_TIMEOUT_MS +#define CONFIG_TQ_DIRECT_PRINTER_OPERATION_TIMEOUT_MS 90000 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_STROBE_ON_US +#define CONFIG_TQ_DIRECT_PRINTER_STROBE_ON_US 1000 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_STROBE_INTERVAL_US +#define CONFIG_TQ_DIRECT_PRINTER_STROBE_INTERVAL_US 200 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_MOTOR_STEP_US +#define CONFIG_TQ_DIRECT_PRINTER_MOTOR_STEP_US 2000 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_MOTOR_REVERSE +#define CONFIG_TQ_DIRECT_PRINTER_MOTOR_REVERSE 0 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_STEPS_PER_LINE +#define CONFIG_TQ_DIRECT_PRINTER_STEPS_PER_LINE 2 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_GAP_STEPS +#define CONFIG_TQ_DIRECT_PRINTER_GAP_STEPS 96 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_TEMP_MIN_C +#define CONFIG_TQ_DIRECT_PRINTER_TEMP_MIN_C 15 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_TEMP_MAX_C +#define CONFIG_TQ_DIRECT_PRINTER_TEMP_MAX_C 55 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_BATTERY_MIN_PERCENT +#define CONFIG_TQ_DIRECT_PRINTER_BATTERY_MIN_PERCENT 5 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_PAPER_PRESENT_LEVEL +#define CONFIG_TQ_DIRECT_PRINTER_PAPER_PRESENT_LEVEL 0 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_BATTERY_EMPTY_MV +#define CONFIG_TQ_DIRECT_PRINTER_BATTERY_EMPTY_MV 3300 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_BATTERY_FULL_MV +#define CONFIG_TQ_DIRECT_PRINTER_BATTERY_FULL_MV 4200 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_BATTERY_DIVIDER_RATIO_X1000 +#define CONFIG_TQ_DIRECT_PRINTER_BATTERY_DIVIDER_RATIO_X1000 2000 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_NTC_PULLUP_OHMS +#define CONFIG_TQ_DIRECT_PRINTER_NTC_PULLUP_OHMS 10000 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_NTC_R25_OHMS +#define CONFIG_TQ_DIRECT_PRINTER_NTC_R25_OHMS 10000 +#endif + +#ifndef CONFIG_TQ_DIRECT_PRINTER_NTC_BETA +#define CONFIG_TQ_DIRECT_PRINTER_NTC_BETA 3950 +#endif + +#ifndef CONFIG_TQ_REST_PRINTER_CONNECT_TIMEOUT_MS +#define CONFIG_TQ_REST_PRINTER_CONNECT_TIMEOUT_MS 15000 +#endif + +#ifndef CONFIG_TQ_REST_LABEL_TIMEOUT_MS +#define CONFIG_TQ_REST_LABEL_TIMEOUT_MS 5000 +#endif + +#ifndef CONFIG_TQ_REST_OTA_STEP_TIMEOUT_MS +#define CONFIG_TQ_REST_OTA_STEP_TIMEOUT_MS 5000 +#endif + +#ifndef CONFIG_TQ_Z_IMAGE_TIMEOUT_MS +#define CONFIG_TQ_Z_IMAGE_TIMEOUT_MS 45000 +#endif + +#ifndef CONFIG_TQ_Z_IMAGE_DOWNLOAD_TIMEOUT_MS +#define CONFIG_TQ_Z_IMAGE_DOWNLOAD_TIMEOUT_MS 15000 +#endif + +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 uint16_t clamp_u16(uint16_t value, uint16_t min_value, uint16_t max_value) { + if (value < min_value) { + return min_value; + } + if (value > max_value) { + return max_value; + } + return value; +} + +static uint8_t clamp_u8(uint8_t value, uint8_t min_value, uint8_t max_value) { + if (value < min_value) { + return min_value; + } + if (value > max_value) { + return max_value; + } + return value; +} + +uint32_t runtime_policy_wifi_connect_timeout_ms(void) { + return clamp_u32(CONFIG_TQ_WIFI_CONNECT_TIMEOUT_MS, 3000, 60000); +} + +uint32_t runtime_policy_lifecycle_start_retry_count(void) { + return clamp_u32(CONFIG_TQ_LIFECYCLE_START_RETRY_COUNT, 0, 5); +} + +uint32_t runtime_policy_lifecycle_retry_backoff_ms(uint32_t attempt) { + uint32_t base = clamp_u32(CONFIG_TQ_LIFECYCLE_RETRY_BACKOFF_MS, 100, 10000); + if (attempt > 8) { + attempt = 8; + } + uint32_t factor = (1u << attempt); + return clamp_u32(base * factor, 100, 30000); +} + +bool runtime_policy_is_retryable_error(esp_err_t err) { + return err == ESP_ERR_TIMEOUT || err == ESP_FAIL || err == ESP_ERR_NO_MEM; +} + +uint32_t runtime_policy_printer_control_lock_timeout_ms(void) { + return clamp_u32(CONFIG_TQ_PRINTER_CONTROL_LOCK_TIMEOUT_MS, 100, 5000); +} + +uint32_t runtime_policy_printer_worker_queue_wait_ms(void) { + return clamp_u32(CONFIG_TQ_PRINTER_WORKER_QUEUE_WAIT_MS, 50, 2000); +} + +uint32_t runtime_policy_printer_queue_retry_delay_ms(void) { + return clamp_u32(CONFIG_TQ_PRINTER_QUEUE_RETRY_DELAY_MS, 5, 500); +} + +uint32_t runtime_policy_printer_status_poll_interval_ms(void) { + return clamp_u32(CONFIG_TQ_PRINTER_STATUS_POLL_INTERVAL_MS, 1000, 30000); +} + +uint32_t runtime_policy_printer_stop_timeout_ms(void) { + return clamp_u32(CONFIG_TQ_PRINTER_STOP_TIMEOUT_MS, 1000, 30000); +} + +uint8_t runtime_policy_printer_default_backend(void) { + return (uint8_t)clamp_u32(CONFIG_TQ_PRINTER_DEFAULT_BACKEND, 0, 1); +} + +bool runtime_policy_direct_printer_enabled(void) { + return CONFIG_TQ_DIRECT_PRINTER_ENABLE != 0; +} + +uint32_t runtime_policy_direct_printer_operation_timeout_ms(void) { + return clamp_u32(CONFIG_TQ_DIRECT_PRINTER_OPERATION_TIMEOUT_MS, 1000, 300000); +} + +uint16_t runtime_policy_direct_printer_strobe_on_us(void) { + return clamp_u16((uint16_t)CONFIG_TQ_DIRECT_PRINTER_STROBE_ON_US, 100, 10000); +} + +uint16_t runtime_policy_direct_printer_strobe_interval_us(void) { + return clamp_u16((uint16_t)CONFIG_TQ_DIRECT_PRINTER_STROBE_INTERVAL_US, 0, 10000); +} + +uint16_t runtime_policy_direct_printer_motor_step_us(void) { + return clamp_u16((uint16_t)CONFIG_TQ_DIRECT_PRINTER_MOTOR_STEP_US, 100, 20000); +} + +bool runtime_policy_direct_printer_motor_reverse(void) { + return CONFIG_TQ_DIRECT_PRINTER_MOTOR_REVERSE != 0; +} + +uint8_t runtime_policy_direct_printer_steps_per_line(void) { + return clamp_u8((uint8_t)CONFIG_TQ_DIRECT_PRINTER_STEPS_PER_LINE, 1, 8); +} + +uint16_t runtime_policy_direct_printer_gap_steps(void) { + return clamp_u16((uint16_t)CONFIG_TQ_DIRECT_PRINTER_GAP_STEPS, 1, 2000); +} + +float runtime_policy_direct_printer_temp_min_c(void) { + return (float)clamp_u32((uint32_t)CONFIG_TQ_DIRECT_PRINTER_TEMP_MIN_C, 0, 120); +} + +float runtime_policy_direct_printer_temp_max_c(void) { + return (float)clamp_u32((uint32_t)CONFIG_TQ_DIRECT_PRINTER_TEMP_MAX_C, 1, 150); +} + +uint8_t runtime_policy_direct_printer_battery_min_percent(void) { + return clamp_u8((uint8_t)CONFIG_TQ_DIRECT_PRINTER_BATTERY_MIN_PERCENT, 0, 100); +} + +uint8_t runtime_policy_direct_printer_paper_present_level(void) { + return clamp_u8((uint8_t)CONFIG_TQ_DIRECT_PRINTER_PAPER_PRESENT_LEVEL, 0, 1); +} + +uint16_t runtime_policy_direct_printer_battery_empty_mv(void) { + return clamp_u16((uint16_t)CONFIG_TQ_DIRECT_PRINTER_BATTERY_EMPTY_MV, 2500, 5000); +} + +uint16_t runtime_policy_direct_printer_battery_full_mv(void) { + uint16_t empty_mv = runtime_policy_direct_printer_battery_empty_mv(); + uint16_t full_mv = clamp_u16((uint16_t)CONFIG_TQ_DIRECT_PRINTER_BATTERY_FULL_MV, 2600, 6000); + if (full_mv <= empty_mv) { + full_mv = (uint16_t)(empty_mv + 100); + } + return full_mv; +} + +uint16_t runtime_policy_direct_printer_battery_divider_ratio_x1000(void) { + return clamp_u16((uint16_t)CONFIG_TQ_DIRECT_PRINTER_BATTERY_DIVIDER_RATIO_X1000, 1000, 10000); +} + +uint32_t runtime_policy_direct_printer_ntc_pullup_ohms(void) { + return clamp_u32(CONFIG_TQ_DIRECT_PRINTER_NTC_PULLUP_OHMS, 1000, 200000); +} + +uint32_t runtime_policy_direct_printer_ntc_r25_ohms(void) { + return clamp_u32(CONFIG_TQ_DIRECT_PRINTER_NTC_R25_OHMS, 1000, 200000); +} + +uint32_t runtime_policy_direct_printer_ntc_beta(void) { + return clamp_u32(CONFIG_TQ_DIRECT_PRINTER_NTC_BETA, 1000, 10000); +} + +uint32_t runtime_policy_rest_printer_connect_timeout_ms(void) { + return clamp_u32(CONFIG_TQ_REST_PRINTER_CONNECT_TIMEOUT_MS, 1000, 60000); +} + +uint32_t runtime_policy_rest_label_timeout_ms(void) { + return clamp_u32(CONFIG_TQ_REST_LABEL_TIMEOUT_MS, 500, 30000); +} + +uint32_t runtime_policy_rest_ota_timeout_ms(void) { + return clamp_u32(CONFIG_TQ_REST_OTA_STEP_TIMEOUT_MS, 500, 60000); +} + +uint32_t runtime_policy_image_generation_timeout_default_ms(void) { + return clamp_u32(CONFIG_TQ_Z_IMAGE_TIMEOUT_MS, 5000, 180000); +} + +uint32_t runtime_policy_image_generation_timeout_min_ms(void) { + return 5000; +} + +uint32_t runtime_policy_image_generation_timeout_max_ms(void) { + return 180000; +} + +uint32_t runtime_policy_image_download_timeout_default_ms(void) { + return clamp_u32(CONFIG_TQ_Z_IMAGE_DOWNLOAD_TIMEOUT_MS, 2000, 120000); +} + +uint32_t runtime_policy_image_download_timeout_min_ms(void) { + return 2000; +} + +uint32_t runtime_policy_image_download_timeout_max_ms(void) { + return 120000; +} diff --git a/components/platform/src/voice_audio.c b/components/platform/src/voice_audio.c new file mode 100644 index 0000000..9b6a8fa --- /dev/null +++ b/components/platform/src/voice_audio.c @@ -0,0 +1,496 @@ +#include "platform.h" + +#include +#include + +#include "audio_codec_if.h" +#include "driver/i2s_std.h" +#include "esp_check.h" +#include "esp_codec_dev.h" +#include "esp_codec_dev_defaults.h" +#include "esp_idf_version.h" +#include "esp_log.h" +#include "es8311_codec.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) && !defined(CONFIG_CODEC_I2C_BACKWARD_COMPATIBLE) +#include "driver/i2c_master.h" +#define VOICE_USE_IDF_I2C_MASTER +#else +#include "driver/i2c.h" +#endif + +static const char *TAG = "voice_audio"; +static const uint32_t VOICE_CODEC_I2C_PROBE_TIMEOUT_MS = 50; + +#define ES8311_CODEC_ALT_ADDR ((uint8_t)(ES8311_CODEC_DEFAULT_ADDR + 0x02)) + +#ifdef CONFIG_TQ_VOICE_CODEC_PA_PIN_REVERSED +#define VOICE_CODEC_PA_REVERSED true +#else +#define VOICE_CODEC_PA_REVERSED false +#endif + +static SemaphoreHandle_t s_lock; + +#ifdef VOICE_USE_IDF_I2C_MASTER +static i2c_master_bus_handle_t s_i2c_bus; +#endif + +static i2s_chan_handle_t s_i2s_tx; +static i2s_chan_handle_t s_i2s_rx; + +static const audio_codec_ctrl_if_t *s_codec_ctrl_if; +static const audio_codec_gpio_if_t *s_codec_gpio_if; +static const audio_codec_data_if_t *s_codec_data_if; +static const audio_codec_if_t *s_codec_if; +static esp_codec_dev_handle_t s_codec_dev; + +static bool s_initialized; +static bool s_opened; +static uint32_t s_sample_rate; +static uint8_t s_channels; +static uint8_t s_bits_per_sample; +static bool s_i2s_need_enable_before_open; + +static void fill_err(char *err, size_t err_len, const char *msg) { + if (err != NULL && err_len > 0) { + snprintf(err, err_len, "%s", msg != NULL ? msg : "unknown error"); + } +} + +static bool voice_audio_i2c_probe_addr(uint8_t addr_8bit) { +#ifdef VOICE_USE_IDF_I2C_MASTER + if (s_i2c_bus == NULL || addr_8bit == 0) { + return false; + } + uint16_t addr_7bit = (uint16_t)(addr_8bit >> 1); + return i2c_master_probe(s_i2c_bus, addr_7bit, (int)VOICE_CODEC_I2C_PROBE_TIMEOUT_MS) == ESP_OK; +#else + if (addr_8bit == 0) { + return false; + } + + i2c_cmd_handle_t cmd = i2c_cmd_link_create(); + if (cmd == NULL) { + return false; + } + + i2c_master_start(cmd); + i2c_master_write_byte(cmd, addr_8bit, true); + i2c_master_stop(cmd); + + esp_err_t err = i2c_master_cmd_begin(CONFIG_TQ_VOICE_I2C_PORT, + cmd, + pdMS_TO_TICKS(VOICE_CODEC_I2C_PROBE_TIMEOUT_MS)); + i2c_cmd_link_delete(cmd); + return err == ESP_OK; +#endif +} + +static uint8_t voice_audio_resolve_codec_addr(uint8_t preferred_addr) { + uint8_t candidates[3] = {preferred_addr, ES8311_CODEC_DEFAULT_ADDR, ES8311_CODEC_ALT_ADDR}; + for (size_t i = 0; i < 3; ++i) { + uint8_t addr = candidates[i]; + bool duplicate = false; + for (size_t j = 0; j < i; ++j) { + if (candidates[j] == addr) { + duplicate = true; + break; + } + } + if (duplicate) { + continue; + } + + if (voice_audio_i2c_probe_addr(addr)) { + return addr; + } + } + + ESP_LOGW(TAG, + "ES8311 not found on I2C(port=%d,sda=%d,scl=%d), tried addrs:0x%02x/0x%02x/0x%02x", + CONFIG_TQ_VOICE_I2C_PORT, + CONFIG_TQ_VOICE_I2C_SDA_PIN, + CONFIG_TQ_VOICE_I2C_SCL_PIN, + (unsigned)preferred_addr, + (unsigned)ES8311_CODEC_DEFAULT_ADDR, + (unsigned)ES8311_CODEC_ALT_ADDR); + return preferred_addr; +} + +static esp_err_t voice_audio_init_i2c(void) { +#ifdef VOICE_USE_IDF_I2C_MASTER + if (s_i2c_bus != NULL) { + return ESP_OK; + } + + i2c_master_bus_config_t bus_cfg = { + .clk_source = I2C_CLK_SRC_DEFAULT, + .i2c_port = CONFIG_TQ_VOICE_I2C_PORT, + .sda_io_num = CONFIG_TQ_VOICE_I2C_SDA_PIN, + .scl_io_num = CONFIG_TQ_VOICE_I2C_SCL_PIN, + .glitch_ignore_cnt = 7, + .flags.enable_internal_pullup = true, + }; + + esp_err_t err = i2c_new_master_bus(&bus_cfg, &s_i2c_bus); + if (err == ESP_OK) { + ESP_LOGI(TAG, + "voice i2c ready: port=%d sda=%d scl=%d", + CONFIG_TQ_VOICE_I2C_PORT, + CONFIG_TQ_VOICE_I2C_SDA_PIN, + CONFIG_TQ_VOICE_I2C_SCL_PIN); + } + return err; +#else + i2c_config_t i2c_cfg = { + .mode = I2C_MODE_MASTER, + .sda_io_num = CONFIG_TQ_VOICE_I2C_SDA_PIN, + .scl_io_num = CONFIG_TQ_VOICE_I2C_SCL_PIN, + .sda_pullup_en = GPIO_PULLUP_ENABLE, + .scl_pullup_en = GPIO_PULLUP_ENABLE, + .master.clk_speed = 100000, + }; + + ESP_RETURN_ON_ERROR(i2c_param_config(CONFIG_TQ_VOICE_I2C_PORT, &i2c_cfg), TAG, "i2c_param_config failed"); + esp_err_t err = i2c_driver_install(CONFIG_TQ_VOICE_I2C_PORT, i2c_cfg.mode, 0, 0, 0); + if (err == ESP_OK) { + ESP_LOGI(TAG, + "voice i2c ready: port=%d sda=%d scl=%d", + CONFIG_TQ_VOICE_I2C_PORT, + CONFIG_TQ_VOICE_I2C_SDA_PIN, + CONFIG_TQ_VOICE_I2C_SCL_PIN); + } + return err; +#endif +} + +static esp_err_t voice_audio_init_i2s(uint32_t sample_rate) { + if (s_i2s_tx != NULL && s_i2s_rx != NULL) { + return ESP_OK; + } + + i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG((i2s_port_t)CONFIG_TQ_VOICE_I2S_PORT, + I2S_ROLE_MASTER); + chan_cfg.dma_desc_num = 6; + chan_cfg.dma_frame_num = 240; + + ESP_RETURN_ON_ERROR(i2s_new_channel(&chan_cfg, &s_i2s_tx, &s_i2s_rx), TAG, "i2s_new_channel failed"); + + i2s_std_config_t std_cfg = { + .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sample_rate), + .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO), + .gpio_cfg = { + .mclk = CONFIG_TQ_VOICE_I2S_MCLK_PIN, + .bclk = CONFIG_TQ_VOICE_I2S_BCLK_PIN, + .ws = CONFIG_TQ_VOICE_I2S_WS_PIN, + .dout = CONFIG_TQ_VOICE_I2S_DOUT_PIN, + .din = CONFIG_TQ_VOICE_I2S_DIN_PIN, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }, + }; + + esp_err_t err = i2s_channel_init_std_mode(s_i2s_tx, &std_cfg); + if (err != ESP_OK) { + return err; + } + + err = i2s_channel_init_std_mode(s_i2s_rx, &std_cfg); + if (err != ESP_OK) { + return err; + } + + ESP_RETURN_ON_ERROR(i2s_channel_enable(s_i2s_tx), TAG, "i2s tx enable failed"); + ESP_RETURN_ON_ERROR(i2s_channel_enable(s_i2s_rx), TAG, "i2s rx enable failed"); + return ESP_OK; +} + +static esp_err_t voice_audio_build_codec_dev(void) { + if (s_codec_dev != NULL) { + return ESP_OK; + } + + uint8_t codec_addr = voice_audio_resolve_codec_addr((uint8_t)CONFIG_TQ_VOICE_CODEC_ADDR); + if (codec_addr != (uint8_t)CONFIG_TQ_VOICE_CODEC_ADDR) { + ESP_LOGW(TAG, + "override ES8311 I2C addr 0x%02x -> 0x%02x", + (unsigned)CONFIG_TQ_VOICE_CODEC_ADDR, + (unsigned)codec_addr); + } + ESP_LOGI(TAG, + "voice codec cfg: i2c_port=%d addr=0x%02x i2s_port=%d mclk=%d bclk=%d ws=%d dout=%d din=%d", + CONFIG_TQ_VOICE_I2C_PORT, + (unsigned)codec_addr, + CONFIG_TQ_VOICE_I2S_PORT, + CONFIG_TQ_VOICE_I2S_MCLK_PIN, + CONFIG_TQ_VOICE_I2S_BCLK_PIN, + CONFIG_TQ_VOICE_I2S_WS_PIN, + CONFIG_TQ_VOICE_I2S_DOUT_PIN, + CONFIG_TQ_VOICE_I2S_DIN_PIN); + + audio_codec_i2c_cfg_t i2c_cfg = { + .port = (uint8_t)CONFIG_TQ_VOICE_I2C_PORT, + .addr = codec_addr, +#ifdef VOICE_USE_IDF_I2C_MASTER + .bus_handle = s_i2c_bus, +#endif + }; + + s_codec_ctrl_if = audio_codec_new_i2c_ctrl(&i2c_cfg); + if (s_codec_ctrl_if == NULL) { + return ESP_ERR_NO_MEM; + } + + audio_codec_i2s_cfg_t i2s_cfg = { + .port = (uint8_t)CONFIG_TQ_VOICE_I2S_PORT, + .rx_handle = s_i2s_rx, + .tx_handle = s_i2s_tx, + }; + + s_codec_data_if = audio_codec_new_i2s_data(&i2s_cfg); + if (s_codec_data_if == NULL) { + return ESP_ERR_NO_MEM; + } + + s_codec_gpio_if = audio_codec_new_gpio(); + if (s_codec_gpio_if == NULL) { + return ESP_ERR_NO_MEM; + } + + es8311_codec_cfg_t codec_cfg = { + .ctrl_if = s_codec_ctrl_if, + .gpio_if = s_codec_gpio_if, + .codec_mode = ESP_CODEC_DEV_WORK_MODE_BOTH, + .pa_pin = CONFIG_TQ_VOICE_CODEC_PA_PIN, + .pa_reverted = VOICE_CODEC_PA_REVERSED, + .master_mode = false, + .use_mclk = (CONFIG_TQ_VOICE_I2S_MCLK_PIN >= 0), + .digital_mic = false, + .invert_mclk = false, + .invert_sclk = false, + }; + + s_codec_if = es8311_codec_new(&codec_cfg); + if (s_codec_if == NULL) { + ESP_LOGE(TAG, "es8311_codec_new failed, check I2C wiring/pull-up/address"); + return ESP_FAIL; + } + + esp_codec_dev_cfg_t dev_cfg = { + .dev_type = ESP_CODEC_DEV_TYPE_IN_OUT, + .codec_if = s_codec_if, + .data_if = s_codec_data_if, + }; + + s_codec_dev = esp_codec_dev_new(&dev_cfg); + if (s_codec_dev == NULL) { + return ESP_ERR_NO_MEM; + } + + return ESP_OK; +} + +static esp_err_t voice_audio_prepare(const voice_audio_open_config_t *cfg) { + ESP_RETURN_ON_ERROR(voice_audio_init_i2c(), TAG, "i2c init failed"); + ESP_RETURN_ON_ERROR(voice_audio_init_i2s(cfg->sample_rate), TAG, "i2s init failed"); + ESP_RETURN_ON_ERROR(voice_audio_build_codec_dev(), TAG, "codec dev init failed"); + return ESP_OK; +} + +static esp_err_t voice_audio_reenable_i2s_if_needed(void) { + if (!s_i2s_need_enable_before_open) { + return ESP_OK; + } + if (s_i2s_tx == NULL || s_i2s_rx == NULL) { + s_i2s_need_enable_before_open = false; + return ESP_OK; + } + + esp_err_t tx_rc = i2s_channel_enable(s_i2s_tx); + if (tx_rc != ESP_OK && tx_rc != ESP_ERR_INVALID_STATE) { + return tx_rc; + } + + esp_err_t rx_rc = i2s_channel_enable(s_i2s_rx); + if (rx_rc != ESP_OK && rx_rc != ESP_ERR_INVALID_STATE) { + return rx_rc; + } + + s_i2s_need_enable_before_open = false; + return ESP_OK; +} + +esp_err_t voice_audio_open(const voice_audio_open_config_t *cfg, char *err, size_t err_len) { + if (cfg == NULL || cfg->sample_rate == 0 || cfg->channels == 0 || cfg->bits_per_sample != 16) { + fill_err(err, err_len, "invalid voice audio config"); + return ESP_ERR_INVALID_ARG; + } + + if (s_lock == NULL) { + s_lock = xSemaphoreCreateMutex(); + if (s_lock == NULL) { + fill_err(err, err_len, "create mutex failed"); + return ESP_ERR_NO_MEM; + } + } + + if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + fill_err(err, err_len, "voice audio lock timeout"); + return ESP_ERR_TIMEOUT; + } + + if (s_opened) { + xSemaphoreGive(s_lock); + return ESP_OK; + } + + esp_err_t init_err = voice_audio_prepare(cfg); + if (init_err != ESP_OK) { + if (init_err == ESP_FAIL) { + fill_err(err, err_len, "es8311 init failed, check i2c wiring/address"); + } else { + fill_err(err, err_len, "voice audio peripheral init failed"); + } + xSemaphoreGive(s_lock); + return init_err; + } + + if (s_initialized && (s_sample_rate != cfg->sample_rate || + s_channels != cfg->channels || + s_bits_per_sample != cfg->bits_per_sample)) { + fill_err(err, err_len, "voice audio format changed after init"); + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_STATE; + } + + esp_err_t i2s_rc = voice_audio_reenable_i2s_if_needed(); + if (i2s_rc != ESP_OK) { + fill_err(err, err_len, "i2s re-enable failed"); + xSemaphoreGive(s_lock); + return i2s_rc; + } + + esp_codec_dev_sample_info_t fs = { + .sample_rate = cfg->sample_rate, + .channel = cfg->channels, + .bits_per_sample = cfg->bits_per_sample, + }; + + int rc = esp_codec_dev_open(s_codec_dev, &fs); + if (rc != ESP_CODEC_DEV_OK) { + fill_err(err, err_len, "esp_codec_dev_open failed"); + xSemaphoreGive(s_lock); + return ESP_FAIL; + } + + (void)esp_codec_dev_set_out_vol(s_codec_dev, cfg->output_volume); + (void)esp_codec_dev_set_in_gain(s_codec_dev, cfg->input_gain_db); + (void)esp_codec_dev_set_out_mute(s_codec_dev, false); + (void)esp_codec_dev_set_in_mute(s_codec_dev, false); + + s_sample_rate = cfg->sample_rate; + s_channels = cfg->channels; + s_bits_per_sample = cfg->bits_per_sample; + s_initialized = true; + s_opened = true; + + ESP_LOGI(TAG, + "voice audio opened: sr=%u ch=%u bits=%u", + (unsigned)s_sample_rate, + (unsigned)s_channels, + (unsigned)s_bits_per_sample); + + xSemaphoreGive(s_lock); + return ESP_OK; +} + +void voice_audio_close(void) { + if (s_lock == NULL) { + return; + } + if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + return; + } + + if (s_opened) { + (void)esp_codec_dev_set_in_mute(s_codec_dev, true); + (void)esp_codec_dev_set_out_mute(s_codec_dev, true); + (void)esp_codec_dev_close(s_codec_dev); + s_opened = false; + s_i2s_need_enable_before_open = true; + } + + xSemaphoreGive(s_lock); +} + +bool voice_audio_is_open(void) { + return s_opened; +} + +esp_err_t voice_audio_read_pcm(int16_t *pcm, + size_t samples, + uint32_t timeout_ms, + char *err, + size_t err_len) { + if (pcm == NULL || samples == 0) { + fill_err(err, err_len, "invalid read args"); + return ESP_ERR_INVALID_ARG; + } + + if (s_lock == NULL || xSemaphoreTake(s_lock, pdMS_TO_TICKS(timeout_ms)) != pdTRUE) { + fill_err(err, err_len, "voice read lock timeout"); + return ESP_ERR_TIMEOUT; + } + + if (!s_opened) { + fill_err(err, err_len, "voice audio not opened"); + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_STATE; + } + + int rc = esp_codec_dev_read(s_codec_dev, pcm, (int)(samples * sizeof(int16_t))); + xSemaphoreGive(s_lock); + if (rc != ESP_CODEC_DEV_OK) { + fill_err(err, err_len, "codec read failed"); + return ESP_FAIL; + } + + return ESP_OK; +} + +esp_err_t voice_audio_write_pcm(const int16_t *pcm, + size_t samples, + uint32_t timeout_ms, + char *err, + size_t err_len) { + if (pcm == NULL || samples == 0) { + fill_err(err, err_len, "invalid write args"); + return ESP_ERR_INVALID_ARG; + } + + if (s_lock == NULL || xSemaphoreTake(s_lock, pdMS_TO_TICKS(timeout_ms)) != pdTRUE) { + fill_err(err, err_len, "voice write lock timeout"); + return ESP_ERR_TIMEOUT; + } + + if (!s_opened) { + fill_err(err, err_len, "voice audio not opened"); + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_STATE; + } + + int rc = esp_codec_dev_write(s_codec_dev, (void *)pcm, (int)(samples * sizeof(int16_t))); + xSemaphoreGive(s_lock); + if (rc != ESP_CODEC_DEV_OK) { + fill_err(err, err_len, "codec write failed"); + return ESP_FAIL; + } + + return ESP_OK; +} diff --git a/components/platform/src/wifi_manager.c b/components/platform/src/wifi_manager.c new file mode 100644 index 0000000..24f986a --- /dev/null +++ b/components/platform/src/wifi_manager.c @@ -0,0 +1,325 @@ +#include "platform.h" + +#include +#include + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_wifi.h" +#include "esp_wifi_default.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" + +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 + +static const char *TAG = "wifi_manager"; + +static EventGroupHandle_t s_wifi_event_group; +static int s_retry_num; +static bool s_ready; +static bool s_started; +static esp_netif_t *s_sta_netif; +static esp_event_handler_instance_t s_wifi_event_inst; +static esp_event_handler_instance_t s_ip_event_inst; + +static void wifi_manager_apply_sta_throughput_profile(void) { + const uint8_t protocol = WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N; + esp_err_t proto_rc = esp_wifi_set_protocol(WIFI_IF_STA, protocol); + if (proto_rc != ESP_OK) { + ESP_LOGW(TAG, "failed to set 11bgn protocol mask, rc=0x%x", (unsigned)proto_rc); + } else { + ESP_LOGI(TAG, "Wi-Fi protocol mask set to 11b/11g/11n"); + } + + esp_err_t bw_rc = esp_wifi_set_bandwidth(WIFI_IF_STA, WIFI_BW_HT40); + if (bw_rc == ESP_OK) { + ESP_LOGI(TAG, "Wi-Fi bandwidth set to HT40"); + return; + } + + ESP_LOGW(TAG, "failed to set HT40, fallback to HT20, rc=0x%x", (unsigned)bw_rc); + bw_rc = esp_wifi_set_bandwidth(WIFI_IF_STA, WIFI_BW_HT20); + if (bw_rc != ESP_OK) { + ESP_LOGW(TAG, "failed to set HT20 fallback, rc=0x%x", (unsigned)bw_rc); + } else { + ESP_LOGI(TAG, "Wi-Fi bandwidth set to HT20"); + } +} + +static void wifi_event_handler(void *arg, + esp_event_base_t event_base, + int32_t event_id, + void *event_data) { + (void)arg; + (void)event_data; + + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { + if (!s_started) { + return; + } + (void)esp_wifi_connect(); + return; + } + + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + s_ready = false; + if (!s_started) { + return; + } + if (s_retry_num < CONFIG_TQ_WIFI_MAXIMUM_RETRY) { + (void)esp_wifi_connect(); + s_retry_num++; + ESP_LOGW(TAG, "retry to connect to the AP (%d/%d)", s_retry_num, CONFIG_TQ_WIFI_MAXIMUM_RETRY); + } else if (s_wifi_event_group != NULL) { + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + return; + } + + if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + if (!s_started) { + return; + } + ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; + ESP_LOGI(TAG, "got ip:" IPSTR, IP2STR(&event->ip_info.ip)); + s_retry_num = 0; + s_ready = true; + if (s_wifi_event_group != NULL) { + xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + } + return; + } +} + +static esp_err_t start_sta_mode(void) { + wifi_config_t wifi_config = {0}; + if (strlen(CONFIG_TQ_WIFI_SSID) == 0) { + ESP_LOGE(TAG, "CONFIG_TQ_WIFI_SSID is empty; STA-only mode requires valid SSID"); + return ESP_ERR_INVALID_STATE; + } + + strlcpy((char *)wifi_config.sta.ssid, CONFIG_TQ_WIFI_SSID, sizeof(wifi_config.sta.ssid)); + strlcpy((char *)wifi_config.sta.password, CONFIG_TQ_WIFI_PASSWORD, sizeof(wifi_config.sta.password)); + wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + wifi_config.sta.pmf_cfg.capable = true; + wifi_config.sta.pmf_cfg.required = false; + + esp_err_t rc = esp_wifi_set_mode(WIFI_MODE_STA); + if (rc != ESP_OK) { + return rc; + } + rc = esp_wifi_set_config(WIFI_IF_STA, &wifi_config); + if (rc != ESP_OK) { + return rc; + } + rc = esp_wifi_start(); + if (rc != ESP_OK && rc != ESP_ERR_INVALID_STATE) { + return rc; + } + + ESP_LOGI(TAG, "wifi_init_sta finished"); + + EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group, + WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, + pdFALSE, + pdFALSE, + pdMS_TO_TICKS(runtime_policy_wifi_connect_timeout_ms())); + + if (bits & WIFI_CONNECTED_BIT) { + ESP_LOGI(TAG, "connected to AP SSID:%s", CONFIG_TQ_WIFI_SSID); + wifi_manager_apply_sta_throughput_profile(); + esp_err_t ps_rc = esp_wifi_set_ps(WIFI_PS_NONE); + if (ps_rc == ESP_OK) { + ESP_LOGI(TAG, "Wi-Fi power save disabled (WIFI_PS_NONE)"); + } else { + ESP_LOGW(TAG, "failed to set WIFI_PS_NONE, rc=0x%x", (unsigned)ps_rc); + } + s_ready = true; + return ESP_OK; + } + + if (bits & WIFI_FAIL_BIT) { + ESP_LOGW(TAG, "Failed to connect to SSID:%s", CONFIG_TQ_WIFI_SSID); + return ESP_FAIL; + } + + ESP_LOGW(TAG, "Wi-Fi connect timeout"); + return ESP_ERR_TIMEOUT; +} + +esp_err_t wifi_manager_start(void) { + if (s_started) { + return s_ready ? ESP_OK : ESP_ERR_INVALID_STATE; + } + + s_retry_num = 0; + s_ready = false; + + if (s_wifi_event_group == NULL) { + s_wifi_event_group = xEventGroupCreate(); + if (s_wifi_event_group == NULL) { + return ESP_ERR_NO_MEM; + } + } + xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT); + + esp_err_t err = esp_netif_init(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + runtime_diag_record_error("wifi_start", err, "esp_netif_init failed"); + return err; + } + + err = esp_event_loop_create_default(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + runtime_diag_record_error("wifi_start", err, "event loop init failed"); + return err; + } + + if (s_sta_netif == NULL) { + s_sta_netif = esp_netif_create_default_wifi_sta(); + if (s_sta_netif == NULL) { + runtime_diag_record_error("wifi_start", ESP_ERR_NO_MEM, "create sta netif failed"); + return ESP_ERR_NO_MEM; + } + } + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + err = esp_wifi_init(&cfg); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + runtime_diag_record_error("wifi_start", err, "esp_wifi_init failed"); + return err; + } + + if (s_wifi_event_inst == NULL) { + err = esp_event_handler_instance_register(WIFI_EVENT, + ESP_EVENT_ANY_ID, + &wifi_event_handler, + NULL, + &s_wifi_event_inst); + if (err != ESP_OK) { + runtime_diag_record_error("wifi_start", err, "register WIFI event failed"); + return err; + } + } + + if (s_ip_event_inst == NULL) { + err = esp_event_handler_instance_register(IP_EVENT, + IP_EVENT_STA_GOT_IP, + &wifi_event_handler, + NULL, + &s_ip_event_inst); + if (err != ESP_OK) { + runtime_diag_record_error("wifi_start", err, "register IP event failed"); + return err; + } + } + + s_started = true; + err = start_sta_mode(); + if (err == ESP_OK) { + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_WIFI_CONNECT_SUCCESS, 1); + } else { + if (err == ESP_ERR_TIMEOUT) { + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_WIFI_CONNECT_TIMEOUT, 1); + } else { + runtime_diag_counter_add(RUNTIME_DIAG_COUNTER_WIFI_CONNECT_FAILED, 1); + } + runtime_diag_record_error("wifi_connect", err, "STA connect failed"); + (void)wifi_manager_stop(); + } + return err; +} + +esp_err_t wifi_manager_stop(void) { + if (!s_started) { + return ESP_OK; + } + + esp_err_t first_err = ESP_OK; + + s_ready = false; + s_retry_num = 0; + + if (s_wifi_event_inst != NULL) { + esp_err_t err = esp_event_handler_instance_unregister(WIFI_EVENT, + ESP_EVENT_ANY_ID, + s_wifi_event_inst); + if (err != ESP_OK && first_err == ESP_OK) { + first_err = err; + } + s_wifi_event_inst = NULL; + } + + if (s_ip_event_inst != NULL) { + esp_err_t err = esp_event_handler_instance_unregister(IP_EVENT, + IP_EVENT_STA_GOT_IP, + s_ip_event_inst); + if (err != ESP_OK && first_err == ESP_OK) { + first_err = err; + } + s_ip_event_inst = NULL; + } + + esp_err_t err = esp_wifi_disconnect(); + if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_STARTED && err != ESP_ERR_WIFI_CONN) { + if (first_err == ESP_OK) { + first_err = err; + } + } + + err = esp_wifi_stop(); + if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_INIT && err != ESP_ERR_WIFI_NOT_STARTED) { + if (first_err == ESP_OK) { + first_err = err; + } + } + + err = esp_wifi_deinit(); + if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_INIT) { + if (first_err == ESP_OK) { + first_err = err; + } + } + + if (s_sta_netif != NULL) { + esp_netif_destroy_default_wifi(s_sta_netif); + s_sta_netif = NULL; + } + + if (s_wifi_event_group != NULL) { + vEventGroupDelete(s_wifi_event_group); + s_wifi_event_group = NULL; + } + + s_started = false; + + if (first_err != ESP_OK) { + runtime_diag_record_error("wifi_stop", first_err, "wifi stop failed"); + } + + return first_err; +} + +bool wifi_manager_is_ready(void) { + return s_ready; +} + +void wifi_manager_get_ip(char *buf, size_t buf_len) { + if (buf == NULL || buf_len == 0) { + return; + } + + buf[0] = '\0'; + + esp_netif_ip_info_t ip_info; + memset(&ip_info, 0, sizeof(ip_info)); + + if (s_sta_netif != NULL && esp_netif_get_ip_info(s_sta_netif, &ip_info) == ESP_OK) { + snprintf(buf, buf_len, IPSTR, IP2STR(&ip_info.ip)); + return; + } + + strlcpy(buf, "0.0.0.0", buf_len); +} diff --git a/dependencies.lock b/dependencies.lock new file mode 100644 index 0000000..1944d13 --- /dev/null +++ b/dependencies.lock @@ -0,0 +1,76 @@ +dependencies: + espressif/esp_audio_codec: + component_hash: af4663830bc73eca58febcc34a976d003465e78ed57458aa00a14b231e392ca1 + dependencies: + - name: idf + require: private + version: '>=4.4' + source: + registry_url: https://components.espressif.com/ + type: service + version: 2.4.1 + espressif/esp_codec_dev: + component_hash: 0d9e9bc288156eb55f79338d312e1ebf8c9dfbd5e7d13ef0f20ccb031b4e15cf + dependencies: + - name: idf + require: private + version: '>=4.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.5.4 + espressif/esp_websocket_client: + component_hash: c5a067a9fddea370c478017e66fac302f4b79c3d4027e9bdd42a019786cceb92 + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.6.1 + espressif/libpng: + component_hash: 8c426d10dc2bf187aafd84ef42834e1a44835ed08a293d78683eb7811d8f8c25 + dependencies: + - name: idf + require: private + version: '>=5.0' + - name: espressif/zlib + registry_url: https://components.espressif.com + require: private + version: ^1.2.13 + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.6.55 + espressif/zlib: + component_hash: 64ea2fd6754c5a05951f1ddb312ac835cd782e68cb5bd9dfcd1c3a107c8fbfbc + dependencies: + - name: idf + require: private + version: '>=4.4' + source: + registry_url: https://components.espressif.com + type: service + version: 1.3.2 + idf: + source: + type: idf + version: 5.5.2 + lvgl/lvgl: + component_hash: d7c1ac037ae6e85d94897f807d6e7ba0946a83e720074fc95a4f6241da9f9f53 + dependencies: [] + source: + registry_url: https://components.espressif.com/ + type: service + version: 8.4.0 +direct_dependencies: +- espressif/esp_audio_codec +- espressif/esp_codec_dev +- espressif/esp_websocket_client +- espressif/libpng +- idf +- lvgl/lvgl +manifest_hash: d32a494b7c86c11954a7b0354da8967836fc6789173852455b8c0611e1898436 +target: esp32s3 +version: 2.0.0 diff --git a/dependency_whitelist.md b/dependency_whitelist.md new file mode 100644 index 0000000..a77af50 --- /dev/null +++ b/dependency_whitelist.md @@ -0,0 +1,13 @@ +# Dependency Whitelist + +Use this file only when a same-level or lateral dependency is unavoidable. + +Entry format: +- requester: `/` +- target: `/` +- reason: short technical justification +- owner: person/team +- expiry: YYYY-MM-DD (must be reviewed before expiry) + +Current entries: +- none diff --git a/docs/gpio-map.md b/docs/gpio-map.md new file mode 100644 index 0000000..d36bbe3 --- /dev/null +++ b/docs/gpio-map.md @@ -0,0 +1,65 @@ +# ESP32-S3 GPIO Mapping + +本文件记录当前板级 GPIO 分配,来源于硬件对接表(2026-02-26)。 + +## GPIO Table + +| Board Pin | GPIO | Signal | Direction | Notes | +| --- | --- | --- | --- | --- | +| 1 | GND | GND | - | Ground | +| 2 | 3V3 | 3V3 | - | 3.3V Power | +| 3 | EN/RST | EN | Input | Chip enable/reset | +| 4 | IO4 | PRINT_STB5_6 | Output | Printer strobe 5/6 | +| 5 | IO5 | PRINT_STB3_4 | Output | Printer strobe 3/4 | +| 6 | IO6 | ES8311_I2C_SDA | Bidirectional | Voice codec I2C SDA | +| 7 | IO7 | ES8311_I2C_SCL | Output | Voice codec I2C SCL | +| 8 | IO15 | BATTERY_ADC | Input | Battery voltage ADC detect | +| 9 | IO16 | PRINT_STB1_2 | Output | Printer strobe 1/2 | +| 10 | IO17 | PRINT_PAPER | Input | Paper detect | +| 11 | IO18 | NTC_ADC | Input | NTC ADC | +| 12 | IO8 | PRINT_OUTA_P | Output | Printer OUTA+ | +| 13 | IO19 | PRINT_OUTA_N | Output | Printer OUTA- | +| 14 | IO20 | ES8311_PA | Output | PA control | +| 15 | IO3 | PRINT_OUTB_P | Output | Printer OUTB+ | +| 16 | IO46 | PRINT_OUTB_N | Output | Printer OUTB- | +| 17 | IO9 | SPI_SHARED_MOSI | Output | Printer/Screen shared MOSI | +| 18 | IO10 | PRINTER_MISO | Input | Printer MISO | +| 19 | IO11 | SPI_SHARED_CLK | Output | Printer/Screen shared CLK | +| 20 | IO12 | SCREEN_DC | Output | ST7789 DC | +| 21 | IO13 | SCREEN_CS1 | Output | ST7789 CS | +| 22 | IO14 | MIC_KEY | Input | Mic key (can ADC-mux 2 keys if needed) | +| 23 | IO21 | LED_G | Output | Green LED | +| 24 | IO47 | POWER_KEY | Input | Hold low for 2s to request power-off logic | +| 25 | IO48 | SCREEN_BACKLIGHT | Output | High level turns backlight on | +| 26 | IO45 | PRINT_LAT | Output | Printer LAT | +| 27 | IO0 | KEY_PRINT / BOOST_CTRL | Input/Output | Print confirm key or 7.2V boost control | +| 28 | IO35 | N/A | - | Not available | +| 29 | IO36 | N/A | - | Not available | +| 30 | IO37 | N/A | - | Not available | +| 31 | IO38 | I2S_MCLK | Output | Voice I2S MCLK | +| 32 | IO39 | I2S_BCLK | Output | Voice I2S BCLK | +| 33 | IO40 | I2S_DI | Input | Voice I2S data in | +| 34 | IO41 | I2S_WS | Output | Voice I2S WS | +| 35 | IO42 | I2S_DO | Output | Voice I2S data out | +| 36 | TXD0 | UART0_TX | Output | Serial log/program | +| 37 | RXD0 | UART0_RX | Input | Serial log/program | +| 38 | IO2 | LED_R | Output | Red LED | +| 39 | IO1 | POWER_HOLD | Output | Must drive high at boot to keep board powered | +| 40 | GND | GND | - | Ground | +| 41 | GND | GND | - | Ground | + +## Firmware Mapping Notes + +- `main/Kconfig.projbuild` contains board pin configs prefixed with `TQ_*`. +- `components/platform/src/platform_bootstrap.c` initializes critical GPIO on startup: + - `POWER_HOLD` asserted high early. + - `SCREEN_DC/SCREEN_CS/SCREEN_BACKLIGHT` default states. + - `KEY_PRINT` defaults to boost-enable output mode (`CONFIG_TQ_KEY_PRINT_BOOST_ENABLE_ON_BOOT=y`). + - key/sensor inputs (`POWER_KEY`, `MIC_KEY`, `PRINT_PAPER`, ADC inputs) default directions. +- `components/platform/src/display_st7789.c` provides ST7789 driver integration: + - SPI bus (`MOSI=IO9`, `CLK=IO11`) + `esp_lcd_new_panel_st7789`. + - Optional boot test pattern via `CONFIG_TQ_SCREEN_TEST_PATTERN_ON_BOOT`. +- Voice codec and I2S pin defaults are aligned to this table: + - I2C: `SDA=6`, `SCL=7` + - I2S: `MCLK=38`, `BCLK=39`, `WS=41`, `DIN=40`, `DOUT=42` + - PA: `IO20` diff --git a/docs/websocket.md b/docs/websocket.md new file mode 100644 index 0000000..c1b01d4 --- /dev/null +++ b/docs/websocket.md @@ -0,0 +1,1164 @@ +本文介绍基于 WebSocket 协议的实时多模态交互 API。WebSocket协议延迟低、资源占用少,是首选接入方案。 + +WebSocket是一种支持全双工通信的网络协议。客户端和服务器通过一次握手建立持久连接,双方可以互相主动推送数据,因此在实时性和效率方面具有显著优势。 + +对于常用编程语言,有许多现成的WebSocket库和示例可供参考,例如: + +- Go:`gorilla/websocket` + +- PHP:`Ratchet` + +- Node.js:`ws` + + +建议您先了解WebSocket的基本原理和技术细节,再参照本文进行开发。 + +**说明** + +对于RTOS或某些旧版Linux系统,建立安全Websocket 通信所依赖的 TLS 隧道,需要做如下配置: + +- TLS 版本要求 TLS1.2 或以上 + +- 开启 SNI(SERVER NAME INDICATION) + +- 配置 CA 证书([GlobalSign Root CA - R3](https://secure.globalsign.net/cacert/Root-R3.crt))(也可至GlobalSign官网下载) + + +## **前提条件** + +已开通服务并[获取API Key](https://help.aliyun.com/zh/model-studio/get-api-key)。请[配置API Key到环境变量](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables),而非硬编码在代码中,防范因代码泄露导致的安全风险。 + +**说明** + +对于客户端调用的场景,在客户端处理API Key有安全风险,建议从服务端用API Key获取临时鉴权Token,再把Token下发给客户端使用。具体方法请参考:[生成临时 API Key](https://help.aliyun.com/zh/model-studio/generate-temporary-api-key)。 + +## **调用时序图** + +**\[关键流程\]** 服务端返回`Started`消息表示会话创建成功,但客户端**禁止**立即发送音频。客户端必须等待并接收到`DialogStateChanged`事件且`state`为`Listening`后,方可开始发送音频流。 + +![image](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/8963248471/p958632.png) + +## **服务地址** + +``` +wss://dashscope.aliyuncs.com/api-ws/v1/inference +``` + +## **鉴权** + +需要在发起初始的WebSocket握手(HTTP Upgrade)请求时,把API Key放在HTTP Header里(需要将your\_api\_key替换为真实的API Key): + +``` +"Authorization": "Bearer your_api_key" +``` + +## **语音交互** + +多模态交互应用开启了**语音交互**后,支持语音识别和语音合成。 + +语音识别支持的模型包括:[Gummy实时语音识别](https://help.aliyun.com/zh/model-studio/real-time-speech-recognition-and-translation-api-reference/)(Gummy),[Paraformer实时语音识别](https://help.aliyun.com/zh/model-studio/paraformer-real-time-speech-recognition-api-reference/)(Paraformer),[FUN-ASR实时语音识别](https://help.aliyun.com/zh/model-studio/fun-asr-real-time-speech-recognition-api-reference/)(FunASR),[千问3-ASR-Flash-Realtime](https://help.aliyun.com/zh/model-studio/qwen-real-time-speech-recognition)(qwen3-asr-flash-realtime),多模态交互轻量版语音识别(AppSpecificASR-Realtime)。 + +语音合成支持的模型包括:[语音合成CosyVoice-v2大模型](https://help.aliyun.com/zh/model-studio/cosyvoice-large-model-for-speech-synthesis/)(cosyvoice-v2),[语音合成CosyVoice-v3-plus大模型](https://help.aliyun.com/zh/model-studio/text-to-speech)(cosyvoice-v3-plus),[语音合成CosyVoice-v3-Flash大模型](https://help.aliyun.com/zh/model-studio/text-to-speech)(cosyvoice-v3-flash),[Sambert语音合成](https://help.aliyun.com/zh/model-studio/sambert-speech-synthesis/)(sambert),[千问3-TTS](https://help.aliyun.com/zh/model-studio/qwen-tts-realtime)(qwen3-tts),多模态交互轻量版语音合成(AppSpecificTTS)。 + +语音合成支持的音色,可以在控制台上选择了模型后,点击右侧语音交互体验区域的右上角查看音色列表。 + +官方音色也可以参考官方文档:cosyvoice-v2 / cosyvoice-v3-plus / cosyvoice-v3-flash 支持的官方音色参考[音色列表](https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list),qwen3-tts 支持的官方音色参考[支持的音色](https://help.aliyun.com/zh/model-studio/qwen-tts-realtime#422789c49bqqx),sambert支持的音色参考[模型列表](https://help.aliyun.com/zh/model-studio/sambert-java-sdk#74cedcb97el0b)(去掉开头的"sambert-"和末尾的"-v1"后就是voice的取值)。 + +使用复刻音色时,确认复刻音色状态为“OK”后才能使用。查询方法参考[查询指定音色](https://help.aliyun.com/zh/model-studio/cosyvoice-clone-api#34490e5a2by7z)。 + +## **消息类型** + +### **二进制消息(Binary Message)** + +当前二进制消息仅包含音频数据。 + +#### **上传音频** + +上传音频时,将原始音频直接转为二进制流即可,无需额外处理。 + +上传的语音识别音频需满足:16bit(采样位深)、单声道、有符号、little-endian PCM编码,采样率参考[Start](#5b2363d40585y)消息的参数parameters.upstream.sample\_rate的取值说明。 + +如果希望减少网络流量和带宽占用,用户可以把PCM音频编码为Opus格式,同时设置上传音频格式为raw-opus。 + +上传音频时,根据[Start](#5b2363d40585y)消息中`upstream.mode`设置不同,采取的措施也不同: + +- mode为 `tap2talk` 或 `duplex`:客户端需持续上传音频,服务端自动检测语音活动。建议每100ms上传一次数据,间隔太长或太短会对延时和处理效率造成负面影响。 + + - 音频上传速率计算公式:`数据每次上传的字节数 = 采样率 * 采样位深/8 * 时间间隔(ms)/ 1000` + + - 以16kHz采样率、16bit位深、100ms间隔为例,每次应上传 `16000 * (16/8) * 100 / 1000 = 3200` 字节的PCM数据。 + +- mode为 `push2talk`:客户端无需持续上传音频,但需通过[SendSpeech](#cec23c1556u8x)和[StopSpeech](#d11933e7b5jz0)通知服务端音频识别的开始和结束。发送[SendSpeech](#cec23c1556u8x)后需立即上传音频,否则会增加处理时间。 + + +#### **下发音频** + +服务端将大模型回复发送至TTS生成语音然后下发给客户端: + +- 下发音频为16bit单声道,采样率和编码由[Start](#5b2363d40585y)消息参数定义。 + +- 下发速度取决于TTS服务性能,通常快于播放速度。 + +- 音频下发前发送[RespondingStarted](#0693e9d4a5f91)事件;结束后发送[RespondingEnded](#402cbee340r1m)事件。 + +- 客户端需在播放完成后上报[LocalRespondingEnded](#f4b1a6bf22ymq),通知服务端播放结束。 + + +### **文本消息(Text Message)** + +文本消息是JSON格式字符串,按传递方向分为两类: + +- **输入消息(Input Message)**:客户端发送给服务端的指令(directive),表示客户端希望服务端执行特定动作。 + +- **输出消息(Output Message)**:服务端发送给客户端的事件(event),表示服务端动作执行结果或进展。 + + +文本消息标记交互流程的关键节点,控制流程并传输关键信息。通过[时序图](#6d25d6f8ddbtd)可了解不同消息的交互时序。 + +文本消息包含两部分:`header`和`payload`。 + +- `payload`:内容随[消息类型](#408ddb268eyt7)变化。 + +- `header`:内容固定,包含以下参数: + + | **参数** | **类型** | **是否必选** | **说明** | + | --- | --- | --- | --- | + | task\_id | string | 是 | 本次连接唯一标识,用于在工程链路上跟踪任务执行。由客户端生成,格式建议为36位uuid字符串,格式示例:"f894c16f-f20e-4c1d-837e-89e0fbc63a43" | + | streaming | string | 是 | 输入输出类型,对于多模态交互必须为 "duplex" ,表示流式输入,流式输出 | + | action | string | 是 | 模型输入消息类型: - run-task: 任务的第一个输入消息 - finish-task: 任务的最后一个输入消息 - continue-task: 任务中其他输入消息 | + + +## **连接保活策略** + +百炼平台规定,如果在**任意连续的60秒内**服务端没有向客户端发送任何消息,则认为调用发生错误,WebSocket连接将被服务端主动断开并返回`ResponseTimeout`错误。 + +若客户端需在无交互时保持连接,应定期发送心跳消息(HeartBeat)。服务端会回应心跳,确保连接活跃,避免超时关闭。 + +移动端和C++ SDK已内置心跳保活逻辑,用户无需手动发送。 + +## **文本消息类型** + +### **开始会话** + +#### **Start - Input Message** + +请求开始会话消息。服务收到Start消息后,向客户端发送**Started**消息。 + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| task\_group | | string | 是 | 任务组名称,固定为"aigc",请直接复制使用 | +| task | | string | 是 | 任务名称,固定为"multimodal-generation",请直接复制使用 | +| function | | string | 是 | 调用功能,固定为"generation",请直接复制使用 | +| model | | string | 是 | 阿里云百炼模型名称,固定为"multimodal-dialog",请直接复制使用 | +| input | directive | string | 是 | 指令名称:Start | +| input | workspace\_id | string | 是 | 客户在阿里云百炼业务空间ID([Workspace ID](https://help.aliyun.com/zh/model-studio/obtain-the-app-id-and-workspace-id#732535cfc959h)),可在**多模态交互开发套件控制**台,点击左下角业务空间名称,“业务空间详情”中查看,目前仅支持主账号默认工作空间。 | +| input | app\_id | string | 是 | 客户创建的应用ID([APP ID](https://help.aliyun.com/zh/model-studio/obtain-the-app-id-and-workspace-id#2612f896detsz)),可在**多模态交互开发套件**控制台的“我的应用”页面查看 | +| input | dialog\_id | string | 否 | 对话ID,默认不填时是开启新会话,服务端会自动生成并在事件中下发,格式示例:"12345678-1234-1234-1234-1234567890ab",共36个字符。当希望继续之前的对话时,把当时服务端下发的dialog\_id在这里传入 | +| parameters | upstream | object | 是 | 参数说明参考下方 [parameters.upstream的参数说明](#c70ad23b46rng)表格 | +| parameters | downstream | object | 否 | 参数说明参考下方 [parameters.downstream的参数说明](#18bc9ff97bxur)表格 | +| parameters | client\_info | object | 是 | 参数说明参考下方 [parameters.client\_info的参数说明](#51228f3bc17yk)表格 | +| parameters | biz\_params | object | 否 | 参数说明参考下方 [parameters.biz\_params的参数说明](#eb89a03bfdk00)表格 | + +**parameters.upstream**的参数说明如下**:** + +| **一级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | +| type | string | 是 | 上行类型: AudioOnly 仅语音通话 | +| mode | string | 否 | 客户端使用的模式,默认**tap2talk。** 可选项: - push2talk: 客户端控制模式。 - tap2talk: 点击模式。 - duplex: 双工模式。 三种模式的对比可以参考下方的[客户端使用的三种模式对比](#5e82453407pum)表格。 | +| audio\_format | string | 否 | 音频格式,支持pcm,raw-opus,默认为pcm | +| sample\_rate | int | 否 | 语音识别的采样率,支持范围: - 8000 - 16000 - 24000 - 48000 默认为16000 | +| vocabulary\_id | string | 否 | 热词id,设置该参数时会覆盖管控台热词配置。当管控台提供的热词不能满足客户需求时,可以考虑用Open API程序化管理热词,参见[热词API文档](https://next.api.aliyun.com/document/MultimodalDialog/2025-09-03/Vocabulary) | + +**parameters.downstream**的参数说明如下: + +| **一级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | +| voice | string | 否 | 合成语音的音色,支持范围取决于用户在管控台选择的语音合成模型 | +| sample\_rate | int | 否 | 合成语音的采样率,支持范围: - 8000 - 16000 - 24000 - 48000 默认为24000。 千问-TTS、千问3-TTS模型仅支持24000。 | +| audio\_format | string | 否 | 音频格式,支持pcm,opus,mp3,raw-opus,默认为pcm。 千问-TTS模型仅支持pcm。 注意:opus 和 raw-opus的区别是opus格式的每一包数据都有额外ogg封装([RFC 7845](https://www.rfc-editor.org/rfc/rfc7845.html)) | +| frame\_size | int | 否 | 合成音频的帧大小,取值范围: - 10 - 20 - 40 - 60 - 100 - 120 默认值为60,单位ms 只在合成音频格式为opus或raw-opus时生效 | +| volume | int | 否 | 合成音频的音量,取值范围0-100,默认50 | +| speech\_rate | int | 否 | 合成音频的语速,取值范围50-200,表示默认语速的50%-200%,默认100 | +| pitch\_rate | int | 否 | 合成音频的声调,取值范围50-200,默认100 | +| bit\_rate | int | 否 | 合成音频的比特率,取值范围:6~510kbps,默认值为32,单位kbps,只在合成音频格式为opus或raw-opus时生效 | +| intermediate\_text | string | 否 | 控制返回给用户哪些中间文本: - transcript:返回用户语音识别结果 - dialog:返回对话系统回答中间结果 可以设置多种,以逗号分隔,默认为transcript | +| transmit\_rate\_limit | int | 否 | 下发音频发送速率限制,单位:字节每秒 | +| incremental\_response | boolean | 否 | 是否增量返回大模型结果,true为增量,false为全量。默认为false,全量下发 | + +**parameters.client\_info**的参数说明如下: + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| user\_id | | string | 是 | 终端用户ID,客户根据自己业务规则生成,用来针对不同终端用户实现定制化功能。最大长度36个字符。 | +| device.uuid | uuid | string | 否 | 客户端全局唯一的ID,需要用户自己生成并传入SDK,最大长度40个字符。一个终端用户可以有多个设备,那么每一个设备的uuid都不同,但user\_id相同。 | +| network | ip | string | 否 | 调用方公网IP | +| location | latitude | string | 否 | 调用方纬度信息,在需要客户端精确位置的业务场景提交 | +| location | longitude | string | 否 | 调用方经度信息,在需要客户端精确位置的业务场景提交 | +| location | city\_name | string | 否 | 调用方所在城市,指明客户端粗略位置 | + +**parameters.biz\_params**的参数说明如下: + +| **一级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | +| user\_defined\_params | json object | 否 | 需要透传给agent的参数,各类agent传递的参数参考[调用官方Agent](https://help.aliyun.com/zh/model-studio/official-agent)文档说明 | +| user\_prompt\_params | json object | 否 | 用于设置用户自定义prompt变量,由用户自定义设置json中的key和value。管控台上配置自定义prompt变量的方法参考[应用配置-提示词](https://help.aliyun.com/zh/model-studio/multimodal-app-configuration#74a8b82973u0r) | +| user\_query\_params | json object | 否 | 用于设置用户自定义对话变量,由用户自定义设置json中的key和value。管控台上配置自定义对话变量的方法参考[应用配置-对话变量](https://help.aliyun.com/zh/model-studio/multimodal-app-configuration#62d90ef075ve1) | + +##### **客户端使用的三种模式对比:** + +| **对比项** | **push2talk** | **tap2talk** | **duplex** | +| --- | --- | --- | --- | +| 类型 | 客户端控制模式 | 点击模式 | 双工模式 | +| 音频上传方式 | 按需 | 持续 Listening状态超过20秒不上传音频即报错 | 持续 任何状态超过20秒不上传音频都报错 | +| VAD检测方 | 客户端 | 服务端 | 服务端 | +| 打断方式 | [RequestToSpeak](#8f987c58ddg2i)消息打断 | [RequestToSpeak](#8f987c58ddg2i)消息打断 | 语音打断 | +| 使用场景 | 由用户控制开始/结束客户端语音发送和识别,适用于按键说话,松开停止说话的场景。 | 客户端需持续上传音频,服务端自动检测语音活动的场景。但不支持用户语音打断大模型输出,只能发送RequestToSpeak打断消息。 | 客户端需持续上传音频,服务端自动检测语音活动的场景。用户随时可以说话打断大模型输出。 | + +示例如下: + +``` +{ + "header": { + "action":"run-task", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43", + "streaming":"duplex" + }, + "payload": { + "task_group":"aigc", + "task":"multimodal-generation", // 任务类型:多模态生成 + "function":"generation", + "model":"multimodal-dialog", // 模型名称:多模态对话(注意与task不同) + "input":{ + "directive": "Start", + "workspace_id": "llm-***********", + "app_id": "****************" + }, + "parameters":{ + "upstream":{ + "type": "AudioOnly", + "mode": "duplex" + }, + "downstream":{ + "voice": "longxiaochun_v2", + "sample_rate": 24000 + }, + "client_info":{ + "user_id": "bin********207", + "device":{ + "uuid": "432k*********k449" + }, + "network":{ + "ip": "203.0.113.10" + }, + "location":{ + "city_name": "北京市" + } + }, + "biz_params":{ + "user_defined_params": { + "agent_id_xxxxx": { + "name": "value" + } + }, + "user_prompt_params": { + "name": "value" + }, + "user_query_params": { + "name": "value" + } + } + } + } +} +``` + +#### **Started - Output Message** + +**说明** + +SDK接收到**Started**之后,先不可以向服务发送音频,应等待**DialogStateChanged**消息,确认切换到Listening状态之后才发送音频。 + +| **一级参数** | **二级参数** | **类型** | **说明** | +| --- | --- | --- | --- | +| output | event | string | 事件名称:Started | +| | dialog\_id | string | 对话ID | + +示例如下: + +Started返回消息样例 + +``` +{ + "header": { + "event":"result-generated", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43" + }, + "payload": { + "output":{ + "event": "Started", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +### **结束会话** + +#### **Stop - Input Message** + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| input | directive | string | 是 | 指令名称:Stop | +| | dialog\_id | string | 否 | 对话ID | + +示例如下: + +Stop请求样例 + +``` +{ + "header": { + "action":"finish-task", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43", + "streaming":"duplex" + }, + "payload": { + "input":{ + "directive": "Stop", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +#### **Stopped - Output Message** + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| output | event | string | 是 | 事件名称:Stopped | +| | dialog\_id | string | 是 | 对话ID | + +示例如下: + +``` +{ + "header": { + "event":"result-generated", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43" + }, + "payload": { + "output":{ + "event": "Stopped", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +### **服务端下发状态切换事件** + +#### **DialogStateChanged - Output Message** + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| output | event | string | 是 | 事件名称:DialogStateChanged | +| | state | string | 是 | AI交互状态,取值范围:Listening,Thinking, Responding 注意:其中状态Listening 只是表示SDK可以发语音给服务端,不代表客户端是否开麦 | +| | dialog\_id | string | 是 | 对话ID | + +示例如下: + +``` +{ + "header": { + "event":"result-generated", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43" + }, + "payload": { + "output":{ + "event": "DialogStateChanged", + "state": "Listening", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +### **请求上传语音** + +#### **RequestToSpeak - Input Message** + +当前状态不是Listening,而用户又想说话时,先提交此事件打断大模型的回答,等待服务端应答。 + +具体触发此事件的用户行为根据交互类型有所不同,比如用户按下按钮、语音打断(依赖双工模块)等。 + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| input | directive | string | 是 | 指令名称:RequestToSpeak | +| | dialog\_id | string | 否 | 对话ID | + +示例如下: + +``` +{ + "header": { + "action":"continue-task", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43", + "streaming":"duplex" + }, + "payload": { + "input":{ + "directive": "RequestToSpeak", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +#### **RequestAccepted - Output Message 请求被准许** + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| output | event | string | 是 | 事件名称:RequestAccepted | +| | dialog\_id | string | 是 | 对话ID | + +示例如下: + +``` +{ + "header": { + "event":"result-generated", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43" + }, + "payload": { + "output":{ + "event": "RequestAccepted", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +### **上传语音指令** + +#### **SendSpeech - Input Message** + +当Start消息指定模式为push2talk,需要客户端告知服务端用户何时开始说话。在Listening状态下,当用户按下按键时应上报SendSpeech消息,通知服务端即将开始上传语音,语音数据应紧接着此事件之后发送。 + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| input | directive | string | 是 | 指令名称:SendSpeech | +| | dialog\_id | string | 否 | 对话ID | + +示例如下: + +``` +{ + "header": { + "action":"continue-task", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43", + "streaming":"duplex" + }, + "payload": { + "input":{ + "directive": "SendSpeech", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +#### **StopSpeech - Input Message** + +当Start消息指定模式为push2talk的时候,用户说完话松开按键时,客户端必须使用StopSpeech消息通知服务端结束语音指令输入。 + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| input | directive | string | 是 | 指令名称:StopSpeech | +| | dialog\_id | string | 否 | 对话ID | + +示例如下: + +``` +{ + "header": { + "action":"continue-task", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43", + "streaming":"duplex" + }, + "payload": { + "input":{ + "directive": "StopSpeech", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +#### **CancelSpeech - Input Message** + +当Start消息指定模式为push2talk或tap2talk的时候,在语音输入过程中,用户可以使用CancelSpeech消息结束语音输入。服务会结束识别,回到空闲状态。 + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| input | directive | string | 是 | 指令名称:CancelSpeech | +| | dialog\_id | string | 否 | 对话ID | + +示例如下: + +``` +{ + "header": { + "action":"continue-task", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43", + "streaming":"duplex" + }, + "payload": { + "input":{ + "directive": "CancelSpeech", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +### **语音识别开始/结束** + +#### **SpeechStarted - Output Message** + +当服务端检测到asr语音起点时下发此事件。 + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| output | event | string | 是 | 事件名称:SpeechStarted | +| | dialog\_id | string | 是 | 对话ID | + +示例如下: + +``` +{ + "header": { + "event":"result-generated", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43" + }, + "payload": { + "output":{ + "event": "SpeechStarted", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +#### **SpeechEnded - Output Message** + +当服务端检测到asr语音尾点时下发此事件,如果客户端还在上传音频,则收到此事件后应停止上传音频。 + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| output | event | string | 是 | 事件名称:SpeechEnded | +| | dialog\_id | string | 是 | 对话ID | + +示例如下: + +``` +{ + "header": { + "event":"result-generated", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43" + }, + "payload": { + "output":{ + "event": "SpeechEnded", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +### **指定应答内容** + +#### **RequestToRespond - Input Message** + +在Listening状态时,通知服务端与用户主动交互,服务端会根据type字段把在text字段中上传的文本直接转换为语音下发,或调用大模型,返回的结果再转换为语音下发。 + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| input | directive | string | 是 | 指令名称:RequestToRespond | +| input | dialog\_id | string | 是 | 对话ID | +| input | type | string | 是 | 服务应该采取的交互类型,目前支持两种: - transcript:表示直接把文本转语音 - prompt:表示把文本发送给大模型让其进行回答 | +| input | text | string | 是 | 要处理的文本,非null。 - 调用部分agent时,text可以是""空字符串,服务端只需要使用parameters中的images或者biz\_params参数即可处理。具体参考[调用官方Agent](https://help.aliyun.com/zh/model-studio/official-agent)。 | +| parameters | images | list\[\] | 否 | 需要分析的图片信息 | +| parameters | biz\_params | object | 否 | 参数说明参考下方 [parameters.biz\_params的参数说明](#b5975e73ab3s0)表格 | + +**parameters.biz\_params**的参数说明如下: + +| **一级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | +| videos | list\[\] | 否 | 用来控制进入和退出视频通话。 示例如下: payload.biz\_params.videos.action为connect,表示进入视频模式。 payload.biz\_params.videos.action为exit,表示退出视频模式。 | +| 其他参数 | | 否 | 与[Start](#5b2363d40585y)消息中`parameters.biz_params`相同,传递对话系统自定义参数。RequestToRespond的`biz_params`参数只在本次请求中生效。 | + +**说明** + +除了videos参数外,`parameters.biz_params`与[Start](#5b2363d40585y)消息中的`parameters.biz_params`相同,传递对话系统自定义参数。RequestToRespond的`biz_params`参数只在本次请求中生效。 + +``` +{ + "header": { + "action":"continue-task", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43", + "streaming":"duplex" + }, + "payload": { + "input":{ + "directive": "RequestToRespond", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb", + "type": "prompt", + "text": "你好,你有什么想聊的呢" + }, + "parameters":{ + "images":[{ + "type": "base64", + "value": "aGVsbG8gd29ybGQ=" + }], + "biz_params":{ + "user_defined_params":{}, + "videos": [ + { + "action": "connect/exit", + "type": "voicechat_video_channel" + } + ] + } + } + } +} +``` + +### **AI语音应答状态** + +#### **RespondingStarted - Output Message** + +AI语音应答开始,sdk要准备接收服务端下发的语音数据 + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| output | event | string | 是 | 事件名称:RespondingStarted | +| | dialog\_id | string | 是 | 对话ID | + +示例如下: + +``` +{ + "header": { + "event":"result-generated", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43" + }, + "payload": { + "output":{ + "event": "RespondingStarted", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +#### **RespondingEnded - Output Message** + +AI语音应答结束 + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| output | event | string | 是 | 事件名称:RespondingEnded | +| | dialog\_id | string | 是 | 对话ID | + +示例如下: + +``` +{ + "header": { + "event":"result-generated", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43" + }, + "payload": { + "output":{ + "event": "RespondingEnded", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +### 客户端**播放事件** + +#### **LocalRespondingStarted - Input Message** + +客户端开始播放服务端下发的音频 + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| input | directive | string | 是 | 指令名称:LocalRespondingStarted | +| | dialog\_id | string | 否 | 对话ID | + +示例如下: + +``` +{ + "header": { + "action":"continue-task", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43", + "streaming":"duplex" + }, + "payload": { + "input":{ + "directive": "LocalRespondingStarted", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +#### **LocalRespondingEnded - Input Message** + +客户端播放服务端下发的音频完成,服务端根据此消息判断端侧语音播放结束,结束当前问答,重新切换到Listening状态。 + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| input | directive | string | 是 | 指令名称:LocalRespondingEnded | +| | dialog\_id | string | 否 | 对话ID | + +示例如下: + +``` +{ + "header": { + "action":"continue-task", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43", + "streaming":"duplex" + }, + "payload": { + "input":{ + "directive": "LocalRespondingEnded", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +### **文本下发事件** + +#### **SpeechContent - Output Message** + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| output | event | string | 是 | 事件名称:SpeechContent | +| | dialog\_id | string | 是 | 对话ID | +| | text | string | 是 | 用户语音识别出的文本,流式返回,每次返回从识别开始到当前的完整识别结果。例如,您会先收到`"text": "你好"`,接着收到`"text": "你好世界"`。 | +| | finished | bool | 是 | 输出是否结束 | + +示例如下: + +``` +{ + "header": { + "event":"result-generated", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43" + }, + "payload": { + "output":{ + "event": "SpeechContent", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb", + "text": "一二三", + "finished": false + } + } +} +``` + +#### **RespondingContent - Output Message** + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| output | event | string | 是 | 事件名称:RespondingContent | +| | dialog\_id | string | 是 | 对话ID | +| | round\_id | string | 是 | 本轮交互的ID | +| | llm\_request\_id | string | 是 | 调用llm的request\_id | +| | text | string | 是 | 系统对外输出的文本,流式全量输出 | +| | spoken | string | 是 | 合成语音时使用的文本,流式全量输出 | +| | finished | bool | 是 | 输出是否结束 | +| | extra\_info | object | 否 | 其他扩展信息,目前支持: - commands: 命令字符串,**此字段为JSON字符串,需要进行二次解析。**各类agent使用的命令字符串可以参考[调用官方Agent](https://help.aliyun.com/zh/model-studio/official-agent)的说明。 - agent\_info: 智能体信息 - tool\_calls: 插件返回的信息 如果没有扩展信息要提交则省略此字段。 | + +示例如下: + +``` +{ + "header": { + "event": "result-generated", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43" + }, + "payload": { + "output": { + "event": "RespondingContent", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb", + "text": "您输入了数字序列“12345”。如果您有关于这些数字的问题或者需要我用它们来完成某项任务,请告诉我更多的细节,我会尽力帮助您。", + "spoken": "您输入了数字序列“12345”。如果您有关于这些数字的问题或者需要我用它们来完成某项任务,请告诉我更多的细节,我会尽力帮助您。", + "finished": true, + "extra_info": { + "commands": "[{\"name\":\"VOLUME_SET\",\"params\":[{\"name\":\"series\",\"normValue\":\"70\",\"value\":\"70\"}]}]", + "tool_calls": [ + { + "id": "", + "type": "function", + "function": { + "name": "function_name", + "arguments": "{\"id\": \"123\", \"name\": \"test\"}", + "outputs": "{\"result\": \"success\"}", + "status": { + "code": 200, + "message": "Success." + } + } + } + ] + } + } + } +} +``` + +### 客户端**更新事件** + +#### **UpdateInfo - Input Message** + +| **一级参数** | **二级参数** | **三级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | --- | +| input | directive | | string | 是 | 指令名称:UpdateInfo | +| | dialog\_id | | string | 否 | 对话ID | +| parameters | images | | list\[\] | 否 | 图片数据 | +| | client\_info | status | object | 否 | 客户端当前状态 | +| | biz\_params | | object | 否 | 与Start消息中biz\_params相同,传递对话系统自定义参数。UpdateInfo指令中biz\_params下面每个子项会全量替换Start指令中biz\_params下面的同名项,并在本次连接后续所有对话中生效。 | + +示例如下: + +``` +{ + "header": { + "action":"continue-task", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43", + "streaming":"duplex" + }, + "payload": { + "input":{ + "directive": "UpdateInfo", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + }, + "parameters":{ + "images":[{ + "type": "base64", + "value": "base64String" + }], + "client_info": { + "status": { + "bluetooth_announcement": { + "status": "stopped" + }, + "stream_media_playback": { + "status": "stopped" + }, + "phone_ringing": { + "status": "stopped" + }, + "stream_media_playback_qq": { + "status": "stopped" + } + } + }, + "biz_params":{ + } + } + } +} +``` + +### **心跳事件** + +可以通过定期向服务端发送此消息,避免连接超时断开。考虑到预留网络延迟和处理时间的buffer,建议发送频率50秒一次。服务端会回应相同消息,客户端收到不需要做任何处理,忽略即可。 + +#### **HeartBeat - Input Message** + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| input | directive | string | 是 | 指令名称:**HeartBeat** | +| | dialog\_id | string | 否 | 对话id | + +示例: + +``` +{ + "header": { + "action": "continue-task", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43", + "streaming": "duplex" + }, + "payload": { + "input": { + "directive": "HeartBeat", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +#### **HeartBeat - Output Message** + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| output | event | string | 是 | 事件名称:**HeartBeat** | +| | dialog\_id | string | 是 | 对话id | + +示例: + +``` +{ + "header": { + "event":"result-generated", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43" + }, + "payload": { + "output":{ + "event": "HeartBeat", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb" + } + } +} +``` + +### **错误事件** + +#### **Error - Output Message** + +| **一级参数** | **二级参数** | **类型** | **是否必选** | **说明** | +| --- | --- | --- | --- | --- | +| output | error\_code | int | 是 | 错误码 | +| output | error\_name | string | 是 | 错误名称 | +| output | error\_message | string | 是 | 错误消息 | + +``` +{ + "header": { + "event":"result-generated", + "task_id": "f894c16f-f20e-4c1d-837e-89e0fbc63a43" + }, + "payload": { + "output":{ + "event": "Error", + "dialog_id": "dd84xxxx-xxxx-xxxx-xxxx-xxxxb7bb", + "error_code": 500, + "error_name": "InternalLLMError", + "error_message": "Internal LLM error" + } + } +} +``` + +### **错误码** + +如遇报错问题,请参见[多模态交互套件-错误码](https://help.aliyun.com/zh/model-studio/multimodal-error-code)进行排查。 + +若问题仍未解决,请联系技术支持,反馈遇到的问题,并提供完整的request\_id和dialog\_id,以便进一步排查问题。 + +## **术语说明** + +**VAD**:Voice Activity Detection,语音活动检测。 + +**ASR**:Automatic Speech Recognition,自动语音识别。 + +**TTS**:Text-to-Speech,文本转语音,语音合成。 + +**LLM**:Large Language Model,大语言模型。 + +本文介绍使用阿里云百炼多模态交互套件可能出现的错误信息及解决方案。 + +## **AccessDenied.Unpurchased** + +``` +{"header":{"task_id":"xxxxx","event":"task-failed","error_code":"AccessDenied.Unpurchased","error_message":"Access to model denied. Please make sure you are eligible for using the model.","attributes":{}},"payload":{}} +``` + +### **Access to model denied. Please make sure you are eligible for using the model.** + +**原因:**错误码error\_code出现在header里,用户未开通阿里云百炼服务,无法建连。 + +**解决方案:**注册或登录阿里云账号,然后前往模型广场开通百炼服务。 + +## **Model.AccessDenied** + +``` +{"header":{"task_id":"xxxxx","event":"task-failed","error_code":"Model.AccessDenied","error_message":"Model access denied.","attributes":{}},"payload":{}} +``` + +### **Model access denied.** + +**原因:** 错误码error\_code出现在header里,使用的业务空间不是默认业务空间,无法建连。目前多模态交互只支持从默认业务空间调用。 + +**解决方案:**使用默认业务空间的API Key调用多模态交互。 + +## **RequestTimeOut** + +``` +{"header":{"task_id":"xxxxx","event":"task-failed","error_code":"ResponseTimeout","error_message":"Response timeout!","attributes":{}},"payload":{}} +``` + +### **Response timeout!** + +**原因:** 错误码error\_code出现在header里,百炼网关报错,无法建连。百炼网关要求服务端和客户端必须持续通信,如果超过1分钟没有交互则会超时报错。 + +**解决方案:** 持续交互,避免长时间无消息传递。若客户端需在无交互时保持连接,应定期发送心跳消息(HeartBeat)。服务端会回应心跳,确保连接活跃,避免超时关闭。具体的心跳消息格式参见[心跳事件](https://help.aliyun.com/zh/model-studio/multimodal-interaction-protocol#df274da95fsoq)。 + +## **421-**InvalidParameter + +``` +{"header":{"event":"result-generated","task_id":"xxxxx","status_code":421,"status_name":"InvalidParameter","status_message":"xxxxx"},"payload":{"output":{"event":"Error","dialog_id":"xxxxx"}}} +``` + +### **type of directive payload is error, please choose transcript or prompt** + +**原因:**错误码status\_code出现在header里,收到该错误后连接会断开,是RequestToRespond的参数type取值错误。 + +**解决方案:**修正RequestToRespond的参数type,重新发请求。type只能支持以下两种: + +(1)transcript 表示直接把文本转语音。 + +(2)prompt 表示把文本送大模型回答。 + +### **status\_message是其他报错信息** + +**原因:**status\_code出现在header里,收到该错误后连接会断开,是其他的参数取值错误 ,status\_message 信息都有具体的提示说明。 + +**解决方案:**根据 status\_message 信息提示修正参数取值。 + +## **422-**DirectiveNotSupported + +``` +{"header":{"event":"result-generated","task_id":"xxxxx","status_code":422,"status_name":"DirectiveNotSupported","status_message":"Directive not supported: xxx"},"payload":{"output":{"event":"Error","dialog_id":"xxxxx"}}} +``` + +### Directive not supported: xxx + +**原因:**错误码status\_code出现在header里,收到该错误后连接会断开。传入的指令 directive 的取值是不支持的指令。 + +**解决方案:**请检查指令名称,使用多模态交互可用的指令。参考[文本消息类型](https://help.aliyun.com/zh/model-studio/multimodal-interaction-protocol#15c38d92bc50s)的说明。 + +## **432-**AppConfigError + +``` +{"header":{"event":"result-generated","task_id":"xxxxx","status_code":432,"status_name":"AppConfigError","status_message":"xxxxxxx"},"payload":{"output":{"event":"Error","dialog_id":"xxxxx"}}} +``` + +**原因:**错误码status\_code出现在header里,收到该错误后连接会断开。获取到百炼应用的配置时出现问题。 + +**解决方案:**参考status\_message 里的具体信息,修改传入的参数配置。 + +## **433-**BillingAuthError + +``` +{"header":{"event":"result-generated","task_id":"xxxxx","status_code":433,"status_name":"BillingAuthError","status_message":"xxxxxxx"},"payload":{"output":{"event":"Error","dialog_id":"xxxxx"}}} +``` + +### Billing auth info not found! + +**原因:**错误码status\_code出现在header里,收到该错误后连接会断开。当前使用的账号未开通百炼多模态交互服务。 + +**解决方案:**为当前账号开通百炼多模态交互服务,或使用已开通的账号。 + +## **444-**ClientAudioTimeout + +``` +{"header":{"event":"result-generated","task_id":"xxxxx","status_code":444,"status_name":"ClientAudioTimeout","status_message":"Waiting for client audio timed out."},"payload":{"output":{"event":"Error","dialog_id":"xxxxx"}}} +``` + +### Waiting for client audio timed out. + +**原因:**错误码status\_code出现在header里,收到该错误后连接会断开。服务端长时间收不到客户端的音频输入。 + +**解决方案:**在duplex模式应持续向服务端上传音频;在tap2talk模式应保证在状态切换到Listening之后立刻持续上传音频,也可以在所有状态下都上传音频;在push2talk模式发送SendSpeech消息后应立刻上传音频直到发送StopSpeech消息。 + +## **449-**TooManyInterrupt + +``` +{"header":{"event":"result-generated","task_id":"xxxxx","status_code":449,"status_name":"TooManyInterrupt","status_message":"Send too many RequestToRespond or RequestToSpeak directives in a short time!"},"payload":{"output":{"event":"Error","dialog_id":"xxxxx"}}} +``` + +### Send too many RequestToRespond or RequestToSpeak directives in a short time! + +**原因:**错误码status\_code出现在header里,收到该错误后连接会断开。用户短时间内发送过多RequestToRespond或RequestToSpeek 打断正常交互,通常是调用程序bug导致。 + +**解决方案:**排查程序调用逻辑,避免多次发送RequestToRespond或RequestToSpeek 。 + +## **424-**AudioFormatError + +``` +{"header":{"event":"result-generated","task_id":"xxxxx","status_code":424,"status_name":"AudioFormatError","status_message":"xxxxx"},"payload":{"output":{"event":"Error","dialog_id":"xxxxx"}}} +``` + +### Failed to decode audio! Please check audio format! / ASR decode error, please check audio format! + +**原因:**错误码status\_code出现在header里,收到该错误后连接会断开。输入音频的格式不合规,ASR无法正常解析音频。 + +**解决方案:**检查输入的音频格式,输入正确的音频数据。 + +## **425-**NoInputAudioError + +``` +{"header":{"event":"result-generated","task_id":"xxxxx","status_code":200,"status_name":"Success","status_message":"Success"},"payload":{"output":{"event":"Error","dialog_id":"xxxxx","round_id":"xxxxx","llm_request_id":"xxxxx","error_code":425,"error_name":"NoInputAudioError","error_message":"ASR input audio error, no input audio , please check audio data!"}}} +``` + +### ASR input audio error, no input audio , please check audio data! + +**原因:**错误码error\_code出现在payload里,收到该错误后连接不会断开。没有获取到有效的输入音频数据,ASR无法识别。 + +**解决方案:**检查是否有音频数据发送,重新输入正确的音频数据。 + +## **426-**InvalidTtsVoice + +``` +{"header":{"event":"result-generated","task_id":"xxxxx","status_code":200,"status_name":"Success","status_message":"Success"},"payload":{"output":{"event":"Error","dialog_id":"xxxxx","round_id":"xxxxx","llm_request_id":"xxxxx","error_code":426,"error_name":"InvalidTtsVoice","error_message":"tts voice error , need xxx voice."}}} +``` + +### tts voice error , need xxx voice. + +**原因:**错误码error\_code出现在payload里,收到该错误后连接不会断开。音色参数 voice 取值错误,选择的音色不是当前的语音合成模型支持的音色。 + +**解决方案:**将voice修正为正确的音色。 + +(1)官方音色: + +- 参考官方文档:cosyvoice-v2 / cosyvoice-v3 / cosyvoice-v3-plus / cosyvoice-v3-flash 支持的官方音色参考[音色列表](https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list),qwen-tts-realtime / qwen3-tts 支持的官方音色参考[支持的音色](https://help.aliyun.com/zh/model-studio/qwen-tts-realtime#422789c49bqqx),sambert支持的音色参考[模型列表](https://help.aliyun.com/zh/model-studio/sambert-java-sdk#74cedcb97el0b)(去掉开头的"sambert-"和末尾的"-v1"后就是voice的取值)。 + +- 其他语音合成模型的音色都可以在多模态交互控制台上查看:在左侧**语音交互**配置区域选择对应的语音合成模型,点击右侧**语音交互体验**区域的右上角即可查看可用的音色列表。 + + +(2)复刻音色,确认音色状态为“OK”后才能使用。查询方法参考[查询指定音色](https://help.aliyun.com/zh/model-studio/cosyvoice-clone-api#34490e5a2by7z)。 + +## **451-**NoSpeechRecognized + +``` +{"header":{"event":"result-generated","task_id":"xxxxx","status_code":200,"status_name":"Success","status_message":"Success"},"payload":{"output":{"event":"Error","dialog_id":"xxxxx","round_id":"xxxxx","llm_request_id":"xxxxx","error_code":451,"error_name":"NoSpeechRecognized","error_message":"No speech recognized from audio!"}}} +``` + +### No speech recognized from audio! + +**原因:**错误码error\_code出现在payload里,收到该错误后连接不会断开。服务没有识别到用户讲话,通常是push2talk模式下,用户发送了SendSpeech后,没有说话就又发送了StopSpeech指令。其他模式下也有极少数情况是由背景噪音引起。 + +**解决方案:**检查消息发送的逻辑,确认是否有用户说话的音频数据发送到服务端。 + +## **500-InternalSynthesizerError/InternalAsrError /InternalLLMError/LLMTimeoutError** + +``` +{"header":{"event":"result-generated","task_id":"xxxxx","status_code":200,"status_name":"Success","status_message":"Success"},"payload":{"output":{"event":"Error","dialog_id":"xxxxx","round_id":"xxxxx","llm_request_id":"xxxxx","error_code":500,"error_name":"InternalAsrError","error_message":"Internal asr error"}}} +``` + +### Internal synthesizer error/Internal asr error/Internal LLM error/LLM response timeout + +**原因:**错误码error\_code出现在payload里,收到此类错误后连接不会断开。服务内部错误,tts/asr/llm出错。 + +**解决方案:**出现此类错误时,可以重新发起请求来恢复使用。如果需要确认具体原因,请记录出错时完整的request\_id和dialog\_id,联系技术支持。 \ No newline at end of file diff --git a/docs/z-image.md b/docs/z-image.md new file mode 100644 index 0000000..acb34d1 --- /dev/null +++ b/docs/z-image.md @@ -0,0 +1,268 @@ +# Z-Image 文生图 API 指南 + +Z-Image 是一款轻量级文生图模型,支持快速出图、中英文文本渲染,以及多种分辨率和宽高比。 + +## 快速入口 + +- 在线体验(北京): +- 在线体验(新加坡): +- 技术博客: +- 模型列表(按地域): + +## 效果示例 + +### 输入提示词 + +```text +film grain, analog film texture, soft film lighting, Kodak Portra 400 style, +cinematic grainy texture, photorealistic details, subtle noise, (film grain:1.2)。 +采用近景特写镜头拍摄的东亚年轻女性,站在户外雪地中,神情带有轻微惊讶。 +整体画面清新自然,主色调为蓝、白、黑。 +``` + +### 输出图像 + +![Z-Image 输出示例](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/6915736671/p1036890.png) + +## 模型概览 + +| 模型名称 | 模型简介 | 输出规格 | +| --- | --- | --- | +| `z-image-turbo` | 轻量模型,快速生图 | 图像格式:PNG;单次固定返回 1 张;总像素范围:`[512*512, 2048*2048]` | + +## 前提条件 + +1. 获取 API Key: +2. 配置环境变量: + +示例: + +```bash +export DASHSCOPE_API_KEY="sk-xxxxxxxx" +``` + +## HTTP 同步调用 + +- 北京地域:`POST https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation` +- 新加坡地域:`POST https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation` + +### 请求示例 + +`prompt_extend=false` 时响应更快,仅返回图像与提示词; +`prompt_extend=true` 时会返回优化后的提示词和推理过程,但耗时与费用更高。 + +```bash +curl --location 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' \ + --header 'Content-Type: application/json' \ + --header "Authorization: Bearer $DASHSCOPE_API_KEY" \ + --data '{ + "model": "z-image-turbo", + "input": { + "messages": [ + { + "role": "user", + "content": [ + { + "text": "一只坐在窗边的橘猫,午后阳光,胶片质感,写实风格。" + } + ] + } + ] + }, + "parameters": { + "prompt_extend": false, + "size": "1120*1440" + } + }' +``` + +## 请求参数 + +### Headers + +| 参数 | 类型 | 必选 | 说明 | +| --- | --- | --- | --- | +| `Content-Type` | string | 是 | 固定为 `application/json` | +| `Authorization` | string | 是 | 鉴权信息,格式:`Bearer $DASHSCOPE_API_KEY` | + +### Body + +| 参数路径 | 类型 | 必选 | 说明 | +| --- | --- | --- | --- | +| `model` | string | 是 | 模型名称,固定为 `z-image-turbo` | +| `input` | object | 是 | 输入对象 | +| `input.messages` | array | 是 | 消息数组,仅支持单轮输入 | +| `input.messages[0].role` | string | 是 | 固定为 `user` | +| `input.messages[0].content` | array | 是 | 内容数组,必须且仅能包含 1 个 `text` 对象 | +| `input.messages[0].content[0].text` | string | 是 | 正向提示词(中英文均可),长度上限 800 字符,超长自动截断 | +| `parameters` | object | 否 | 生成参数对象 | +| `parameters.size` | string | 否 | 输出分辨率,格式 `宽*高`,默认 `1024*1536` | +| `parameters.prompt_extend` | bool | 否 | 是否启用提示词智能改写,默认 `false` | +| `parameters.seed` | integer | 否 | 随机种子,范围 `[0, 2147483647]` | + +### size 参数说明 + +- 总像素限制:`[512*512, 2048*2048]` +- 推荐总像素范围:`[1024*1024, 1536*1536]` +- 同一 `seed` 可提升结果稳定性,但不能保证完全一致 + +### 推荐分辨率 + +#### 总像素约 1024x1024 + +| 宽高比 | 分辨率 | +| --- | --- | +| 1:1 | `1024*1024` | +| 2:3 | `832*1248` | +| 3:2 | `1248*832` | +| 3:4 | `864*1152` | +| 4:3 | `1152*864` | +| 7:9 | `896*1152` | +| 9:7 | `1152*896` | +| 9:16 | `720*1280` | +| 9:21 | `576*1344` | +| 16:9 | `1280*720` | +| 21:9 | `1344*576` | + +#### 总像素约 1280x1280 + +| 宽高比 | 分辨率 | +| --- | --- | +| 1:1 | `1280*1280` | +| 2:3 | `1024*1536` | +| 3:2 | `1536*1024` | +| 3:4 | `1104*1472` | +| 4:3 | `1472*1104` | +| 7:9 | `1120*1440` | +| 9:7 | `1440*1120` | +| 9:16 | `864*1536` | +| 9:21 | `720*1680` | +| 16:9 | `1536*864` | +| 21:9 | `1680*720` | + +#### 总像素约 1536x1536 + +| 宽高比 | 分辨率 | +| --- | --- | +| 1:1 | `1536*1536` | +| 2:3 | `1248*1872` | +| 3:2 | `1872*1248` | +| 3:4 | `1296*1728` | +| 4:3 | `1728*1296` | +| 7:9 | `1344*1728` | +| 9:7 | `1728*1344` | +| 9:16 | `1152*2048` | +| 9:21 | `864*2016` | +| 16:9 | `2048*1152` | +| 21:9 | `2016*864` | + +## 响应示例 + +### 成功响应 + +任务数据(如图像 URL)仅保留 24 小时,请及时保存。 + +```json +{ + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [ + { + "image": "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/xxx.png?Expires=xxx" + }, + { + "text": "一只坐在窗边的橘猫,午后阳光,胶片质感,写实风格。" + } + ], + "reasoning_content": "" + } + } + ] + }, + "usage": { + "width": 1120, + "height": 1440, + "image_count": 1, + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0 + }, + "request_id": "8a0809b4-a796-47f4-a095-394b02b62xxx" +} +``` + +### 失败响应 + +```json +{ + "request_id": "a4d78a5f-655f-9639-8437-xxxxxx", + "code": "InvalidParameter", + "message": "num_images_per_prompt must be 1" +} +``` + +## 响应字段说明 + +| 参数路径 | 类型 | 说明 | +| --- | --- | --- | +| `output` | object | 任务输出信息 | +| `output.choices` | array | 模型输出数组,仅包含 1 个元素 | +| `output.choices[0].finish_reason` | string | 停止原因,正常完成为 `stop` | +| `output.choices[0].message.role` | string | 固定为 `assistant` | +| `output.choices[0].message.content[].image` | string | 生成图像 URL(PNG,24 小时有效) | +| `output.choices[0].message.content[].text` | string | `prompt_extend=false` 返回原提示词;`true` 返回改写后提示词 | +| `output.choices[0].message.reasoning_content` | string | 推理过程,仅 `prompt_extend=true` 时返回 | +| `usage` | object | 统计信息 | +| `usage.width` | integer | 图像宽度(像素) | +| `usage.height` | integer | 图像高度(像素) | +| `usage.image_count` | integer | 图像数量,固定为 1 | +| `usage.input_tokens` | integer | 输入 token 数,`prompt_extend=false` 时固定为 0 | +| `usage.output_tokens` | integer | 输出 token 数,`prompt_extend=false` 时固定为 0 | +| `usage.output_tokens_details.reasoning_tokens` | integer | 推理 token 数,仅 `prompt_extend=true` 时返回 | +| `usage.total_tokens` | integer | 总 token 数,`prompt_extend=false` 时固定为 0 | +| `request_id` | string | 请求唯一标识,可用于排障 | +| `code` | string | 错误码,仅失败时返回 | +| `message` | string | 错误详情,仅失败时返回 | + +## 使用限制 + +- 图像 URL 仅保留 24 小时,请及时下载。 +- 输入 `prompt` 与输出图像均经过内容安全审核,违规请求可能返回 `IPInfringementSuspect` 或 `DataInspectionFailed`。 +- 若业务网络禁止访问公网 OSS,请将下列域名加入白名单: + +```text +dashscope-result-bj.oss-cn-beijing.aliyuncs.com +dashscope-result-hz.oss-cn-hangzhou.aliyuncs.com +dashscope-result-sh.oss-cn-shanghai.aliyuncs.com +dashscope-result-wlcb.oss-cn-wulanchabu.aliyuncs.com +dashscope-result-zjk.oss-cn-zhangjiakou.aliyuncs.com +dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com +dashscope-result-hy.oss-cn-heyuan.aliyuncs.com +dashscope-result-cd.oss-cn-chengdu.aliyuncs.com +dashscope-result-gz.oss-cn-guangzhou.aliyuncs.com +dashscope-result-wlcb-acdr-1.oss-cn-wulanchabu-acdr-1.aliyuncs.com +``` + +## 计费与限流 + +- 模型价格与免费额度: +- 限流说明: +- 计费按成功生成的图像张数统计;失败请求不计费,也不消耗免费额度: + +## 错误码 + +错误码总览: + +## 常见问题 + +### 如何查看模型调用量? + +模型调用约 1 小时后,可在以下页面查看调用次数、成功率等指标: + +- 模型监控(北京): +- 模型监控(新加坡): +- 账单查询与成本管理: diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt new file mode 100644 index 0000000..0784f7b --- /dev/null +++ b/main/CMakeLists.txt @@ -0,0 +1,3 @@ +# Keep `main` as a lightweight component holder for project-wide Kconfig. +# Runtime sources are split into layered components under `components/`. +idf_component_register() diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild new file mode 100644 index 0000000..0434daf --- /dev/null +++ b/main/Kconfig.projbuild @@ -0,0 +1,580 @@ +menu "TQ Controller Config" + +config TQ_WIFI_SSID + string "Wi-Fi SSID" + default "" + +config TQ_WIFI_PASSWORD + string "Wi-Fi Password" + default "" + +config TQ_WIFI_MAXIMUM_RETRY + int "Wi-Fi Maximum Retry" + default 10 + +config TQ_WIFI_CONNECT_TIMEOUT_MS + int "Wi-Fi connect timeout (ms)" + range 3000 60000 + default 15000 + +config TQ_LIFECYCLE_START_RETRY_COUNT + int "Lifecycle start retry count" + range 0 5 + default 2 + +config TQ_LIFECYCLE_RETRY_BACKOFF_MS + int "Lifecycle retry base backoff (ms)" + range 100 10000 + default 800 + +config TQ_HTTP_PORT + int "REST HTTP Port" + default 80 + +config TQ_REST_PRINTER_CONNECT_TIMEOUT_MS + int "REST printer connect timeout default (ms)" + range 1000 60000 + default 15000 + +config TQ_REST_LABEL_TIMEOUT_MS + int "REST label/offset timeout default (ms)" + range 500 30000 + default 5000 + +config TQ_REST_OTA_STEP_TIMEOUT_MS + int "REST OTA step timeout default (ms)" + range 500 60000 + default 5000 + +config TQ_PRINTER_CONTROL_LOCK_TIMEOUT_MS + int "Printer control lane lock timeout (ms)" + range 100 5000 + default 1000 + +config TQ_PRINTER_WORKER_QUEUE_WAIT_MS + int "Printer worker queue poll wait (ms)" + range 50 2000 + default 250 + +config TQ_PRINTER_QUEUE_RETRY_DELAY_MS + int "Printer queue retry delay (ms)" + range 5 500 + default 20 + +config TQ_PRINTER_STATUS_POLL_INTERVAL_MS + int "Printer status poll interval (ms)" + range 1000 30000 + default 5000 + +config TQ_PRINTER_STOP_TIMEOUT_MS + int "Printer protocol stop timeout (ms)" + range 1000 30000 + default 8000 + +config TQ_PRINTER_DEFAULT_BACKEND + int "Printer default backend (0=BLE, 1=Direct)" + range 0 1 + default 0 + +config TQ_DIRECT_PRINTER_ENABLE + bool "Enable direct thermal printer backend" + default y + +config TQ_DIRECT_PRINTER_OPERATION_TIMEOUT_MS + int "Direct printer max operation timeout (ms)" + range 1000 300000 + default 90000 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_STROBE_ON_US + int "Direct printer strobe on-time (us)" + range 100 10000 + default 1000 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_STROBE_INTERVAL_US + int "Direct printer strobe interval (us)" + range 0 10000 + default 200 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_MOTOR_STEP_US + int "Direct printer motor step delay (us)" + range 100 20000 + default 2000 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_MOTOR_REVERSE + bool "Direct printer motor direction reversed" + default n + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_STEPS_PER_LINE + int "Direct printer motor steps per printed line" + range 1 8 + default 2 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_GAP_STEPS + int "Direct printer gap move steps" + range 1 2000 + default 96 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_TEMP_MIN_C + int "Direct printer allowed minimum temperature (C)" + range 0 120 + default 15 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_TEMP_MAX_C + int "Direct printer allowed maximum temperature (C)" + range 1 150 + default 55 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_BATTERY_MIN_PERCENT + int "Direct printer minimum battery percent" + range 0 100 + default 5 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_PAPER_PRESENT_LEVEL + int "Direct printer paper-present GPIO level (0/1)" + range 0 1 + default 0 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_BATTERY_EMPTY_MV + int "Direct printer battery empty voltage (mV)" + range 2500 5000 + default 3300 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_BATTERY_FULL_MV + int "Direct printer battery full voltage (mV)" + range 2600 6000 + default 4200 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_BATTERY_DIVIDER_RATIO_X1000 + int "Direct printer battery divider ratio x1000" + range 1000 10000 + default 2000 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_NTC_PULLUP_OHMS + int "Direct printer NTC pull-up resistor (ohms)" + range 1000 200000 + default 10000 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_NTC_R25_OHMS + int "Direct printer NTC R25 resistance (ohms)" + range 1000 200000 + default 10000 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_DIRECT_PRINTER_NTC_BETA + int "Direct printer NTC beta constant" + range 1000 10000 + default 3950 + depends on TQ_DIRECT_PRINTER_ENABLE + +config TQ_API_KEY + string "REST API Key (optional, empty means disabled)" + default "" + +config TQ_Z_IMAGE_API_KEY + string "Z-Image API Key for DashScope HTTP (optional, fallback to Voice API key)" + default "sk-7a50eca6856d4afb968ac3bf512f6d1b" + +config TQ_Z_IMAGE_API_ENDPOINT + string "Z-Image HTTP endpoint" + default "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" + +config TQ_Z_IMAGE_MODEL + string "Z-Image model" + default "z-image-turbo" + +config TQ_Z_IMAGE_DEFAULT_SIZE + string "Z-Image default output size" + default "512*768" + +config TQ_Z_IMAGE_TIMEOUT_MS + int "Z-Image generation timeout (ms)" + range 5000 180000 + default 45000 + +config TQ_Z_IMAGE_DOWNLOAD_TIMEOUT_MS + int "Z-Image image download timeout (ms)" + range 2000 120000 + default 15000 + +config TQ_Z_IMAGE_MAX_RESPONSE_BYTES + int "Z-Image max response body bytes" + range 1024 262144 + default 65536 + +config TQ_Z_IMAGE_MAX_PNG_BYTES + int "Z-Image max downloaded PNG bytes" + range 65536 8388608 + default 4194304 + +config TQ_Z_IMAGE_HTTP_BUFFER_BYTES + int "Z-Image HTTP client buffer bytes" + range 4096 32768 + default 16384 + +config TQ_Z_IMAGE_HTTP_READ_CHUNK_BYTES + int "Z-Image HTTP stream read chunk bytes" + range 4096 32768 + default 16384 + +config TQ_Z_IMAGE_COEX_PREFER_WIFI_DURING_DOWNLOAD + bool "Prefer Wi-Fi RF during image download" + default y + +config TQ_VOICE_API_KEY + string "Voice API Key for DashScope WebSocket (optional)" + default "sk-7a50eca6856d4afb968ac3bf512f6d1b" + +config TQ_VOICE_WS_URI + string "Voice WebSocket URI" + default "wss://dashscope.aliyuncs.com/api-ws/v1/inference" + +config TQ_VOICE_WORKSPACE_ID + string "Voice workspace_id" + default "llm-igyrxzdvbwja5gyt" + +config TQ_VOICE_APP_ID + string "Voice app_id" + default "106a67b690db4686a5d6b40d119653fa" + +config TQ_VOICE_USER_ID + string "Voice client user_id" + default "esp32-user" + +config TQ_VOICE_DEVICE_UUID + string "Voice client device uuid (optional)" + default "" + +config TQ_VOICE_TTS_VOICE + string "Voice TTS voice (optional)" + default "longling_v3" + +config TQ_VOICE_SAMPLE_RATE + int "Voice sample rate" + range 8000 48000 + default 16000 + +config TQ_VOICE_OPUS_FRAME_MS + int "Voice raw-opus frame size (ms)" + range 10 120 + default 100 + +config TQ_VOICE_OPUS_BITRATE_KBPS + int "Voice raw-opus bitrate (kbps)" + range 6 128 + default 32 + +config TQ_VOICE_HEARTBEAT_SEC + int "Voice heartbeat interval seconds" + range 10 55 + default 50 + +config TQ_VOICE_I2C_PORT + int "Voice I2C port" + range 0 1 + default 1 + +config TQ_VOICE_I2C_SDA_PIN + int "Voice I2C SDA GPIO" + range 0 48 + default 6 + +config TQ_VOICE_I2C_SCL_PIN + int "Voice I2C SCL GPIO" + range 0 48 + default 7 + +config TQ_VOICE_CODEC_ADDR + int "ES8311 I2C address" + range 1 126 + default 48 + +config TQ_VOICE_I2S_PORT + int "Voice I2S port" + range 0 1 + default 0 + +config TQ_VOICE_I2S_MCLK_PIN + int "Voice I2S MCLK GPIO" + range -1 48 + default 38 + +config TQ_VOICE_I2S_BCLK_PIN + int "Voice I2S BCLK GPIO" + range 0 48 + default 39 + +config TQ_VOICE_I2S_WS_PIN + int "Voice I2S WS GPIO" + range 0 48 + default 41 + +config TQ_VOICE_I2S_DOUT_PIN + int "Voice I2S DOUT GPIO" + range 0 48 + default 42 + +config TQ_VOICE_I2S_DIN_PIN + int "Voice I2S DIN GPIO" + range 0 48 + default 40 + +config TQ_VOICE_CODEC_PA_PIN + int "ES8311 PA control GPIO" + range -1 48 + default 20 + +menu "Board GPIO Mapping (ESP32-S3)" + +config TQ_POWER_HOLD_PIN + int "Power hold GPIO (drive high to keep power)" + range -1 48 + default 1 + +config TQ_POWER_KEY_PIN + int "Power key GPIO" + range -1 48 + default 47 + +config TQ_LED_R_PIN + int "LED_R GPIO" + range -1 48 + default 2 + +config TQ_LED_G_PIN + int "LED_G GPIO" + range -1 48 + default 21 + +config TQ_KEY_PRINT_PIN + int "Print confirm key / 7.2V boost control GPIO" + range -1 48 + default 0 + +config TQ_KEY_PRINT_BOOST_ENABLE_ON_BOOT + bool "Use KEY_PRINT pin as 7.2V boost enable on boot" + default y + +config TQ_KEY_PRINT_BOOST_ACTIVE_HIGH + bool "KEY_PRINT boost enable level is high" + default y + depends on TQ_KEY_PRINT_BOOST_ENABLE_ON_BOOT + +config TQ_MIC_KEY_PIN + int "Mic key GPIO" + range -1 48 + default 14 + +config TQ_BATTERY_ADC_PIN + int "Battery ADC detect GPIO" + range -1 48 + default 15 + +config TQ_NTC_ADC_PIN + int "NTC ADC GPIO" + range -1 48 + default 18 + +config TQ_PRINT_STB_56_PIN + int "Printer STB5_6 GPIO" + range -1 48 + default 4 + +config TQ_PRINT_STB_34_PIN + int "Printer STB3_4 GPIO" + range -1 48 + default 5 + +config TQ_PRINT_STB_12_PIN + int "Printer STB1_2 GPIO" + range -1 48 + default 16 + +config TQ_PRINT_PAPER_PIN + int "Printer paper detect GPIO" + range -1 48 + default 17 + +config TQ_PRINT_OUTA_P_PIN + int "Printer OUTA+ GPIO" + range -1 48 + default 8 + +config TQ_PRINT_OUTA_N_PIN + int "Printer OUTA- GPIO" + range -1 48 + default 19 + +config TQ_PRINT_OUTB_P_PIN + int "Printer OUTB+ GPIO" + range -1 48 + default 3 + +config TQ_PRINT_OUTB_N_PIN + int "Printer OUTB- GPIO" + range -1 48 + default 46 + +config TQ_PRINT_LAT_PIN + int "Printer LAT GPIO" + range -1 48 + default 45 + +config TQ_SPI_SHARED_MOSI_PIN + int "Printer/Screen shared MOSI GPIO" + range -1 48 + default 9 + +config TQ_PRINTER_MISO_PIN + int "Printer MISO GPIO" + range -1 48 + default 10 + +config TQ_SPI_SHARED_CLK_PIN + int "Printer/Screen shared CLK GPIO" + range -1 48 + default 11 + +config TQ_SCREEN_DC_PIN + int "Screen DC GPIO" + range -1 48 + default 12 + +config TQ_SCREEN_CS_PIN + int "Screen CS GPIO" + range -1 48 + default 13 + +config TQ_SCREEN_BACKLIGHT_PIN + int "Screen backlight GPIO (active high)" + range -1 48 + default 48 + +endmenu + +menu "ST7789 Screen" + +config TQ_SCREEN_ENABLE + bool "Enable ST7789 screen driver" + default y + depends on SOC_GPSPI_SUPPORTED + +config TQ_SCREEN_TEST_PATTERN_ON_BOOT + bool "Show ST7789 test pattern at boot" + default y + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_PIXEL_CLOCK_HZ + int "ST7789 SPI pixel clock (Hz)" + range 1000000 80000000 + default 10000000 + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_H_RES + int "ST7789 horizontal resolution" + range 64 480 + default 320 + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_V_RES + int "ST7789 vertical resolution" + range 64 480 + default 240 + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_DRAW_LINES + int "ST7789 draw chunk lines" + range 1 120 + default 20 + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_SPI_MODE + int "ST7789 SPI mode (0-3)" + range 0 3 + default 0 + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_COLOR_ORDER_BGR + bool "ST7789 color order is BGR" + default n + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_INVERT_COLOR + bool "ST7789 invert color" + default y + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_SWAP_COLOR_BYTES + bool "ST7789 swap color bytes" + default y + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_BACKLIGHT_ACTIVE_HIGH + bool "ST7789 backlight active-high" + default y + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_MIRROR_X + bool "ST7789 mirror X axis" + default y + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_MIRROR_Y + bool "ST7789 mirror Y axis" + default n + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_SWAP_XY + bool "ST7789 swap X/Y axis" + default y + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_X_GAP + int "ST7789 X gap" + range 0 480 + default 0 + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_Y_GAP + int "ST7789 Y gap" + range 0 480 + default 0 + depends on TQ_SCREEN_ENABLE + +config TQ_SCREEN_RESET_PIN + int "ST7789 reset GPIO (-1 if not connected)" + range -1 48 + default -1 + depends on TQ_SCREEN_ENABLE + +endmenu + +config TQ_VOICE_CODEC_PA_PIN_REVERSED + bool "ES8311 PA enable level is active-low" + default n + +config TQ_VOICE_CODEC_OUT_VOL + int "ES8311 output volume (0-100)" + range 0 100 + default 70 + +config TQ_VOICE_CODEC_MIC_GAIN_DB + int "ES8311 mic gain dB" + range 0 42 + default 24 + +endmenu diff --git a/main/idf_component.yml b/main/idf_component.yml new file mode 100644 index 0000000..b3308cf --- /dev/null +++ b/main/idf_component.yml @@ -0,0 +1,21 @@ +## IDF Component Manager Manifest File +dependencies: + ## Required IDF version + idf: + version: '>=4.1.0' + # # Put list of dependencies here + # # For components maintained by Espressif: + # component: "~1.0.0" + # # For 3rd party components: + # username/component: ">=1.0.0,<2.0.0" + # username2/component2: + # version: "~1.0.0" + # # For transient dependencies `public` flag can be set. + # # `public` flag doesn't have an effect dependencies of the `main` component. + # # All dependencies of `main` are public by default. + # public: true + espressif/esp_websocket_client: ^1.6.1 + espressif/esp_codec_dev: ^1.5.4 + espressif/esp_audio_codec: ^2.4.1 + espressif/libpng: ^1.6.55 + lvgl/lvgl: ^8.3.11 diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..c721d7e --- /dev/null +++ b/partitions.csv @@ -0,0 +1,4 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, , 0x6000, +phy_init, data, phy, , 0x1000, +factory, app, factory, , 4M, diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 0000000..7071698 --- /dev/null +++ b/sdkconfig.defaults @@ -0,0 +1,107 @@ +CONFIG_IDF_TARGET="esp32s3" +CONFIG_BT_ENABLED=y +CONFIG_BT_NIMBLE_ENABLED=y +CONFIG_BT_BLUEDROID_ENABLED=n +CONFIG_BT_NIMBLE_ROLE_CENTRAL=y +CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=247 +# CONFIG_ESP_WIFI_SOFTAP_SUPPORT is not set +CONFIG_ESP_WIFI_STA_DISCONNECTED_PM_ENABLE=n +CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=24 +CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=256 +CONFIG_ESP_WIFI_STATIC_TX_BUFFER_NUM=32 +CONFIG_ESP_WIFI_CACHE_TX_BUFFER_NUM=64 +CONFIG_ESP_WIFI_TX_BA_WIN=16 +CONFIG_ESP_WIFI_RX_BA_WIN=32 +CONFIG_LWIP_WND_SCALE=y +CONFIG_LWIP_TCP_RCV_SCALE=3 +CONFIG_LWIP_TCP_SND_BUF_DEFAULT=57344 +CONFIG_LWIP_TCP_WND_DEFAULT=262144 +CONFIG_LWIP_TCP_RECVMBOX_SIZE=192 +CONFIG_LWIP_TCPIP_RECVMBOX_SIZE=256 +CONFIG_TQ_WIFI_SSID="TalkingQ" +CONFIG_TQ_WIFI_PASSWORD="TalkingQ123" +CONFIG_TQ_Z_IMAGE_DEFAULT_SIZE="512*768" +CONFIG_TQ_Z_IMAGE_HTTP_BUFFER_BYTES=16384 +CONFIG_TQ_Z_IMAGE_HTTP_READ_CHUNK_BYTES=16384 +CONFIG_TQ_Z_IMAGE_COEX_PREFER_WIFI_DURING_DOWNLOAD=y +# CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE is not set +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="16MB" + +# ESP32-S3-WROOM-1-N16R8: 16MB Quad Flash + 8MB Octal PSRAM +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_TYPE_AUTO=y +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_BOOT_INIT=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y +CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY=y +CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM=y +CONFIG_TQ_VOICE_API_KEY="sk-7a50eca6856d4afb968ac3bf512f6d1b" +CONFIG_TQ_VOICE_WS_URI="wss://dashscope.aliyuncs.com/api-ws/v1/inference" +CONFIG_TQ_VOICE_WORKSPACE_ID="llm-igyrxzdvbwja5gyt" +CONFIG_TQ_VOICE_APP_ID="106a67b690db4686a5d6b40d119653fa" +CONFIG_TQ_VOICE_USER_ID="esp32-user" +CONFIG_TQ_VOICE_DEVICE_UUID="" +CONFIG_TQ_VOICE_TTS_VOICE="longling_v3" +CONFIG_TQ_VOICE_SAMPLE_RATE=16000 +CONFIG_TQ_VOICE_OPUS_FRAME_MS=100 +CONFIG_TQ_VOICE_OPUS_BITRATE_KBPS=32 +CONFIG_TQ_VOICE_HEARTBEAT_SEC=50 +CONFIG_TQ_VOICE_I2C_PORT=1 +CONFIG_TQ_VOICE_I2C_SDA_PIN=6 +CONFIG_TQ_VOICE_I2C_SCL_PIN=7 +CONFIG_TQ_VOICE_CODEC_ADDR=48 +CONFIG_TQ_VOICE_I2S_PORT=0 +CONFIG_TQ_VOICE_I2S_MCLK_PIN=38 +CONFIG_TQ_VOICE_I2S_BCLK_PIN=39 +CONFIG_TQ_VOICE_I2S_WS_PIN=41 +CONFIG_TQ_VOICE_I2S_DOUT_PIN=42 +CONFIG_TQ_VOICE_I2S_DIN_PIN=40 +CONFIG_TQ_VOICE_CODEC_PA_PIN=20 +CONFIG_TQ_VOICE_CODEC_OUT_VOL=70 +CONFIG_TQ_VOICE_CODEC_MIC_GAIN_DB=24 +CONFIG_TQ_POWER_HOLD_PIN=1 +CONFIG_TQ_POWER_KEY_PIN=47 +CONFIG_TQ_LED_R_PIN=2 +CONFIG_TQ_LED_G_PIN=21 +CONFIG_TQ_KEY_PRINT_PIN=0 +CONFIG_TQ_KEY_PRINT_BOOST_ENABLE_ON_BOOT=y +CONFIG_TQ_KEY_PRINT_BOOST_ACTIVE_HIGH=y +CONFIG_TQ_MIC_KEY_PIN=14 +CONFIG_TQ_BATTERY_ADC_PIN=15 +CONFIG_TQ_NTC_ADC_PIN=18 +CONFIG_TQ_PRINT_STB_56_PIN=4 +CONFIG_TQ_PRINT_STB_34_PIN=5 +CONFIG_TQ_PRINT_STB_12_PIN=16 +CONFIG_TQ_PRINT_PAPER_PIN=17 +CONFIG_TQ_PRINT_OUTA_P_PIN=8 +CONFIG_TQ_PRINT_OUTA_N_PIN=19 +CONFIG_TQ_PRINT_OUTB_P_PIN=3 +CONFIG_TQ_PRINT_OUTB_N_PIN=46 +CONFIG_TQ_PRINT_LAT_PIN=45 +CONFIG_TQ_SPI_SHARED_MOSI_PIN=9 +CONFIG_TQ_PRINTER_MISO_PIN=10 +CONFIG_TQ_SPI_SHARED_CLK_PIN=11 +CONFIG_TQ_SCREEN_DC_PIN=12 +CONFIG_TQ_SCREEN_CS_PIN=13 +CONFIG_TQ_SCREEN_BACKLIGHT_PIN=48 +CONFIG_TQ_SCREEN_ENABLE=y +CONFIG_TQ_SCREEN_TEST_PATTERN_ON_BOOT=y +CONFIG_TQ_SCREEN_PIXEL_CLOCK_HZ=10000000 +CONFIG_TQ_SCREEN_H_RES=320 +CONFIG_TQ_SCREEN_V_RES=240 +CONFIG_TQ_SCREEN_DRAW_LINES=20 +CONFIG_TQ_SCREEN_SPI_MODE=0 +# CONFIG_TQ_SCREEN_COLOR_ORDER_BGR is not set +CONFIG_TQ_SCREEN_INVERT_COLOR=y +CONFIG_TQ_SCREEN_SWAP_COLOR_BYTES=y +CONFIG_TQ_SCREEN_BACKLIGHT_ACTIVE_HIGH=y +CONFIG_TQ_SCREEN_MIRROR_X=y +CONFIG_TQ_SCREEN_SWAP_XY=y +CONFIG_TQ_SCREEN_X_GAP=0 +CONFIG_TQ_SCREEN_Y_GAP=0 +CONFIG_TQ_SCREEN_RESET_PIN=-1 diff --git a/tools/gen_cn16_font.py b/tools/gen_cn16_font.py new file mode 100644 index 0000000..e6e17fa --- /dev/null +++ b/tools/gen_cn16_font.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +Generate compact Chinese 16x16 bitmap font assets for ESP32 firmware. + +Output: + - main/domain/assets/fonts/cn16_index.bin (entry: uint32 codepoint + uint16 glyph_id, little-endian) + - main/domain/assets/fonts/cn16_glyphs.bin (glyph bytes, 32 bytes per glyph, row-major, MSB-left) + +Glyph set: + All valid single-character GB2312 code points. +""" + +from __future__ import annotations + +import argparse +import struct +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + + +def default_out_dir() -> Path: + repo_root = Path(__file__).resolve().parent.parent + return repo_root / "main" / "domain" / "assets" / "fonts" + + +def iter_gb2312_codepoints(): + seen = set() + for hi in range(0xA1, 0xFF): + for lo in range(0xA1, 0xFF): + bs = bytes((hi, lo)) + try: + ch = bs.decode("gb2312") + except UnicodeDecodeError: + continue + if len(ch) != 1: + continue + cp = ord(ch) + if cp in seen: + continue + seen.add(cp) + yield cp + + +def render_glyph_16(font: ImageFont.FreeTypeFont, cp: int) -> bytes: + ch = chr(cp) + img = Image.new("L", (16, 16), color=255) + draw = ImageDraw.Draw(img) + + # Center glyph in 16x16 cell. + bbox = draw.textbbox((0, 0), ch, font=font) + if bbox is None: + bbox = (0, 0, 0, 0) + x0, y0, x1, y1 = bbox + w = x1 - x0 + h = y1 - y0 + + draw_x = (16 - w) // 2 - x0 + draw_y = (16 - h) // 2 - y0 + draw.text((draw_x, draw_y), ch, font=font, fill=0) + + # Convert to 1bpp packed rows, MSB-left. + px = img.load() + out = bytearray() + for y in range(16): + row_bits = 0 + for x in range(16): + bit = 1 if px[x, y] < 128 else 0 + row_bits = (row_bits << 1) | bit + out.append((row_bits >> 8) & 0xFF) + out.append(row_bits & 0xFF) + return bytes(out) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--font", + default="app/src/main/assets/fonts/msyh.ttc", + help="Source CJK font path (TTF/TTC/OTF)", + ) + parser.add_argument( + "--font-index", + type=int, + default=0, + help="TTC face index", + ) + parser.add_argument( + "--out-dir", + default=str(default_out_dir()), + help="Output directory (default: /main/domain/assets/fonts)", + ) + args = parser.parse_args() + + font_path = Path(args.font).resolve() + if not font_path.exists(): + raise FileNotFoundError(f"font not found: {font_path}") + + out_dir = Path(args.out_dir).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + index_path = out_dir / "cn16_index.bin" + glyph_path = out_dir / "cn16_glyphs.bin" + + font = ImageFont.truetype(str(font_path), 16, index=args.font_index) + + codepoints = sorted(iter_gb2312_codepoints()) + glyphs = [] + index_entries = [] + + for gid, cp in enumerate(codepoints): + glyph = render_glyph_16(font, cp) + glyphs.append(glyph) + index_entries.append((cp, gid)) + + with open(index_path, "wb") as f: + for cp, gid in index_entries: + f.write(struct.pack("