From c4a1da9d8fe512ee795252fef66ea3d008149e03 Mon Sep 17 00:00:00 2001 From: chenjintao Date: Tue, 1 Sep 2026 14:25:08 +0800 Subject: [PATCH] chore: archive diagnostics and project documentation --- .gitignore | 1 + ...control_plane_multimodal_session_service.c | 8 + ...f => SCH_招财猫0.3&商界0.2_2026-08-03.pdf} | 7372 +++++++++-------- .../backlight-pwm-brightness-control.zh-CN.md | 268 + ...-reprovisioning-memory-protection.zh-CN.md | 426 + docs/controlled-low-battery-shutdown.zh-CN.md | 366 + docs/factory-cli-wifi-management.zh-CN.md | 384 + ...-cloud-chouqian-session-readiness.zh-CN.md | 312 + ...uqian-system-event-stack-overflow.zh-CN.md | 257 + ...x-disable-tls-renegotiation-crash.zh-CN.md | 363 + .../bugfix-downstream-stall-recovery.zh-CN.md | 375 + ...ersistent-local-responding-worker.zh-CN.md | 577 ++ ...ix-wall-clock-timezone-before-lcd.zh-CN.md | 289 + ...ix-websocket-fresh-auth-reconnect.zh-CN.md | 273 + .../button-cloud-chouqian-flow.zh-CN.md | 478 ++ .../device-auth-v2-flow.zh-CN.md | 1006 +++ ...stream-audio-stutter-fix-analysis.zh-CN.md | 1117 +++ .../flash-and-memory-capacity-analysis.md | 960 +++ .../lcd-wall-clock-display.zh-CN.md | 530 ++ .../littlefs-resource-build-flash-mount.md | 810 ++ ...al-voice-startup-upload-isolation.zh-CN.md | 417 + ...ta-and-multimodal-stability-fixes.zh-CN.md | 849 ++ ...ic-check-and-https-download-fixes.zh-CN.md | 242 + .../project-runtime-workflow.md | 708 ++ ...diagnostics-and-network-recovery-config.md | 172 + .../versioned-firmware-artifact.zh-CN.md | 267 + docs/knowledge vault/voice-uplink-flow-zh.md | 1050 +++ .../websocket-and-audio-flow.md | 1033 +++ ...d-png-storage-optimization-report.zh-CN.md | 505 ++ .../light-standby-power-optimization.zh-CN.md | 707 ++ docs/network-outage-voice-feedback.zh-CN.md | 557 ++ tools/firmware_info.sh | 51 + tools/play_prompt_opus.command | 33 + tools/play_prompt_opus.py | 142 + tools/view_flash_logs.command | 198 + 35 files changed, 19843 insertions(+), 3260 deletions(-) rename docs/{SCH_招财猫0.3&商界0.2_2026-07-24.pdf => SCH_招财猫0.3&商界0.2_2026-08-03.pdf} (93%) create mode 100644 docs/backlight-pwm-brightness-control.zh-CN.md create mode 100644 docs/ble-reprovisioning-memory-protection.zh-CN.md create mode 100644 docs/controlled-low-battery-shutdown.zh-CN.md create mode 100644 docs/factory-cli-wifi-management.zh-CN.md create mode 100644 docs/knowledge vault/bugfix-cloud-chouqian-session-readiness.zh-CN.md create mode 100644 docs/knowledge vault/bugfix-cloud-chouqian-system-event-stack-overflow.zh-CN.md create mode 100644 docs/knowledge vault/bugfix-disable-tls-renegotiation-crash.zh-CN.md create mode 100644 docs/knowledge vault/bugfix-downstream-stall-recovery.zh-CN.md create mode 100644 docs/knowledge vault/bugfix-persistent-local-responding-worker.zh-CN.md create mode 100644 docs/knowledge vault/bugfix-wall-clock-timezone-before-lcd.zh-CN.md create mode 100644 docs/knowledge vault/bugfix-websocket-fresh-auth-reconnect.zh-CN.md create mode 100644 docs/knowledge vault/button-cloud-chouqian-flow.zh-CN.md create mode 100644 docs/knowledge vault/device-auth-v2-flow.zh-CN.md create mode 100644 docs/knowledge vault/downstream-audio-stutter-fix-analysis.zh-CN.md create mode 100644 docs/knowledge vault/flash-and-memory-capacity-analysis.md create mode 100644 docs/knowledge vault/lcd-wall-clock-display.zh-CN.md create mode 100644 docs/knowledge vault/littlefs-resource-build-flash-mount.md create mode 100644 docs/knowledge vault/multimodal-voice-startup-upload-isolation.zh-CN.md create mode 100644 docs/knowledge vault/ota-and-multimodal-stability-fixes.zh-CN.md create mode 100644 docs/knowledge vault/ota-periodic-check-and-https-download-fixes.zh-CN.md create mode 100644 docs/knowledge vault/project-runtime-workflow.md create mode 100644 docs/knowledge vault/runtime-diagnostics-and-network-recovery-config.md create mode 100644 docs/knowledge vault/versioned-firmware-artifact.zh-CN.md create mode 100644 docs/knowledge vault/voice-uplink-flow-zh.md create mode 100644 docs/knowledge vault/websocket-and-audio-flow.md create mode 100644 docs/lcd-png-storage-optimization-report.zh-CN.md create mode 100644 docs/light-standby-power-optimization.zh-CN.md create mode 100644 docs/network-outage-voice-feedback.zh-CN.md create mode 100755 tools/firmware_info.sh create mode 100755 tools/play_prompt_opus.command create mode 100755 tools/play_prompt_opus.py create mode 100755 tools/view_flash_logs.command diff --git a/.gitignore b/.gitignore index b4403b8..a6fbf33 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ Desktop.ini # Logs and crash dumps *.log *.stackdump +/flash_log_dump/ # Python caches / virtualenvs __pycache__/ diff --git a/components/control_plane/src/multimodal/control_plane_multimodal_session_service.c b/components/control_plane/src/multimodal/control_plane_multimodal_session_service.c index bb7838d..1399c02 100644 --- a/components/control_plane/src/multimodal/control_plane_multimodal_session_service.c +++ b/components/control_plane/src/multimodal/control_plane_multimodal_session_service.c @@ -1502,6 +1502,14 @@ static void process_text_json(const char *json_text) if (is_finished && text_len > 0U) { log_text_preview("TTS", tts_text, is_finished); } + if (is_finished) { + log_text_preview("Response text", + cJSON_IsString(text) ? text->valuestring : NULL, + true); + log_text_preview("Response spoken", + cJSON_IsString(spoken) ? spoken->valuestring : NULL, + true); + } if (cJSON_IsString(text) && text->valuestring != NULL) { notify_responding_text(text->valuestring, is_finished); diff --git a/docs/SCH_招财猫0.3&商界0.2_2026-07-24.pdf b/docs/SCH_招财猫0.3&商界0.2_2026-08-03.pdf similarity index 93% rename from docs/SCH_招财猫0.3&商界0.2_2026-07-24.pdf rename to docs/SCH_招财猫0.3&商界0.2_2026-08-03.pdf index 69c0d14..99ddd4b 100644 --- a/docs/SCH_招财猫0.3&商界0.2_2026-07-24.pdf +++ b/docs/SCH_招财猫0.3&商界0.2_2026-08-03.pdf @@ -1842,7 +1842,7 @@ endobj endobj 6 0 obj << -/Length 67056 +/Length 67127 >> stream 0.14 w @@ -2411,7 +2411,7 @@ BT 10.80 TL 0.000 g 705.21 114.00 Td -<0032003000320036002d00300037002d00320032> Tj +<0032003000320036002d00300037002d00320039> Tj ET 1 J 1 j @@ -3976,8 +3976,8 @@ S 0.72 w 0.53 0.00 0.00 RG [] 0 d -158.040 395.640 m -158.040 388.440 l +154.440 395.640 m +154.440 388.440 l S 1 J 1 j @@ -3985,8 +3985,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -165.240 388.440 m -150.840 388.440 l +161.640 388.440 m +147.240 388.440 l S 1 J 1 j @@ -3994,8 +3994,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -163.080 386.280 m -153.000 386.280 l +159.480 386.280 m +149.400 386.280 l S 1 J 1 j @@ -4003,8 +4003,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -160.920 384.120 m -155.160 384.120 l +157.320 384.120 m +151.560 384.120 l S 1 J 1 j @@ -4012,8 +4012,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -158.760 381.960 m -157.320 381.960 l +155.160 381.960 m +153.720 381.960 l S 1 J 1 j @@ -4048,8 +4048,8 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -237.240 316.440 m -230.040 316.440 l +233.640 316.440 m +226.440 316.440 l S 1 J 1 j @@ -4057,8 +4057,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -230.040 312.840 m -230.040 320.040 l +226.440 312.840 m +226.440 320.040 l S 0.00 0.00 1.00 RG 0.51 w @@ -4068,7 +4068,7 @@ BT /F2 6.352940820080207 Tf 7.20 TL 0.000 0.000 1.000 rg -215.19 314.11 Td +211.59 314.11 Td (VCC) Tj ET 1 J @@ -4176,8 +4176,8 @@ S 0.72 w 0.53 0.00 0.00 RG [] 0 d -442.440 284.040 m -442.440 276.840 l +442.440 280.440 m +442.440 273.240 l S 1 J 1 j @@ -4185,8 +4185,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -449.640 276.840 m -435.240 276.840 l +449.640 273.240 m +435.240 273.240 l S 1 J 1 j @@ -4194,8 +4194,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -447.480 274.680 m -437.400 274.680 l +447.480 271.080 m +437.400 271.080 l S 1 J 1 j @@ -4203,8 +4203,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -445.320 272.520 m -439.560 272.520 l +445.320 268.920 m +439.560 268.920 l S 1 J 1 j @@ -4212,8 +4212,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -443.160 270.360 m -441.720 270.360 l +443.160 266.760 m +441.720 266.760 l S 1 J 1 j @@ -4781,8 +4781,8 @@ ET 0.63 0.00 0.00 RG 0.00 g [] 0 d -163.800 410.040 m -152.280 410.040 l +160.200 408.600 m +148.680 408.600 l S 1 J 1 j @@ -4790,8 +4790,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -158.040 418.680 m -158.040 412.920 l +154.440 417.240 m +154.440 411.480 l S 1 J 1 j @@ -4799,8 +4799,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -152.280 412.920 m -163.800 412.920 l +148.680 411.480 m +160.200 411.480 l S 1 J 1 j @@ -4808,31 +4808,31 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -158.040 410.040 m -158.040 404.280 l +154.440 408.600 m +154.440 402.840 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -158.040 397.080 m -158.040 404.280 l +154.440 395.640 m +154.440 402.840 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -158.040 425.880 m -158.040 418.680 l +154.440 424.440 m +154.440 417.240 l S 7.20 w BT /F2 6.352940820080207 Tf 7.20 TL 0.000 0.000 1.000 rg -144.36 404.83 Td +140.76 403.39 Td (10uF) Tj ET 7.20 w @@ -4840,7 +4840,7 @@ BT /F2 6.352940820080207 Tf 7.20 TL 0.000 0.000 0.502 rg -150.12 415.63 Td +146.52 414.19 Td (C1) Tj ET 1 J @@ -5180,14 +5180,14 @@ S 0.00 0.53 0.00 RG [] 0 d 0.72 w -158.040 425.880 m -158.040 428.040 l +154.440 428.040 m +154.440 424.440 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -150.840 428.040 m -158.040 428.040 l +154.440 428.040 m +150.840 428.040 l S 1 J 1 j @@ -5233,7 +5233,7 @@ S [] 0 d 0.72 w 240.840 316.440 m -237.240 316.440 l +233.640 316.440 l S 1 J 1 j @@ -5241,15 +5241,21 @@ S [] 0 d 0.72 w 237.240 428.040 m -237.240 428.040 l +233.640 428.040 l S +0.80 0.00 0.00 rg +239.04 428.04 m 239.04 429.03 238.23 429.84 237.24 429.84 c +236.25 429.84 235.44 429.03 235.44 428.04 c +235.44 427.05 236.25 426.24 237.24 426.24 c +238.23 426.24 239.04 427.05 239.04 428.04 c +f 1 J 1 j 0.00 0.53 0.00 RG [] 0 d 0.72 w 114.840 435.240 m -158.040 435.240 l +154.440 435.240 l S 1 J 1 j @@ -5264,8 +5270,8 @@ S 0.00 0.53 0.00 RG [] 0 d 0.72 w -158.040 397.080 m -158.040 395.640 l +154.440 395.640 m +154.440 395.640 l S 1 J 1 j @@ -5294,9 +5300,15 @@ S 0.00 0.53 0.00 RG [] 0 d 0.72 w -446.040 284.040 m +442.440 280.440 m 442.440 284.040 l S +0.00 0.53 0.00 RG +[] 0 d +0.72 w +442.440 284.040 m +446.040 284.040 l +S 7.20 w BT 5.76 TL @@ -5756,25 +5768,25 @@ S 0.72 w 0.63 0.00 0.00 RG [] 0 d -165.24 449.64 64.80 -28.80 re +161.64 449.64 64.80 -28.80 re S 1 J 1 j 0.72 w 0.63 0.00 0.00 RG [] 0 d -169.92 446.04 m 169.92 446.64 169.44 447.12 168.84 447.12 c -168.24 447.12 167.76 446.64 167.76 446.04 c -167.76 445.44 168.24 444.96 168.84 444.96 c -169.44 444.96 169.92 445.44 169.92 446.04 c +166.32 446.04 m 166.32 446.64 165.84 447.12 165.24 447.12 c +164.64 447.12 164.16 446.64 164.16 446.04 c +164.16 445.44 164.64 444.96 165.24 444.96 c +165.84 444.96 166.32 445.44 166.32 446.04 c S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -158.040 442.440 m -165.240 442.440 l +154.440 442.440 m +161.640 442.440 l S 0.00 G 0.41 w @@ -5784,7 +5796,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -167.90 439.45 Td +164.30 439.45 Td (CHRG#) Tj ET 7.20 w @@ -5792,7 +5804,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -162.05 443.05 Td +158.45 443.05 Td (1) Tj ET 1 J @@ -5800,8 +5812,8 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -158.040 435.240 m -165.240 435.240 l +154.440 435.240 m +161.640 435.240 l S 0.00 G 0.41 w @@ -5811,7 +5823,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -167.90 432.25 Td +164.30 432.25 Td (GND) Tj ET 7.20 w @@ -5819,7 +5831,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -162.05 435.85 Td +158.45 435.85 Td (2) Tj ET 1 J @@ -5827,8 +5839,8 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -158.040 428.040 m -165.240 428.040 l +154.440 428.040 m +161.640 428.040 l S 0.00 G 0.41 w @@ -5838,7 +5850,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -167.90 425.05 Td +164.30 425.05 Td (BAT) Tj ET 7.20 w @@ -5846,22 +5858,22 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -162.05 428.65 Td +158.45 428.65 Td (3) Tj ET 0.80 0.00 0.00 rg -159.84 428.04 m 159.84 429.03 159.03 429.84 158.04 429.84 c -157.05 429.84 156.24 429.03 156.24 428.04 c -156.24 427.05 157.05 426.24 158.04 426.24 c -159.03 426.24 159.84 427.05 159.84 428.04 c +156.24 428.04 m 156.24 429.03 155.43 429.84 154.44 429.84 c +153.45 429.84 152.64 429.03 152.64 428.04 c +152.64 427.05 153.45 426.24 154.44 426.24 c +155.43 426.24 156.24 427.05 156.24 428.04 c f 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -237.240 428.040 m -230.040 428.040 l +233.640 428.040 m +226.440 428.040 l S 0.00 G 0.41 w @@ -5871,7 +5883,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -216.65 425.05 Td +213.05 425.05 Td (VCC) Tj ET 7.20 w @@ -5879,22 +5891,16 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -230.40 428.65 Td +226.80 428.65 Td (4) Tj ET -0.80 0.00 0.00 rg -239.04 428.04 m 239.04 429.03 238.23 429.84 237.24 429.84 c -236.25 429.84 235.44 429.03 235.44 428.04 c -235.44 427.05 236.25 426.24 237.24 426.24 c -238.23 426.24 239.04 427.05 239.04 428.04 c -f 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -237.240 435.240 m -230.040 435.240 l +233.640 435.240 m +226.440 435.240 l S 0.00 G 0.41 w @@ -5904,7 +5910,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -207.61 432.25 Td +204.01 432.25 Td (STDBY#) Tj ET 7.20 w @@ -5912,7 +5918,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -230.40 435.85 Td +226.80 435.85 Td (5) Tj ET 1 J @@ -5920,18 +5926,18 @@ ET 0.72 w 0.20 0.80 0.20 RG [] 0 d -234.360 438.120 m -240.120 432.360 l -234.360 432.360 m -240.120 438.120 l +230.760 438.120 m +236.520 432.360 l +230.760 432.360 m +236.520 438.120 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -237.240 442.440 m -230.040 442.440 l +233.640 442.440 m +226.440 442.440 l S 0.00 G 0.41 w @@ -5941,7 +5947,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -212.41 439.45 Td +208.81 439.45 Td (PROG) Tj ET 7.20 w @@ -5949,7 +5955,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -230.40 443.05 Td +226.80 443.05 Td (6) Tj ET 7.20 w @@ -5957,7 +5963,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 0.502 rg -165.24 450.91 Td +161.64 450.91 Td (U4) Tj ET 7.20 w @@ -5965,7 +5971,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 1.000 rg -165.24 414.91 Td +161.64 414.91 Td (TP4057) Tj ET 1 J @@ -6046,14 +6052,14 @@ S 0.00 0.53 0.00 RG [] 0 d 0.72 w -158.040 442.440 m -136.440 442.440 l +136.440 442.440 m +136.440 446.040 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w 136.440 442.440 m -136.440 446.040 l +154.440 442.440 l S 0.80 0.00 0.00 rg 138.24 442.44 m 138.24 443.43 137.43 444.24 136.44 444.24 c @@ -6123,7 +6129,7 @@ S [] 0 d 0.72 w 237.240 442.440 m -237.240 442.440 l +233.640 442.440 l S 2 J 0 j @@ -6870,7 +6876,7 @@ S BT 5.76 TL 0.000 g -21.24 454.51 Td +39.24 454.51 Td /F2 5.082352656064165 Tf (Charge) Tj /F3 5.082352656064165 Tf @@ -11797,7 +11803,7 @@ endobj endobj 10 0 obj << -/Length 109832 +/Length 111894 >> stream 0.14 w @@ -12366,7 +12372,7 @@ BT 10.80 TL 0.000 g 1349.25 120.12 Td -<0032003000320036002d00300037002d00310036> Tj +<0032003000320036002d00300037002d00320039> Tj ET 1 J 1 j @@ -13645,8 +13651,8 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -737.280 369.360 m -737.280 362.160 l +791.280 369.360 m +791.280 362.160 l S 1 J 1 j @@ -13654,8 +13660,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -744.480 362.160 m -730.080 362.160 l +798.480 362.160 m +784.080 362.160 l S 1 J 1 j @@ -13663,8 +13669,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -742.320 360.000 m -732.240 360.000 l +796.320 360.000 m +786.240 360.000 l S 1 J 1 j @@ -13672,8 +13678,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -740.160 357.840 m -734.400 357.840 l +794.160 357.840 m +788.400 357.840 l S 1 J 1 j @@ -13681,16 +13687,16 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -738.000 355.680 m -736.560 355.680 l +792.000 355.680 m +790.560 355.680 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -737.280 398.160 m -737.280 405.360 l +791.280 398.160 m +791.280 405.360 l S 1 J 1 j @@ -13698,8 +13704,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -733.680 405.360 m -740.880 405.360 l +787.680 405.360 m +794.880 405.360 l S 0.00 0.00 1.00 RG 0.51 w @@ -13709,7 +13715,7 @@ BT /F2 6.352940820080207 Tf 7.20 TL 0.000 0.000 1.000 rg -731.63 408.07 Td +785.63 408.07 Td (3V3) Tj ET 1 J @@ -13842,7 +13848,7 @@ ET BT 7.20 TL 0.000 g -1381.68 442.63 Td +1381.68 410.23 Td /F3 6.352940820080207 Tf <54aa54aa59348f935165ff0c> Tj /F2 6.352940820080207 Tf @@ -14580,168 +14586,6 @@ ET 0.72 w 0.63 0.00 0.00 RG [] 0 d -924.48 189.36 14.40 -36.00 re -S -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -[] 0 d -929.16 185.76 m 929.16 186.36 928.68 186.84 928.08 186.84 c -927.48 186.84 927.00 186.36 927.00 185.76 c -927.00 185.16 927.48 184.68 928.08 184.68 c -928.68 184.68 929.16 185.16 929.16 185.76 c -S -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d -917.280 174.960 m -924.480 174.960 l -S -0.00 G -0.41 w -[] 0 d -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -927.14 171.97 Td -(1) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -921.29 175.57 Td -(1) Tj -ET -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d -917.280 167.760 m -924.480 167.760 l -S -0.00 G -0.41 w -[] 0 d -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -927.14 164.77 Td -(2) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -921.29 168.37 Td -(2) Tj -ET -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d -931.680 196.560 m -931.680 189.360 l -S -0.00 G -0.41 w -[] 0 d -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -0.00 1.00 -1.00 0.00 933.29 182.49 Tm -(3) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -0.00 1.00 -1.00 0.00 929.69 188.34 Tm -(3) Tj -ET -1 J -1 j -0.72 w -0.20 0.80 0.20 RG -[] 0 d -928.800 199.440 m -934.560 193.680 l -928.800 193.680 m -934.560 199.440 l -S -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d -931.680 146.160 m -931.680 153.360 l -S -0.00 G -0.41 w -[] 0 d -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -0.00 1.00 -1.00 0.00 933.29 154.65 Tm -(4) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -0.00 1.00 -1.00 0.00 929.69 148.80 Tm -(4) Tj -ET -1 J -1 j -0.72 w -0.20 0.80 0.20 RG -[] 0 d -928.800 149.040 m -934.560 143.280 l -928.800 143.280 m -934.560 149.040 l -S -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 0.000 0.502 rg -942.48 197.83 Td -(J6) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 0.000 1.000 rg -942.48 193.51 Td -(HC-1.25-2PLT) Tj -ET -2 J -0 j -72 M -0.72 w -0.63 0.00 0.00 RG -[] 0 d 1406.88 495.36 14.40 -36.00 re S 1 J @@ -14838,16 +14682,6 @@ ET 1 J 1 j 0.72 w -0.20 0.80 0.20 RG -[] 0 d -1411.200 505.440 m -1416.960 499.680 l -1411.200 499.680 m -1416.960 505.440 l -S -1 J -1 j -0.72 w 0.53 0.00 0.00 RG [] 0 d 1414.080 452.160 m @@ -14872,16 +14706,6 @@ BT 0.00 1.00 -1.00 0.00 1412.09 454.80 Tm (4) Tj ET -1 J -1 j -0.72 w -0.20 0.80 0.20 RG -[] 0 d -1411.200 455.040 m -1416.960 449.280 l -1411.200 449.280 m -1416.960 455.040 l -S 7.20 w BT /F2 5.082352656064165 Tf @@ -14898,6 +14722,148 @@ BT 1424.88 482.23 Td (HC-1.25-2PLT) Tj ET +2 J +0 j +72 M +0.72 w +0.63 0.00 0.00 RG +[] 0 d +924.48 189.36 14.40 -36.00 re +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +[] 0 d +929.16 185.76 m 929.16 186.36 928.68 186.84 928.08 186.84 c +927.48 186.84 927.00 186.36 927.00 185.76 c +927.00 185.16 927.48 184.68 928.08 184.68 c +928.68 184.68 929.16 185.16 929.16 185.76 c +S +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +917.280 174.960 m +924.480 174.960 l +S +0.00 G +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +927.14 171.97 Td +(1) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +921.29 175.57 Td +(1) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +917.280 167.760 m +924.480 167.760 l +S +0.00 G +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +927.14 164.77 Td +(2) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +921.29 168.37 Td +(2) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +931.680 196.560 m +931.680 189.360 l +S +0.00 G +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +0.00 1.00 -1.00 0.00 933.29 182.49 Tm +(3) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +0.00 1.00 -1.00 0.00 929.69 188.34 Tm +(3) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +931.680 146.160 m +931.680 153.360 l +S +0.00 G +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +0.00 1.00 -1.00 0.00 933.29 154.65 Tm +(4) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +0.00 1.00 -1.00 0.00 929.69 148.80 Tm +(4) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 0.502 rg +942.48 197.83 Td +(J6) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +942.48 193.51 Td +(HC-1.25-2PLT) Tj +ET 1 J 1 j 0.72 w @@ -15996,40 +15962,40 @@ f 0.00 0.53 0.00 RG [] 0 d 0.72 w -722.880 398.160 m -737.280 398.160 l +791.280 398.160 m +776.880 398.160 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -722.880 398.160 m -708.480 398.160 l +762.480 398.160 m +776.880 398.160 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -708.480 398.160 m -694.080 398.160 l +748.080 398.160 m +762.480 398.160 l S 1 J 1 j 0.00 0.53 0.00 RG [] 0 d 0.72 w -722.880 369.360 m -737.280 369.360 l +791.280 369.360 m +776.880 369.360 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -722.880 369.360 m -708.480 369.360 l +762.480 369.360 m +776.880 369.360 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -708.480 369.360 m -694.080 369.360 l +748.080 369.360 m +762.480 369.360 l S 0.00 0.00 1.00 RG 0.51 w @@ -18238,102 +18204,6 @@ f 0.72 w 0.53 0.00 0.00 RG [] 0 d -794.880 182.160 m -792.720 182.160 l -S -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d -766.080 182.160 m -768.240 182.160 l -S -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -0.00 g -[] 0 d -768.327 182.203 m -768.465 183.388 l -769.084 184.400 l -770.052 185.066 l -771.196 185.295 l -772.339 185.063 l -773.306 184.394 l -773.923 183.381 l -774.057 182.196 l -S -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -0.00 g -[] 0 d -774.433 182.203 m -774.569 183.388 l -775.186 184.401 l -776.154 185.068 l -777.298 185.299 l -778.441 185.068 l -779.409 184.401 l -780.027 183.388 l -780.162 182.203 l -S -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -0.00 g -[] 0 d -780.531 182.203 m -780.667 183.388 l -781.285 184.401 l -782.252 185.068 l -783.396 185.299 l -784.540 185.068 l -785.507 184.401 l -786.125 183.388 l -786.261 182.203 l -S -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -0.00 g -[] 0 d -786.745 182.204 m -786.895 183.378 l -787.518 184.376 l -788.482 185.032 l -789.617 185.258 l -790.752 185.029 l -791.714 184.371 l -792.334 183.371 l -792.482 182.197 l -S -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 0.000 0.502 rg -769.68 187.03 Td -(FB1) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 0.000 1.000 rg -766.08 176.23 Td -(BLM21PG221SN1D) Tj -ET -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d 1100.880 268.560 m 1098.720 268.560 l S @@ -18523,28 +18393,19 @@ BT ET 1 J 1 j -0.00 0.53 0.00 RG -[] 0 d 0.72 w -1122.480 268.560 m -1100.880 268.560 l -S -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -0.00 g +0.53 0.00 0.00 RG [] 0 d -811.440 137.520 m -799.920 137.520 l +794.880 182.160 m +792.720 182.160 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -805.680 124.560 m -805.680 131.760 l +766.080 182.160 m +768.240 182.160 l S 1 J 1 j @@ -18552,8 +18413,15 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -805.680 146.160 m -805.680 140.400 l +768.327 182.203 m +768.465 183.388 l +769.084 184.400 l +770.052 185.066 l +771.196 185.295 l +772.339 185.063 l +773.306 184.394 l +773.923 183.381 l +774.057 182.196 l S 1 J 1 j @@ -18561,16 +18429,15 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -799.920 140.400 m -811.440 140.400 l -S -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d -805.680 153.360 m -805.680 146.160 l +774.433 182.203 m +774.569 183.388 l +775.186 184.401 l +776.154 185.068 l +777.298 185.299 l +778.441 185.068 l +779.409 184.401 l +780.027 183.388 l +780.162 182.203 l S 1 J 1 j @@ -18578,27 +18445,58 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -805.680 137.520 m -805.680 131.760 l +780.531 182.203 m +780.667 183.388 l +781.285 184.401 l +782.252 185.068 l +783.396 185.299 l +784.540 185.068 l +785.507 184.401 l +786.125 183.388 l +786.261 182.203 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +786.745 182.204 m +786.895 183.378 l +787.518 184.376 l +788.482 185.032 l +789.617 185.258 l +790.752 185.029 l +791.714 184.371 l +792.334 183.371 l +792.482 182.197 l S 7.20 w BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 0.502 rg -808.56 143.83 Td -(C34) Tj +769.68 187.03 Td +(FB1) Tj ET 7.20 w BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 1.000 rg -807.84 132.31 Td -(330pF) Tj +766.08 176.23 Td +(BLM21PG221SN1D) Tj ET 1 J 1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +1122.480 268.560 m +1100.880 268.560 l +S +1 J +1 j 0.72 w 0.63 0.00 0.00 RG 0.00 g @@ -18671,31 +18569,16 @@ ET 0.63 0.00 0.00 RG 0.00 g [] 0 d -743.040 382.320 m -731.520 382.320 l +811.440 137.520 m +799.920 137.520 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -737.280 369.360 m -737.280 376.560 l -S -0.80 0.00 0.00 rg -739.08 369.36 m 739.08 370.35 738.27 371.16 737.28 371.16 c -736.29 371.16 735.48 370.35 735.48 369.36 c -735.48 368.37 736.29 367.56 737.28 367.56 c -738.27 367.56 739.08 368.37 739.08 369.36 c -f -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -0.00 g -[] 0 d -737.280 390.960 m -737.280 385.200 l +805.680 124.560 m +805.680 131.760 l S 1 J 1 j @@ -18703,38 +18586,121 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -731.520 385.200 m -743.040 385.200 l +805.680 146.160 m +805.680 140.400 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +799.920 140.400 m +811.440 140.400 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -737.280 398.160 m -737.280 390.960 l +805.680 153.360 m +805.680 146.160 l S -0.80 0.00 0.00 rg -739.08 398.16 m 739.08 399.15 738.27 399.96 737.28 399.96 c -736.29 399.96 735.48 399.15 735.48 398.16 c -735.48 397.17 736.29 396.36 737.28 396.36 c -738.27 396.36 739.08 397.17 739.08 398.16 c -f 1 J 1 j 0.72 w 0.63 0.00 0.00 RG 0.00 g [] 0 d -737.280 382.320 m -737.280 376.560 l +805.680 137.520 m +805.680 131.760 l S 7.20 w BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 0.502 rg -738.72 386.47 Td +808.56 143.83 Td +(C34) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +807.84 132.31 Td +(330pF) Tj +ET +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +797.040 382.320 m +785.520 382.320 l +S +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +791.280 369.360 m +791.280 376.560 l +S +0.80 0.00 0.00 rg +793.08 369.36 m 793.08 370.35 792.27 371.16 791.28 371.16 c +790.29 371.16 789.48 370.35 789.48 369.36 c +789.48 368.37 790.29 367.56 791.28 367.56 c +792.27 367.56 793.08 368.37 793.08 369.36 c +f +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +791.280 390.960 m +791.280 385.200 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +785.520 385.200 m +797.040 385.200 l +S +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +791.280 398.160 m +791.280 390.960 l +S +0.80 0.00 0.00 rg +793.08 398.16 m 793.08 399.15 792.27 399.96 791.28 399.96 c +790.29 399.96 789.48 399.15 789.48 398.16 c +789.48 397.17 790.29 396.36 791.28 396.36 c +792.27 396.36 793.08 397.17 793.08 398.16 c +f +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +791.280 382.320 m +791.280 376.560 l +S +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 0.502 rg +792.72 386.47 Td (C27) Tj ET 7.20 w @@ -18742,7 +18708,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 1.000 rg -738.72 377.11 Td +792.72 377.11 Td (47uF) Tj ET 1 J @@ -18751,22 +18717,22 @@ ET 0.63 0.00 0.00 RG 0.00 g [] 0 d -728.640 382.320 m -717.120 382.320 l +782.640 382.320 m +771.120 382.320 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -722.880 369.360 m -722.880 376.560 l +776.880 369.360 m +776.880 376.560 l S 0.80 0.00 0.00 rg -724.68 369.36 m 724.68 370.35 723.87 371.16 722.88 371.16 c -721.89 371.16 721.08 370.35 721.08 369.36 c -721.08 368.37 721.89 367.56 722.88 367.56 c -723.87 367.56 724.68 368.37 724.68 369.36 c +778.68 369.36 m 778.68 370.35 777.87 371.16 776.88 371.16 c +775.89 371.16 775.08 370.35 775.08 369.36 c +775.08 368.37 775.89 367.56 776.88 367.56 c +777.87 367.56 778.68 368.37 778.68 369.36 c f 1 J 1 j @@ -18774,8 +18740,8 @@ f 0.63 0.00 0.00 RG 0.00 g [] 0 d -722.880 390.960 m -722.880 385.200 l +776.880 390.960 m +776.880 385.200 l S 1 J 1 j @@ -18783,22 +18749,22 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -717.120 385.200 m -728.640 385.200 l +771.120 385.200 m +782.640 385.200 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -722.880 398.160 m -722.880 390.960 l +776.880 398.160 m +776.880 390.960 l S 0.80 0.00 0.00 rg -724.68 398.16 m 724.68 399.15 723.87 399.96 722.88 399.96 c -721.89 399.96 721.08 399.15 721.08 398.16 c -721.08 397.17 721.89 396.36 722.88 396.36 c -723.87 396.36 724.68 397.17 724.68 398.16 c +778.68 398.16 m 778.68 399.15 777.87 399.96 776.88 399.96 c +775.89 399.96 775.08 399.15 775.08 398.16 c +775.08 397.17 775.89 396.36 776.88 396.36 c +777.87 396.36 778.68 397.17 778.68 398.16 c f 1 J 1 j @@ -18806,15 +18772,15 @@ f 0.63 0.00 0.00 RG 0.00 g [] 0 d -722.880 382.320 m -722.880 376.560 l +776.880 382.320 m +776.880 376.560 l S 7.20 w BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 0.502 rg -724.32 386.47 Td +778.32 386.47 Td (C26) Tj ET 7.20 w @@ -18822,7 +18788,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 1.000 rg -723.60 377.83 Td +777.60 377.83 Td (10uF) Tj ET 1 J @@ -18831,16 +18797,16 @@ ET 0.63 0.00 0.00 RG 0.00 g [] 0 d -699.840 382.320 m -688.320 382.320 l +753.840 382.320 m +742.320 382.320 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -694.080 369.360 m -694.080 376.560 l +748.080 369.360 m +748.080 376.560 l S 1 J 1 j @@ -18848,8 +18814,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -694.080 390.960 m -694.080 385.200 l +748.080 390.960 m +748.080 385.200 l S 1 J 1 j @@ -18857,16 +18823,16 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -688.320 385.200 m -699.840 385.200 l +742.320 385.200 m +753.840 385.200 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -694.080 398.160 m -694.080 390.960 l +748.080 398.160 m +748.080 390.960 l S 1 J 1 j @@ -18874,15 +18840,15 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -694.080 382.320 m -694.080 376.560 l +748.080 382.320 m +748.080 376.560 l S 7.20 w BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 0.502 rg -695.52 386.47 Td +749.52 386.47 Td (C24) Tj ET 7.20 w @@ -18890,7 +18856,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 1.000 rg -697.68 377.83 Td +751.68 377.83 Td (1nF) Tj ET 1 J @@ -19947,22 +19913,22 @@ ET 0.63 0.00 0.00 RG 0.00 g [] 0 d -708.480 382.320 m -708.480 376.560 l +762.480 382.320 m +762.480 376.560 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -708.480 398.160 m -708.480 390.960 l +762.480 398.160 m +762.480 390.960 l S 0.80 0.00 0.00 rg -710.28 398.16 m 710.28 399.15 709.47 399.96 708.48 399.96 c -707.49 399.96 706.68 399.15 706.68 398.16 c -706.68 397.17 707.49 396.36 708.48 396.36 c -709.47 396.36 710.28 397.17 710.28 398.16 c +764.28 398.16 m 764.28 399.15 763.47 399.96 762.48 399.96 c +761.49 399.96 760.68 399.15 760.68 398.16 c +760.68 397.17 761.49 396.36 762.48 396.36 c +763.47 396.36 764.28 397.17 764.28 398.16 c f 1 J 1 j @@ -19970,8 +19936,8 @@ f 0.63 0.00 0.00 RG 0.00 g [] 0 d -702.720 385.200 m -714.240 385.200 l +756.720 385.200 m +768.240 385.200 l S 1 J 1 j @@ -19979,22 +19945,22 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -708.480 390.960 m -708.480 385.200 l +762.480 390.960 m +762.480 385.200 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -708.480 369.360 m -708.480 376.560 l +762.480 369.360 m +762.480 376.560 l S 0.80 0.00 0.00 rg -710.28 369.36 m 710.28 370.35 709.47 371.16 708.48 371.16 c -707.49 371.16 706.68 370.35 706.68 369.36 c -706.68 368.37 707.49 367.56 708.48 367.56 c -709.47 367.56 710.28 368.37 710.28 369.36 c +764.28 369.36 m 764.28 370.35 763.47 371.16 762.48 371.16 c +761.49 371.16 760.68 370.35 760.68 369.36 c +760.68 368.37 761.49 367.56 762.48 367.56 c +763.47 367.56 764.28 368.37 764.28 369.36 c f 1 J 1 j @@ -20002,15 +19968,15 @@ f 0.63 0.00 0.00 RG 0.00 g [] 0 d -714.240 382.320 m -702.720 382.320 l +768.240 382.320 m +756.720 382.320 l S 7.20 w BT /F2 4.288235053554139 Tf 4.86 TL 0.000 0.000 0.502 rg -709.92 387.19 Td +763.92 387.19 Td (C25) Tj ET 7.20 w @@ -20018,7 +19984,7 @@ BT /F2 4.288235053554139 Tf 4.86 TL 0.000 0.000 1.000 rg -710.64 377.83 Td +764.64 377.83 Td (1uF) Tj ET 1 J @@ -20093,7 +20059,7 @@ ET BT 5.76 TL 0.000 g -1320.48 269.83 Td +1313.28 284.23 Td /F2 5.082352656064165 Tf (0) Tj /F3 5.082352656064165 Tf @@ -20255,6 +20221,258 @@ BT /F3 5.082352656064165 Tf <03a9> Tj ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +1414.080 506.160 m +1414.080 513.360 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +1421.280 513.360 m +1406.880 513.360 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +1414.800 519.840 m +1413.360 519.840 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +1416.960 517.680 m +1411.200 517.680 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +1419.120 515.520 m +1409.040 515.520 l +S +0.00 0.00 1.00 RG +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +1408.43 522.55 Td +(GND) Tj +ET +1 J +1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +1414.080 502.560 m +1414.080 506.160 l +S +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +1414.080 441.360 m +1414.080 434.160 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +1406.880 434.160 m +1421.280 434.160 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +1413.360 427.680 m +1414.800 427.680 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +1411.200 429.840 m +1416.960 429.840 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +1409.040 432.000 m +1419.120 432.000 l +S +0.00 0.00 1.00 RG +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +1408.43 421.75 Td +(GND) Tj +ET +1 J +1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +1414.080 452.160 m +1414.080 441.360 l +S +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +931.680 138.960 m +931.680 131.760 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +924.480 131.760 m +938.880 131.760 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +930.960 125.280 m +932.400 125.280 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +928.800 127.440 m +934.560 127.440 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +926.640 129.600 m +936.720 129.600 l +S +0.00 0.00 1.00 RG +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +926.03 119.35 Td +(GND) Tj +ET +1 J +1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +931.680 146.160 m +931.680 138.960 l +S +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +931.680 200.160 m +931.680 207.360 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +938.880 207.360 m +924.480 207.360 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +932.400 213.840 m +930.960 213.840 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +934.560 211.680 m +928.800 211.680 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +936.720 209.520 m +926.640 209.520 l +S +0.00 0.00 1.00 RG +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +926.03 216.55 Td +(GND) Tj +ET +1 J +1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +931.680 196.560 m +931.680 200.160 l +S Q endstream endobj @@ -20272,7 +20490,7 @@ endobj >> stream 0.14 w -0.53 0.00 0.00 RG +0.00 0.53 0.00 RG 1 J 1 j q @@ -23064,7 +23282,7 @@ endobj endobj 14 0 obj << -/Length 85871 +/Length 90030 >> stream 0.14 w @@ -23632,7 +23850,7 @@ BT 10.80 TL 0.000 g 705.21 114.00 Td -<0032003000320036002d00300037002d00310037> Tj +<0032003000320036002d00300037002d00320039> Tj ET 1 J 1 j @@ -24810,7 +25028,7 @@ Q BT 7.20 TL 0.000 g -485.64 476.11 Td +446.04 483.31 Td /F3 6.352940820080207 Tf <5fae52a85f00517363a553e3ff0c> Tj /F2 6.352940820080207 Tf @@ -24825,8 +25043,8 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -363.240 215.640 m -363.240 208.440 l +345.240 190.440 m +345.240 183.240 l S 1 J 1 j @@ -24834,8 +25052,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -370.440 208.440 m -356.040 208.440 l +352.440 183.240 m +338.040 183.240 l S 1 J 1 j @@ -24843,8 +25061,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -368.280 206.280 m -358.200 206.280 l +350.280 181.080 m +340.200 181.080 l S 1 J 1 j @@ -24852,8 +25070,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -366.120 204.120 m -360.360 204.120 l +348.120 178.920 m +342.360 178.920 l S 1 J 1 j @@ -24861,8 +25079,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -363.960 201.960 m -362.520 201.960 l +345.960 176.760 m +344.520 176.760 l S 0.00 0.00 1.00 RG 0.51 w @@ -24872,7 +25090,7 @@ BT /F2 6.352940820080207 Tf 7.20 TL 0.000 0.000 1.000 rg -356.18 193.87 Td +338.18 168.67 Td (GND) Tj ET 7.20 w @@ -24916,8 +25134,8 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -111.240 399.240 m -104.040 399.240 l +136.440 449.640 m +129.240 449.640 l S 1 J 1 j @@ -24925,8 +25143,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -104.040 392.040 m -104.040 406.440 l +129.240 442.440 m +129.240 456.840 l S 1 J 1 j @@ -24934,8 +25152,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -101.880 394.200 m -101.880 404.280 l +127.080 444.600 m +127.080 454.680 l S 1 J 1 j @@ -24943,8 +25161,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -99.720 396.360 m -99.720 402.120 l +124.920 446.760 m +124.920 452.520 l S 1 J 1 j @@ -24952,8 +25170,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -97.560 398.520 m -97.560 399.960 l +122.760 448.920 m +122.760 450.360 l S 0.00 0.00 1.00 RG 0.51 w @@ -24963,7 +25181,7 @@ BT /F2 6.352940820080207 Tf 7.20 TL 0.000 0.000 1.000 rg -0.00 1.00 -1.00 0.00 94.13 392.18 Tm +0.00 1.00 -1.00 0.00 119.33 442.58 Tm (GND) Tj ET 1 J @@ -25026,6 +25244,61 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d +111.240 399.240 m +104.040 399.240 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +104.040 392.040 m +104.040 406.440 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +101.880 394.200 m +101.880 404.280 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +99.720 396.360 m +99.720 402.120 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +97.560 398.520 m +97.560 399.960 l +S +0.00 0.00 1.00 RG +0.51 w +[] 0 d +7.20 w +BT +/F2 6.352940820080207 Tf +7.20 TL +0.000 0.000 1.000 rg +0.00 1.00 -1.00 0.00 94.13 392.18 Tm +(GND) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d 104.040 424.440 m 96.840 424.440 l S @@ -25081,61 +25354,6 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -136.440 449.640 m -129.240 449.640 l -S -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -0.00 g -[] 0 d -129.240 442.440 m -129.240 456.840 l -S -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -0.00 g -[] 0 d -127.080 444.600 m -127.080 454.680 l -S -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -0.00 g -[] 0 d -124.920 446.760 m -124.920 452.520 l -S -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -0.00 g -[] 0 d -122.760 448.920 m -122.760 450.360 l -S -0.00 0.00 1.00 RG -0.51 w -[] 0 d -7.20 w -BT -/F2 6.352940820080207 Tf -7.20 TL -0.000 0.000 1.000 rg -0.00 1.00 -1.00 0.00 119.33 442.58 Tm -(GND) Tj -ET -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d 230.040 381.240 m 230.040 374.040 l S @@ -25191,8 +25409,8 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -363.240 251.640 m -363.240 258.840 l +345.240 226.440 m +345.240 233.640 l S 1 J 1 j @@ -25200,8 +25418,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -359.640 258.840 m -366.840 258.840 l +341.640 233.640 m +348.840 233.640 l S 0.00 0.00 1.00 RG 0.51 w @@ -25211,7 +25429,7 @@ BT /F2 6.352940820080207 Tf 7.20 TL 0.000 0.000 1.000 rg -357.59 263.71 Td +339.59 238.51 Td (3V3) Tj ET 1 J @@ -25247,6 +25465,34 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d +489.240 338.040 m +489.240 345.240 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +485.640 345.240 m +492.840 345.240 l +S +0.00 0.00 1.00 RG +0.51 w +[] 0 d +7.20 w +BT +/F2 6.352940820080207 Tf +7.20 TL +0.000 0.000 1.000 rg +483.59 347.95 Td +(3V3) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d 644.040 258.840 m 636.840 258.840 l S @@ -25275,34 +25521,6 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -482.040 381.240 m -482.040 388.440 l -S -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -0.00 g -[] 0 d -478.440 388.440 m -485.640 388.440 l -S -0.00 0.00 1.00 RG -0.51 w -[] 0 d -7.20 w -BT -/F2 6.352940820080207 Tf -7.20 TL -0.000 0.000 1.000 rg -476.39 391.15 Td -(3V3) Tj -ET -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d 528.840 438.840 m 528.840 431.640 l S @@ -25358,8 +25576,8 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -528.840 370.440 m -528.840 363.240 l +536.040 327.240 m +536.040 320.040 l S 1 J 1 j @@ -25367,8 +25585,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -536.040 363.240 m -521.640 363.240 l +543.240 320.040 m +528.840 320.040 l S 1 J 1 j @@ -25376,8 +25594,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -533.880 361.080 m -523.800 361.080 l +541.080 317.880 m +531.000 317.880 l S 1 J 1 j @@ -25385,8 +25603,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -531.720 358.920 m -525.960 358.920 l +538.920 315.720 m +533.160 315.720 l S 1 J 1 j @@ -25394,8 +25612,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -529.560 356.760 m -528.120 356.760 l +536.760 313.560 m +535.320 313.560 l S 0.00 0.00 1.00 RG 0.51 w @@ -25405,7 +25623,7 @@ BT /F2 6.352940820080207 Tf 7.20 TL 0.000 0.000 1.000 rg -521.78 348.67 Td +528.98 305.47 Td (GND) Tj ET 1 J @@ -25591,16 +25809,16 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -510.840 377.640 m -505.800 377.640 l +518.040 334.440 m +513.000 334.440 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -489.240 377.640 m -494.280 377.640 l +496.440 334.440 m +501.480 334.440 l S 1 J 1 j @@ -25608,8 +25826,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -494.280 376.200 m -494.280 379.080 l +501.480 333.000 m +501.480 335.880 l S 1 J 1 j @@ -25617,8 +25835,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -505.800 376.200 m -505.800 379.080 l +513.000 333.000 m +513.000 335.880 l S 1 J 1 j @@ -25626,8 +25844,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -505.800 379.080 m -494.280 379.080 l +513.000 335.880 m +501.480 335.880 l S 1 J 1 j @@ -25635,14 +25853,14 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -505.800 376.200 m -494.280 376.200 l +513.000 333.000 m +501.480 333.000 l S 7.20 w BT 5.76 TL 0.000 0.000 1.000 rg -493.56 370.99 Td +500.76 327.79 Td /F2 5.082352656064165 Tf (10k) Tj /F3 5.082352656064165 Tf @@ -25653,7 +25871,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 0.502 rg -493.56 381.07 Td +500.76 337.87 Td (R23) Tj ET 0.00 0.00 1.00 RG @@ -25924,7 +26142,7 @@ BT /F2 6.352940820080207 Tf 7.20 TL 0.000 0.000 1.000 rg -518.04 378.91 Td +525.24 335.71 Td (SW2) Tj ET 1 J @@ -25932,8 +26150,8 @@ ET 0.00 0.53 0.00 RG [] 0 d 0.72 w -536.040 377.640 m -510.840 377.640 l +543.240 334.440 m +518.040 334.440 l S 0.00 0.00 1.00 RG 0.51 w @@ -25993,14 +26211,14 @@ S 0.00 0.53 0.00 RG [] 0 d 0.72 w -482.040 377.640 m -482.040 381.240 l +489.240 334.440 m +489.240 338.040 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -489.240 377.640 m -482.040 377.640 l +496.440 334.440 m +489.240 334.440 l S 2 J 0 j @@ -26008,6 +26226,202 @@ S 0.72 w 0.63 0.00 0.00 RG [] 0 d +406.44 226.44 36.00 -28.80 re +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +[] 0 d +439.92 201.24 m 439.92 201.84 439.44 202.32 438.84 202.32 c +438.24 202.32 437.76 201.84 437.76 201.24 c +437.76 200.64 438.24 200.16 438.84 200.16 c +439.44 200.16 439.92 200.64 439.92 201.24 c +S +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +449.640 204.840 m +442.440 204.840 l +S +0.00 G +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +429.05 201.85 Td +(OUT) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +442.80 205.45 Td +(1) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +449.640 212.040 m +442.440 212.040 l +S +0.00 G +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +428.48 209.05 Td +(GND) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +442.80 212.65 Td +(2) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +449.640 219.240 m +442.440 219.240 l +S +0.00 G +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +432.43 216.25 Td +(SO) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +442.80 219.85 Td +(3) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +399.240 219.240 m +406.440 219.240 l +S +0.00 G +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +409.10 216.25 Td +(SLH) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +403.25 219.85 Td +(4) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +399.240 212.040 m +406.440 212.040 l +S +0.00 G +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +409.10 209.05 Td +(VDD) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +403.25 212.65 Td +(5) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +399.240 204.840 m +406.440 204.840 l +S +0.00 G +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +409.10 201.85 Td +(STG) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 g +403.25 205.45 Td +(6) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 0.502 rg +406.44 227.71 Td +(U6) Tj +ET +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +406.44 191.71 Td +(TTP223-BA6-TD) Tj +ET +2 J +0 j +72 M +0.72 w +0.63 0.00 0.00 RG +[] 0 d 240.84 471.24 36.00 -93.60 re S 1 J @@ -26424,202 +26838,6 @@ BT 240.84 371.71 Td (TYPE-C 16PIN 2MD\(073\)) Tj ET -2 J -0 j -72 M -0.72 w -0.63 0.00 0.00 RG -[] 0 d -424.44 251.64 36.00 -28.80 re -S -1 J -1 j -0.72 w -0.63 0.00 0.00 RG -[] 0 d -457.92 226.44 m 457.92 227.04 457.44 227.52 456.84 227.52 c -456.24 227.52 455.76 227.04 455.76 226.44 c -455.76 225.84 456.24 225.36 456.84 225.36 c -457.44 225.36 457.92 225.84 457.92 226.44 c -S -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d -467.640 230.040 m -460.440 230.040 l -S -0.00 G -0.41 w -[] 0 d -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -447.05 227.05 Td -(OUT) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -460.80 230.65 Td -(1) Tj -ET -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d -467.640 237.240 m -460.440 237.240 l -S -0.00 G -0.41 w -[] 0 d -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -446.48 234.25 Td -(GND) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -460.80 237.85 Td -(2) Tj -ET -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d -467.640 244.440 m -460.440 244.440 l -S -0.00 G -0.41 w -[] 0 d -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -450.43 241.45 Td -(SO) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -460.80 245.05 Td -(3) Tj -ET -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d -417.240 244.440 m -424.440 244.440 l -S -0.00 G -0.41 w -[] 0 d -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -427.10 241.45 Td -(SLH) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -421.25 245.05 Td -(4) Tj -ET -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d -417.240 237.240 m -424.440 237.240 l -S -0.00 G -0.41 w -[] 0 d -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -427.10 234.25 Td -(VDD) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -421.25 237.85 Td -(5) Tj -ET -1 J -1 j -0.72 w -0.53 0.00 0.00 RG -[] 0 d -417.240 230.040 m -424.440 230.040 l -S -0.00 G -0.41 w -[] 0 d -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -427.10 227.05 Td -(STG) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 g -421.25 230.65 Td -(6) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 0.000 0.502 rg -424.44 252.91 Td -(U6) Tj -ET -7.20 w -BT -/F2 5.082352656064165 Tf -5.76 TL -0.000 0.000 1.000 rg -424.44 216.91 Td -(TTP223-BA6-TD) Tj -ET 1 J 1 j 0.00 0.53 0.00 RG @@ -26653,108 +26871,108 @@ S 0.00 0.53 0.00 RG [] 0 d 0.72 w -406.440 244.440 m -417.240 244.440 l +399.240 219.240 m +388.440 219.240 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -417.240 244.440 m -417.240 237.240 l +399.240 212.040 m +399.240 219.240 l S 0.80 0.00 0.00 rg -408.24 244.44 m 408.24 245.43 407.43 246.24 406.44 246.24 c -405.45 246.24 404.64 245.43 404.64 244.44 c -404.64 243.45 405.45 242.64 406.44 242.64 c -407.43 242.64 408.24 243.45 408.24 244.44 c +401.04 219.24 m 401.04 220.23 400.23 221.04 399.24 221.04 c +398.25 221.04 397.44 220.23 397.44 219.24 c +397.44 218.25 398.25 217.44 399.24 217.44 c +400.23 217.44 401.04 218.25 401.04 219.24 c f 0.80 0.00 0.00 rg -419.04 244.44 m 419.04 245.43 418.23 246.24 417.24 246.24 c -416.25 246.24 415.44 245.43 415.44 244.44 c -415.44 243.45 416.25 242.64 417.24 242.64 c -418.23 242.64 419.04 243.45 419.04 244.44 c +390.24 219.24 m 390.24 220.23 389.43 221.04 388.44 221.04 c +387.45 221.04 386.64 220.23 386.64 219.24 c +386.64 218.25 387.45 217.44 388.44 217.44 c +389.43 217.44 390.24 218.25 390.24 219.24 c f 1 J 1 j 0.00 0.53 0.00 RG [] 0 d 0.72 w -363.240 244.440 m -363.240 251.640 l +345.240 226.440 m +345.240 219.240 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -363.240 244.440 m -374.040 244.440 l +356.040 219.240 m +345.240 219.240 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -374.040 244.440 m -377.640 244.440 l +359.640 219.240 m +356.040 219.240 l S 1 J 1 j 0.00 0.53 0.00 RG [] 0 d 0.72 w -374.040 215.640 m -406.440 215.640 l +388.440 190.440 m +356.040 190.440 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -417.240 215.640 m -406.440 215.640 l +388.440 190.440 m +399.240 190.440 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -417.240 230.040 m -417.240 215.640 l +399.240 190.440 m +399.240 204.840 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -489.240 215.640 m -417.240 215.640 l +399.240 190.440 m +471.240 190.440 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -489.240 237.240 m -489.240 215.640 l +471.240 190.440 m +471.240 212.040 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -467.640 237.240 m -489.240 237.240 l +471.240 212.040 m +449.640 212.040 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -363.240 215.640 m -374.040 215.640 l +356.040 190.440 m +345.240 190.440 l S 0.80 0.00 0.00 rg -375.84 215.64 m 375.84 216.63 375.03 217.44 374.04 217.44 c -373.05 217.44 372.24 216.63 372.24 215.64 c -372.24 214.65 373.05 213.84 374.04 213.84 c -375.03 213.84 375.84 214.65 375.84 215.64 c +390.24 190.44 m 390.24 191.43 389.43 192.24 388.44 192.24 c +387.45 192.24 386.64 191.43 386.64 190.44 c +386.64 189.45 387.45 188.64 388.44 188.64 c +389.43 188.64 390.24 189.45 390.24 190.44 c f 0.80 0.00 0.00 rg -408.24 215.64 m 408.24 216.63 407.43 217.44 406.44 217.44 c -405.45 217.44 404.64 216.63 404.64 215.64 c -404.64 214.65 405.45 213.84 406.44 213.84 c -407.43 213.84 408.24 214.65 408.24 215.64 c +357.84 190.44 m 357.84 191.43 357.03 192.24 356.04 192.24 c +355.05 192.24 354.24 191.43 354.24 190.44 c +354.24 189.45 355.05 188.64 356.04 188.64 c +357.03 188.64 357.84 189.45 357.84 190.44 c f 0.80 0.00 0.00 rg -419.04 215.64 m 419.04 216.63 418.23 217.44 417.24 217.44 c -416.25 217.44 415.44 216.63 415.44 215.64 c -415.44 214.65 416.25 213.84 417.24 213.84 c -418.23 213.84 419.04 214.65 419.04 215.64 c +401.04 190.44 m 401.04 191.43 400.23 192.24 399.24 192.24 c +398.25 192.24 397.44 191.43 397.44 190.44 c +397.44 189.45 398.25 188.64 399.24 188.64 c +400.23 188.64 401.04 189.45 401.04 190.44 c f 0.00 0.00 1.00 RG 0.41 w @@ -26764,7 +26982,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 1.000 rg -467.64 231.31 Td +449.64 206.11 Td (touch) Tj ET 1 J @@ -26772,24 +26990,24 @@ ET 0.00 0.53 0.00 RG [] 0 d 0.72 w -467.640 230.040 m -467.640 230.040 l +449.640 204.840 m +449.640 204.840 l S 1 J 1 j 0.00 0.53 0.00 RG [] 0 d 0.72 w -474.840 244.440 m -467.640 244.440 l +449.640 219.240 m +456.840 219.240 l S 1 J 1 j 0.00 0.53 0.00 RG [] 0 d 0.72 w -525.240 244.440 m -503.640 244.440 l +485.640 219.240 m +507.240 219.240 l S 2 J 0 j @@ -26797,37 +27015,37 @@ S 0.72 w 0.63 0.00 0.00 RG [] 0 d -482.04 248.04 14.40 -7.20 re +464.04 222.84 14.40 -7.20 re S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -503.640 244.440 m -496.440 244.440 l +485.640 219.240 m +478.440 219.240 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -474.840 244.440 m -482.040 244.440 l +456.840 219.240 m +464.040 219.240 l S 7.20 w BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 0.502 rg -482.04 249.31 Td +464.04 224.11 Td (R16) Tj ET 7.20 w BT 5.76 TL 0.000 0.000 1.000 rg -492.84 249.31 Td +474.84 224.11 Td /F2 5.082352656064165 Tf (1k) Tj /F3 5.082352656064165 Tf @@ -26839,37 +27057,37 @@ ET 0.72 w 0.63 0.00 0.00 RG [] 0 d -384.84 248.04 14.40 -7.20 re +366.84 222.84 14.40 -7.20 re S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -406.440 244.440 m -399.240 244.440 l +388.440 219.240 m +381.240 219.240 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -377.640 244.440 m -384.840 244.440 l +359.640 219.240 m +366.840 219.240 l S 7.20 w BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 0.502 rg -381.24 249.31 Td +363.24 224.11 Td (R11) Tj ET 7.20 w BT 5.76 TL 0.000 0.000 1.000 rg -392.04 249.31 Td +374.04 224.11 Td /F2 5.082352656064165 Tf (10) Tj /F3 5.082352656064165 Tf @@ -26881,22 +27099,22 @@ ET 0.63 0.00 0.00 RG 0.00 g [] 0 d -379.800 228.600 m -368.280 228.600 l +361.800 203.400 m +350.280 203.400 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -374.040 215.640 m -374.040 222.840 l +356.040 190.440 m +356.040 197.640 l S 0.80 0.00 0.00 rg -375.84 215.64 m 375.84 216.63 375.03 217.44 374.04 217.44 c -373.05 217.44 372.24 216.63 372.24 215.64 c -372.24 214.65 373.05 213.84 374.04 213.84 c -375.03 213.84 375.84 214.65 375.84 215.64 c +357.84 190.44 m 357.84 191.43 357.03 192.24 356.04 192.24 c +355.05 192.24 354.24 191.43 354.24 190.44 c +354.24 189.45 355.05 188.64 356.04 188.64 c +357.03 188.64 357.84 189.45 357.84 190.44 c f 1 J 1 j @@ -26904,8 +27122,8 @@ f 0.63 0.00 0.00 RG 0.00 g [] 0 d -374.040 237.240 m -374.040 231.480 l +356.040 212.040 m +356.040 206.280 l S 1 J 1 j @@ -26913,22 +27131,22 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -368.280 231.480 m -379.800 231.480 l +350.280 206.280 m +361.800 206.280 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -374.040 244.440 m -374.040 237.240 l +356.040 219.240 m +356.040 212.040 l S 0.80 0.00 0.00 rg -375.84 244.44 m 375.84 245.43 375.03 246.24 374.04 246.24 c -373.05 246.24 372.24 245.43 372.24 244.44 c -372.24 243.45 373.05 242.64 374.04 242.64 c -375.03 242.64 375.84 243.45 375.84 244.44 c +357.84 219.24 m 357.84 220.23 357.03 221.04 356.04 221.04 c +355.05 221.04 354.24 220.23 354.24 219.24 c +354.24 218.25 355.05 217.44 356.04 217.44 c +357.03 217.44 357.84 218.25 357.84 219.24 c f 1 J 1 j @@ -26936,15 +27154,15 @@ f 0.63 0.00 0.00 RG 0.00 g [] 0 d -374.040 228.600 m -374.040 222.840 l +356.040 203.400 m +356.040 197.640 l S 7.20 w BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 0.502 rg -362.52 233.47 Td +344.52 208.27 Td (C40) Tj ET 7.20 w @@ -26952,7 +27170,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 1.000 rg -359.64 216.91 Td +341.64 191.71 Td (10uF) Tj ET 2 J @@ -26961,25 +27179,25 @@ ET 0.72 w 0.63 0.00 0.00 RG [] 0 d -532.44 258.84 14.40 -36.00 re +514.44 233.64 14.40 -36.00 re S 1 J 1 j 0.72 w 0.63 0.00 0.00 RG [] 0 d -537.12 255.24 m 537.12 255.84 536.64 256.32 536.04 256.32 c -535.44 256.32 534.96 255.84 534.96 255.24 c -534.96 254.64 535.44 254.16 536.04 254.16 c -536.64 254.16 537.12 254.64 537.12 255.24 c +519.12 230.04 m 519.12 230.64 518.64 231.12 518.04 231.12 c +517.44 231.12 516.96 230.64 516.96 230.04 c +516.96 229.44 517.44 228.96 518.04 228.96 c +518.64 228.96 519.12 229.44 519.12 230.04 c S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -525.240 244.440 m -532.440 244.440 l +507.240 219.240 m +514.440 219.240 l S 0.00 G 0.41 w @@ -26989,7 +27207,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -535.10 241.45 Td +517.10 216.25 Td (1) Tj ET 7.20 w @@ -26997,7 +27215,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -529.25 245.05 Td +511.25 219.85 Td (1) Tj ET 1 J @@ -27005,8 +27223,8 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -525.240 237.240 m -532.440 237.240 l +507.240 212.040 m +514.440 212.040 l S 0.00 G 0.41 w @@ -27016,7 +27234,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -535.10 234.25 Td +517.10 209.05 Td (2) Tj ET 7.20 w @@ -27024,7 +27242,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -529.25 237.85 Td +511.25 212.65 Td (2) Tj ET 1 J @@ -27032,8 +27250,8 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -539.640 266.040 m -539.640 258.840 l +521.640 240.840 m +521.640 233.640 l S 0.00 G 0.41 w @@ -27043,7 +27261,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -0.00 1.00 -1.00 0.00 541.25 251.97 Tm +0.00 1.00 -1.00 0.00 523.25 226.77 Tm (3) Tj ET 7.20 w @@ -27051,26 +27269,16 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -0.00 1.00 -1.00 0.00 537.65 257.82 Tm +0.00 1.00 -1.00 0.00 519.65 232.62 Tm (3) Tj ET 1 J 1 j 0.72 w -0.20 0.80 0.20 RG -[] 0 d -536.760 268.920 m -542.520 263.160 l -536.760 263.160 m -542.520 268.920 l -S -1 J -1 j -0.72 w 0.53 0.00 0.00 RG [] 0 d -539.640 215.640 m -539.640 222.840 l +521.640 190.440 m +521.640 197.640 l S 0.00 G 0.41 w @@ -27080,7 +27288,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -0.00 1.00 -1.00 0.00 541.25 224.13 Tm +0.00 1.00 -1.00 0.00 523.25 198.93 Tm (4) Tj ET 7.20 w @@ -27088,25 +27296,15 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -0.00 1.00 -1.00 0.00 537.65 218.28 Tm +0.00 1.00 -1.00 0.00 519.65 193.08 Tm (4) Tj ET -1 J -1 j -0.72 w -0.20 0.80 0.20 RG -[] 0 d -536.760 218.520 m -542.520 212.760 l -536.760 212.760 m -542.520 218.520 l -S 7.20 w BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 0.502 rg -550.44 253.63 Td +532.44 228.43 Td (J2) Tj ET 7.20 w @@ -27114,7 +27312,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 1.000 rg -550.44 249.31 Td +532.44 224.11 Td (HC-1.25-2PLT) Tj ET 2 J @@ -27219,16 +27417,6 @@ ET 1 J 1 j 0.72 w -0.20 0.80 0.20 RG -[] 0 d -547.560 470.520 m -553.320 464.760 l -547.560 464.760 m -553.320 470.520 l -S -1 J -1 j -0.72 w 0.53 0.00 0.00 RG [] 0 d 550.440 417.240 m @@ -27253,16 +27441,6 @@ BT 0.00 1.00 -1.00 0.00 548.45 419.88 Tm (4) Tj ET -1 J -1 j -0.72 w -0.20 0.80 0.20 RG -[] 0 d -547.560 420.120 m -553.320 414.360 l -547.560 414.360 m -553.320 420.120 l -S 7.20 w BT /F2 5.082352656064165 Tf @@ -27285,25 +27463,25 @@ ET 0.72 w 0.63 0.00 0.00 RG [] 0 d -543.24 392.04 14.40 -36.00 re +550.44 348.84 14.40 -36.00 re S 1 J 1 j 0.72 w 0.63 0.00 0.00 RG [] 0 d -547.92 388.44 m 547.92 389.04 547.44 389.52 546.84 389.52 c -546.24 389.52 545.76 389.04 545.76 388.44 c -545.76 387.84 546.24 387.36 546.84 387.36 c -547.44 387.36 547.92 387.84 547.92 388.44 c +555.12 345.24 m 555.12 345.84 554.64 346.32 554.04 346.32 c +553.44 346.32 552.96 345.84 552.96 345.24 c +552.96 344.64 553.44 344.16 554.04 344.16 c +554.64 344.16 555.12 344.64 555.12 345.24 c S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -536.040 377.640 m -543.240 377.640 l +543.240 334.440 m +550.440 334.440 l S 0.00 G 0.41 w @@ -27313,7 +27491,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -545.90 374.65 Td +553.10 331.45 Td (1) Tj ET 7.20 w @@ -27321,7 +27499,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -540.05 378.25 Td +547.25 335.05 Td (1) Tj ET 1 J @@ -27329,8 +27507,8 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -536.040 370.440 m -543.240 370.440 l +543.240 327.240 m +550.440 327.240 l S 0.00 G 0.41 w @@ -27340,7 +27518,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -545.90 367.45 Td +553.10 324.25 Td (2) Tj ET 7.20 w @@ -27348,7 +27526,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -540.05 371.05 Td +547.25 327.85 Td (2) Tj ET 1 J @@ -27356,8 +27534,8 @@ ET 0.72 w 0.53 0.00 0.00 RG [] 0 d -550.440 399.240 m -550.440 392.040 l +557.640 356.040 m +557.640 348.840 l S 0.00 G 0.41 w @@ -27367,7 +27545,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -0.00 1.00 -1.00 0.00 552.05 385.17 Tm +0.00 1.00 -1.00 0.00 559.25 341.97 Tm (3) Tj ET 7.20 w @@ -27375,26 +27553,16 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -0.00 1.00 -1.00 0.00 548.45 391.02 Tm +0.00 1.00 -1.00 0.00 555.65 347.82 Tm (3) Tj ET 1 J 1 j 0.72 w -0.20 0.80 0.20 RG -[] 0 d -547.560 402.120 m -553.320 396.360 l -547.560 396.360 m -553.320 402.120 l -S -1 J -1 j -0.72 w 0.53 0.00 0.00 RG [] 0 d -550.440 348.840 m -550.440 356.040 l +557.640 305.640 m +557.640 312.840 l S 0.00 G 0.41 w @@ -27404,7 +27572,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -0.00 1.00 -1.00 0.00 552.05 357.33 Tm +0.00 1.00 -1.00 0.00 559.25 314.13 Tm (4) Tj ET 7.20 w @@ -27412,25 +27580,15 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 g -0.00 1.00 -1.00 0.00 548.45 351.48 Tm +0.00 1.00 -1.00 0.00 555.65 308.28 Tm (4) Tj ET -1 J -1 j -0.72 w -0.20 0.80 0.20 RG -[] 0 d -547.560 351.720 m -553.320 345.960 l -547.560 345.960 m -553.320 351.720 l -S 7.20 w BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 0.502 rg -561.24 386.83 Td +568.44 343.63 Td (J8) Tj ET 7.20 w @@ -27438,7 +27596,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 1.000 rg -561.24 382.51 Td +568.44 339.31 Td (HC-1.25-2PLT) Tj ET 1 J @@ -27446,8 +27604,8 @@ ET 0.00 0.53 0.00 RG [] 0 d 0.72 w -528.840 370.440 m -536.040 370.440 l +536.040 327.240 m +543.240 327.240 l S 1 J 1 j @@ -27782,22 +27940,22 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -412.200 228.600 m -400.680 228.600 l +394.200 203.400 m +382.680 203.400 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -406.440 215.640 m -406.440 222.840 l +388.440 190.440 m +388.440 197.640 l S 0.80 0.00 0.00 rg -408.24 215.64 m 408.24 216.63 407.43 217.44 406.44 217.44 c -405.45 217.44 404.64 216.63 404.64 215.64 c -404.64 214.65 405.45 213.84 406.44 213.84 c -407.43 213.84 408.24 214.65 408.24 215.64 c +390.24 190.44 m 390.24 191.43 389.43 192.24 388.44 192.24 c +387.45 192.24 386.64 191.43 386.64 190.44 c +386.64 189.45 387.45 188.64 388.44 188.64 c +389.43 188.64 390.24 189.45 390.24 190.44 c f 1 J 1 j @@ -27805,8 +27963,8 @@ f 0.63 0.00 0.00 RG 0.00 g [] 0 d -406.440 237.240 m -406.440 231.480 l +388.440 212.040 m +388.440 206.280 l S 1 J 1 j @@ -27814,22 +27972,22 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -400.680 231.480 m -412.200 231.480 l +382.680 206.280 m +394.200 206.280 l S 1 J 1 j 0.72 w 0.53 0.00 0.00 RG [] 0 d -406.440 244.440 m -406.440 237.240 l +388.440 219.240 m +388.440 212.040 l S 0.80 0.00 0.00 rg -408.24 244.44 m 408.24 245.43 407.43 246.24 406.44 246.24 c -405.45 246.24 404.64 245.43 404.64 244.44 c -404.64 243.45 405.45 242.64 406.44 242.64 c -407.43 242.64 408.24 243.45 408.24 244.44 c +390.24 219.24 m 390.24 220.23 389.43 221.04 388.44 221.04 c +387.45 221.04 386.64 220.23 386.64 219.24 c +386.64 218.25 387.45 217.44 388.44 217.44 c +389.43 217.44 390.24 218.25 390.24 219.24 c f 1 J 1 j @@ -27837,15 +27995,15 @@ f 0.63 0.00 0.00 RG 0.00 g [] 0 d -406.440 228.600 m -406.440 222.840 l +388.440 203.400 m +388.440 197.640 l S 7.20 w BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 0.502 rg -410.04 234.19 Td +392.04 208.99 Td (C31) Tj ET 7.20 w @@ -27853,7 +28011,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 1.000 rg -410.04 224.11 Td +392.04 198.91 Td (100nF) Tj ET 2 J @@ -28126,16 +28284,6 @@ S 284.040 384.840 m 284.040 384.840 l S -7.20 w -BT -5.76 TL -0.000 g -147.24 339.31 Td -/F2 5.082352656064165 Tf -(USB) Tj -/F3 5.082352656064165 Tf -<63a553e365e053d85316> Tj -ET 1 J 1 j 0.72 w @@ -28523,6 +28671,16 @@ BT ET 7.20 w BT +5.76 TL +0.000 g +147.24 339.31 Td +/F2 5.082352656064165 Tf +(USB) Tj +/F3 5.082352656064165 Tf +<63a553e365e053d85316> Tj +ET +7.20 w +BT 10.80 TL 0.000 g 179.64 141.31 Td @@ -28940,14 +29098,14 @@ S 0.00 0.53 0.00 RG [] 0 d 0.72 w -503.640 237.240 m -503.640 226.440 l +485.640 201.240 m +485.640 212.040 l S 0.00 0.53 0.00 RG [] 0 d 0.72 w -525.240 237.240 m -503.640 237.240 l +485.640 212.040 m +507.240 212.040 l S 1 J 1 j @@ -28998,8 +29156,8 @@ S 0.72 w 0.53 0.00 0.00 RG [] 0 d -503.640 226.440 m -503.640 219.240 l +485.640 201.240 m +485.640 194.040 l S 1 J 1 j @@ -29007,8 +29165,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -496.440 219.240 m -510.840 219.240 l +478.440 194.040 m +492.840 194.040 l S 1 J 1 j @@ -29016,8 +29174,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -502.920 212.760 m -504.360 212.760 l +484.920 187.560 m +486.360 187.560 l S 1 J 1 j @@ -29025,8 +29183,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -500.760 214.920 m -506.520 214.920 l +482.760 189.720 m +488.520 189.720 l S 1 J 1 j @@ -29034,8 +29192,8 @@ S 0.63 0.00 0.00 RG 0.00 g [] 0 d -498.600 217.080 m -508.680 217.080 l +480.600 191.880 m +490.680 191.880 l S 0.00 0.00 1.00 RG 0.41 w @@ -29520,16 +29678,6 @@ ET 1 J 1 j 0.72 w -0.20 0.80 0.20 RG -[] 0 d -695.160 243.720 m -700.920 237.960 l -695.160 237.960 m -700.920 243.720 l -S -1 J -1 j -0.72 w 0.53 0.00 0.00 RG [] 0 d 698.040 248.040 m @@ -29554,16 +29702,6 @@ BT 691.20 248.65 Td (5) Tj ET -1 J -1 j -0.72 w -0.20 0.80 0.20 RG -[] 0 d -695.160 250.920 m -700.920 245.160 l -695.160 245.160 m -700.920 250.920 l -S 7.20 w BT /F2 5.082352656064165 Tf @@ -29577,7 +29715,7 @@ BT /F2 5.082352656064165 Tf 5.76 TL 0.000 0.000 1.000 rg -676.44 224.11 Td +665.64 222.67 Td (HC-1.25-3PLT) Tj ET 1 J @@ -29834,6 +29972,516 @@ BT 150.84 450.91 Td (H5VL10B) Tj ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +557.640 305.640 m +557.640 298.440 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +550.440 298.440 m +564.840 298.440 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +556.920 291.960 m +558.360 291.960 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +554.760 294.120 m +560.520 294.120 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +552.600 296.280 m +562.680 296.280 l +S +0.00 0.00 1.00 RG +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +551.99 286.03 Td +(GND) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +557.640 356.040 m +557.640 363.240 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +564.840 363.240 m +550.440 363.240 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +558.360 369.720 m +556.920 369.720 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +560.520 367.560 m +554.760 367.560 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +562.680 365.400 m +552.600 365.400 l +S +0.00 0.00 1.00 RG +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +551.99 372.43 Td +(GND) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +550.440 417.240 m +550.440 410.040 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +543.240 410.040 m +557.640 410.040 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +549.720 403.560 m +551.160 403.560 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +547.560 405.720 m +553.320 405.720 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +545.400 407.880 m +555.480 407.880 l +S +0.00 0.00 1.00 RG +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +544.79 397.63 Td +(GND) Tj +ET +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +550.440 467.640 m +550.440 474.840 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +557.640 474.840 m +543.240 474.840 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +551.160 481.320 m +549.720 481.320 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +553.320 479.160 m +547.560 479.160 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +555.480 477.000 m +545.400 477.000 l +S +0.00 0.00 1.00 RG +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +544.79 484.03 Td +(GND) Tj +ET +1 J +1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +557.640 305.640 m +557.640 305.640 l +S +1 J +1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +557.640 356.040 m +557.640 356.040 l +S +1 J +1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +550.440 417.240 m +550.440 417.240 l +S +1 J +1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +550.440 467.640 m +550.440 467.640 l +S +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +521.640 240.840 m +521.640 248.040 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +528.840 248.040 m +514.440 248.040 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +522.360 254.520 m +520.920 254.520 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +524.520 252.360 m +518.760 252.360 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +526.680 250.200 m +516.600 250.200 l +S +0.00 0.00 1.00 RG +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +515.99 257.23 Td +(GND) Tj +ET +1 J +1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +521.640 240.840 m +521.640 240.840 l +S +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +521.640 190.440 m +521.640 183.240 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +514.440 183.240 m +528.840 183.240 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +520.920 176.760 m +522.360 176.760 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +518.760 178.920 m +524.520 178.920 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +516.600 181.080 m +526.680 181.080 l +S +0.00 0.00 1.00 RG +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +515.99 170.83 Td +(GND) Tj +ET +1 J +1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +521.640 190.440 m +521.640 190.440 l +S +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +705.240 248.040 m +705.240 255.240 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +712.440 255.240 m +698.040 255.240 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +705.960 261.720 m +704.520 261.720 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +708.120 259.560 m +702.360 259.560 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +710.280 257.400 m +700.200 257.400 l +S +0.00 0.00 1.00 RG +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +699.59 264.43 Td +(GND) Tj +ET +1 J +1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +698.040 248.040 m +705.240 248.040 l +S +1 J +1 j +0.72 w +0.53 0.00 0.00 RG +[] 0 d +705.240 233.640 m +705.240 226.440 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +698.040 226.440 m +712.440 226.440 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +704.520 219.960 m +705.960 219.960 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +702.360 222.120 m +708.120 222.120 l +S +1 J +1 j +0.72 w +0.63 0.00 0.00 RG +0.00 g +[] 0 d +700.200 224.280 m +710.280 224.280 l +S +0.00 0.00 1.00 RG +0.41 w +[] 0 d +7.20 w +BT +/F2 5.082352656064165 Tf +5.76 TL +0.000 0.000 1.000 rg +699.59 214.03 Td +(GND) Tj +ET +1 J +1 j +0.00 0.53 0.00 RG +[] 0 d +0.72 w +705.240 240.840 m +705.240 233.640 l +S +0.00 0.53 0.00 RG +[] 0 d +0.72 w +698.040 240.840 m +705.240 240.840 l +S Q endstream endobj @@ -31187,390 +31835,366 @@ endobj >> >> endobj -305 0 obj -<> endobj -306 0 obj -<> endobj -307 0 obj -<> endobj -308 0 obj -<> endobj -309 0 obj -<> endobj -310 0 obj -<> endobj -311 0 obj -<> endobj -312 0 obj -<> endobj -313 0 obj -<> endobj -314 0 obj -<> endobj -315 0 obj -<> endobj -316 0 obj -<> endobj 317 0 obj -<> endobj +<> endobj 318 0 obj -<> endobj +<> endobj 319 0 obj -<> endobj +<> endobj 320 0 obj -<> endobj +<> endobj 321 0 obj -<> endobj +<> endobj 322 0 obj -<> endobj +<> endobj 323 0 obj -<> endobj +<> endobj 324 0 obj -<> endobj +<> endobj 325 0 obj -<> endobj +<> endobj 326 0 obj -<> endobj +<> endobj 327 0 obj -<> endobj +<> endobj 328 0 obj -<> endobj +<> endobj 329 0 obj <> endobj 330 0 obj -<> endobj +<> endobj 331 0 obj <> endobj 332 0 obj -<> endobj +<> endobj 333 0 obj -<> endobj +<> endobj 334 0 obj -<> endobj +<> endobj 335 0 obj -<> endobj +<> endobj 336 0 obj -<> endobj +<> endobj 337 0 obj -<> endobj +<> endobj 338 0 obj -<> endobj +<> endobj 339 0 obj -<> endobj +<> endobj 340 0 obj -<> endobj +<> endobj 341 0 obj -<> endobj +<> endobj 342 0 obj -<> endobj +<> endobj 343 0 obj -<> endobj +<> endobj 344 0 obj -<> endobj +<> endobj 345 0 obj -<> endobj +<> endobj 346 0 obj -<> endobj +<> endobj 347 0 obj -<> endobj +<> endobj 348 0 obj -<> endobj +<> endobj 349 0 obj -<> endobj +<> endobj 350 0 obj -<> endobj +<> endobj 351 0 obj -<> endobj +<> endobj 352 0 obj -<> endobj +<> endobj 353 0 obj -<> endobj +<> endobj 354 0 obj -<> endobj +<> endobj 355 0 obj -<> endobj +<> endobj 356 0 obj -<> endobj +<> endobj 357 0 obj -<> endobj +<> endobj 358 0 obj -<> endobj +<> endobj 359 0 obj -<> endobj +<> endobj 360 0 obj -<> endobj +<> endobj 361 0 obj -<> endobj +<> endobj 362 0 obj -<> endobj +<> endobj 363 0 obj -<> endobj +<> endobj 364 0 obj -<> endobj +<> endobj 365 0 obj <> endobj 366 0 obj -<> endobj +<> endobj 367 0 obj <> endobj 368 0 obj -<> endobj +<> endobj 369 0 obj <> endobj 370 0 obj -<> endobj +<> endobj 371 0 obj -<> endobj +<> endobj 372 0 obj -<> endobj +<> endobj 373 0 obj -<> endobj +<> endobj 374 0 obj -<> endobj +<> endobj 375 0 obj -<> endobj +<> endobj 376 0 obj -<> endobj +<> endobj 377 0 obj -<> endobj +<> endobj 378 0 obj -<> endobj +<> endobj 379 0 obj -<> endobj +<> endobj 380 0 obj -<> endobj +<> endobj 381 0 obj -<> endobj +<> endobj 382 0 obj -<> endobj +<> endobj 383 0 obj -<> endobj +<> endobj 384 0 obj -<> endobj +<> endobj 385 0 obj -<> endobj +<> endobj 386 0 obj -<> endobj +<> endobj 387 0 obj -<> endobj +<> endobj 388 0 obj -<> endobj +<> endobj 389 0 obj -<> endobj +<> endobj 390 0 obj -<> endobj +<> endobj 391 0 obj -<> endobj +<> endobj 392 0 obj -<> endobj +<> endobj 393 0 obj -<> endobj +<> endobj 394 0 obj -<> endobj +<> endobj 395 0 obj -<> endobj +<> endobj 396 0 obj -<> endobj +<> endobj 397 0 obj -<> endobj +<> endobj 398 0 obj -<> endobj +<> endobj 399 0 obj -<> endobj +<> endobj 400 0 obj -<> endobj +<> endobj 401 0 obj -<> endobj +<> endobj 402 0 obj -<> endobj +<> endobj 403 0 obj -<> endobj +<> endobj 404 0 obj -<> endobj +<> endobj 405 0 obj -<> endobj +<> endobj 406 0 obj -<> endobj +<> endobj 407 0 obj -<> endobj +<> endobj 408 0 obj -<> endobj +<> endobj 409 0 obj -<> endobj +<> endobj 410 0 obj -<> endobj +<> endobj 411 0 obj -<> endobj +<> endobj 412 0 obj -<> endobj +<> endobj 413 0 obj -<> endobj +<> endobj 414 0 obj -<> endobj +<> endobj 415 0 obj -<> endobj +<> endobj 416 0 obj -<> endobj +<> endobj 417 0 obj <> endobj 418 0 obj -<> endobj +<> endobj 419 0 obj -<> endobj +<> endobj 420 0 obj -<> endobj +<> endobj 421 0 obj -<> endobj +<> endobj 422 0 obj -<> endobj +<> endobj 423 0 obj -<> endobj +<> endobj 424 0 obj -<> endobj +<> endobj 425 0 obj -<> endobj +<> endobj 426 0 obj -<> endobj +<> endobj 427 0 obj -<> endobj +<> endobj 428 0 obj -<> endobj +<> endobj 429 0 obj <> endobj 430 0 obj -<> endobj +<> endobj 431 0 obj -<> endobj +<> endobj 432 0 obj -<> endobj +<> endobj 433 0 obj <> endobj 434 0 obj -<> endobj +<> endobj 435 0 obj -<> endobj +<> endobj 436 0 obj -<> endobj +<> endobj 437 0 obj <> endobj 438 0 obj -<> endobj +<> endobj 439 0 obj <> endobj 440 0 obj -<> endobj +<> endobj 441 0 obj -<> endobj +<> endobj 442 0 obj -<> endobj +<> endobj 443 0 obj <> endobj 444 0 obj -<> endobj +<> endobj 445 0 obj <> endobj 446 0 obj -<> endobj +<> endobj 447 0 obj -<> endobj +<> endobj 448 0 obj -<> endobj +<> endobj 449 0 obj <> endobj 450 0 obj -<> endobj +<> endobj 451 0 obj -<> endobj +<> endobj 452 0 obj -<> endobj +<> endobj 453 0 obj -<> endobj +<> endobj 454 0 obj -<> endobj +<> endobj 455 0 obj -<> endobj +<> endobj 456 0 obj -<> endobj +<> endobj 457 0 obj -<> endobj +<> endobj 458 0 obj -<> endobj +<> endobj 459 0 obj -<> endobj +<> endobj 460 0 obj -<> endobj +<> endobj 461 0 obj -<> endobj +<> endobj 462 0 obj -<> endobj +<> endobj 463 0 obj -<> endobj +<> endobj 464 0 obj -<> endobj +<> endobj 465 0 obj -<> endobj +<> endobj 466 0 obj -<> endobj +<> endobj 467 0 obj -<> endobj +<> endobj 468 0 obj -<> endobj +<> endobj 469 0 obj -<> endobj +<> endobj 470 0 obj -<> endobj +<> endobj 471 0 obj -<> endobj +<> endobj 472 0 obj -<> endobj +<> endobj 473 0 obj -<> endobj +<> endobj 474 0 obj -<> endobj +<> endobj 475 0 obj -<> endobj +<> endobj 476 0 obj -<> endobj +<> endobj 477 0 obj <> endobj 478 0 obj -<> endobj +<> endobj 479 0 obj -<> endobj +<> endobj 480 0 obj -<> endobj +<> endobj 481 0 obj -<> endobj +<> endobj 482 0 obj -<> endobj +<> endobj 483 0 obj -<> endobj +<> endobj 484 0 obj -<> endobj +<> endobj 485 0 obj <> endobj 486 0 obj -<> endobj +<> endobj 487 0 obj <> endobj 488 0 obj -<> endobj +<> endobj 489 0 obj -<> endobj +<> endobj 490 0 obj -<> endobj +<> endobj 491 0 obj -<> endobj +<> endobj 492 0 obj -<> endobj +<> endobj 493 0 obj -<> endobj +<> endobj 494 0 obj -<> endobj +<> endobj 495 0 obj -<> endobj +<> endobj 496 0 obj -<> endobj +<> endobj 497 0 obj <> endobj 498 0 obj @@ -31578,414 +32202,486 @@ endobj 499 0 obj <> endobj 500 0 obj -<> endobj +<> endobj 501 0 obj -<> endobj +<> endobj 502 0 obj -<> endobj +<> endobj 503 0 obj -<> endobj +<> endobj 504 0 obj -<> endobj +<> endobj 505 0 obj -<> endobj +<> endobj 506 0 obj -<> endobj +<> endobj 507 0 obj -<> endobj +<> endobj 508 0 obj -<> endobj +<> endobj 509 0 obj -<> endobj +<> endobj 510 0 obj -<> endobj +<> endobj 511 0 obj -<> endobj +<> endobj 512 0 obj -<> endobj +<> endobj 513 0 obj -<> endobj +<> endobj 514 0 obj -<> endobj +<> endobj 515 0 obj -<> endobj +<> endobj 516 0 obj -<> endobj +<> endobj 517 0 obj -<> endobj +<> endobj 518 0 obj -<> endobj +<> endobj 519 0 obj -<> endobj +<> endobj 520 0 obj -<> endobj +<> endobj 521 0 obj -<> endobj +<> endobj 522 0 obj -<> endobj +<> endobj 523 0 obj -<> endobj +<> endobj 524 0 obj -<> endobj +<> endobj 525 0 obj -<> endobj +<> endobj 526 0 obj -<> endobj +<> endobj 527 0 obj -<> endobj +<> endobj 528 0 obj -<> endobj +<> endobj 529 0 obj -<> endobj +<> endobj 530 0 obj -<> endobj +<> endobj 531 0 obj -<> endobj +<> endobj 532 0 obj -<> endobj +<> endobj 533 0 obj -<> endobj +<> endobj 534 0 obj -<> endobj +<> endobj 535 0 obj -<> endobj +<> endobj 536 0 obj -<> endobj +<> endobj 537 0 obj -<> endobj +<> endobj 538 0 obj -<> endobj +<> endobj 539 0 obj -<> endobj +<> endobj 540 0 obj -<> endobj +<> endobj 541 0 obj -<> endobj +<> endobj 542 0 obj -<> endobj +<> endobj 543 0 obj -<> endobj +<> endobj 544 0 obj -<> endobj +<> endobj 545 0 obj -<> endobj +<> endobj 546 0 obj -<> endobj +<> endobj 547 0 obj <> endobj 548 0 obj -<> endobj +<> endobj 549 0 obj -<> endobj +<> endobj 550 0 obj -<> endobj +<> endobj 551 0 obj -<> endobj +<> endobj 552 0 obj -<> endobj +<> endobj 553 0 obj -<> endobj +<> endobj 554 0 obj -<> endobj +<> endobj 555 0 obj -<> endobj +<> endobj 556 0 obj -<> endobj +<> endobj 557 0 obj -<> endobj +<> endobj 558 0 obj -<> endobj +<> endobj 559 0 obj -<> endobj +<> endobj 560 0 obj -<> endobj +<> endobj 561 0 obj -<> endobj +<> endobj 562 0 obj -<> endobj +<> endobj 563 0 obj -<> endobj +<> endobj 564 0 obj -<> endobj +<> endobj 565 0 obj -<> endobj +<> endobj 566 0 obj -<> endobj +<> endobj 567 0 obj -<> endobj +<> endobj 568 0 obj -<> endobj +<> endobj 569 0 obj -<> endobj +<> endobj 570 0 obj -<> endobj +<> endobj 571 0 obj -<> endobj +<> endobj 572 0 obj -<> endobj +<> endobj 573 0 obj -<> endobj +<> endobj 574 0 obj -<> endobj +<> endobj 575 0 obj -<> endobj +<> endobj 576 0 obj -<> endobj +<> endobj 577 0 obj <> endobj 578 0 obj -<> endobj +<> endobj 579 0 obj -<> endobj +<> endobj 580 0 obj -<> endobj +<> endobj 581 0 obj -<> endobj +<> endobj 582 0 obj -<> endobj +<> endobj 583 0 obj -<> endobj +<> endobj 584 0 obj -<> endobj +<> endobj 585 0 obj -<> endobj +<> endobj 586 0 obj -<> endobj +<> endobj 587 0 obj -<> endobj +<> endobj 588 0 obj -<> endobj +<> endobj 589 0 obj -<> endobj +<> endobj 590 0 obj -<> endobj +<> endobj 591 0 obj -<> endobj +<> endobj 592 0 obj -<> endobj +<> endobj 593 0 obj <> endobj 594 0 obj -<> endobj +<> endobj 595 0 obj <> endobj 596 0 obj -<> endobj +<> endobj 597 0 obj <> endobj 598 0 obj -<> endobj +<> endobj 599 0 obj -<> endobj +<> endobj 600 0 obj -<> endobj +<> endobj 601 0 obj -<> endobj +<> endobj 602 0 obj -<> endobj +<> endobj 603 0 obj -<> endobj +<> endobj 604 0 obj -<> endobj +<> endobj 605 0 obj -<> endobj +<> endobj 606 0 obj -<> endobj +<> endobj 607 0 obj <> endobj 608 0 obj -<> endobj +<> endobj 609 0 obj <> endobj 610 0 obj -<> endobj +<> endobj 611 0 obj -<> endobj +<> endobj 612 0 obj -<> endobj +<> endobj 613 0 obj -<> endobj +<> endobj 614 0 obj -<> endobj +<> endobj 615 0 obj <> endobj 616 0 obj -<> endobj +<> endobj 617 0 obj -<> endobj +<> endobj 618 0 obj -<> endobj +<> endobj 619 0 obj -<> endobj +<> endobj 620 0 obj -<> endobj +<> endobj 621 0 obj -<> endobj +<> endobj 622 0 obj -<> endobj +<> endobj 623 0 obj -<> endobj +<> endobj 624 0 obj -<> endobj +<> endobj 625 0 obj -<> endobj +<> endobj 626 0 obj -<> endobj +<> endobj 627 0 obj -<> endobj +<> endobj 628 0 obj -<> endobj +<> endobj 629 0 obj -<> endobj +<> endobj 630 0 obj -<> endobj +<> endobj 631 0 obj <> endobj 632 0 obj -<> endobj +<> endobj 633 0 obj -<> endobj +<> endobj 634 0 obj -<> endobj +<> endobj 635 0 obj <> endobj 636 0 obj -<> endobj +<> endobj 637 0 obj -<> endobj +<> endobj 638 0 obj -<> endobj +<> endobj 639 0 obj -<> endobj +<> endobj 640 0 obj -<> endobj +<> endobj 641 0 obj -<> endobj +<> endobj 642 0 obj -<> endobj +<> endobj 643 0 obj -<> endobj +<> endobj 644 0 obj -<> endobj +<> endobj 645 0 obj -<> endobj +<> endobj 646 0 obj -<> endobj +<> endobj 647 0 obj -<> endobj +<> endobj 648 0 obj -<> endobj +<> endobj 649 0 obj -<> endobj +<> endobj 650 0 obj -<> endobj +<> endobj 651 0 obj -<> endobj +<> endobj 652 0 obj -<> endobj +<> endobj 653 0 obj <> endobj 654 0 obj -<> endobj +<> endobj 655 0 obj <> endobj 656 0 obj -<> endobj +<> endobj 657 0 obj -<> endobj +<> endobj 658 0 obj -<> endobj +<> endobj 659 0 obj -<> endobj +<> endobj 660 0 obj -<> endobj +<> endobj 661 0 obj -<> endobj +<> endobj 662 0 obj -<> endobj +<> endobj 663 0 obj <> endobj 664 0 obj -<> endobj +<> endobj 665 0 obj -<> endobj +<> endobj 666 0 obj -<> endobj +<> endobj 667 0 obj -<> endobj +<> endobj 668 0 obj -<> endobj +<> endobj 669 0 obj -<> endobj +<> endobj 670 0 obj -<> endobj +<> endobj 671 0 obj -<> endobj +<> endobj 672 0 obj -<> endobj +<> endobj 673 0 obj -<> endobj +<> endobj 674 0 obj -<> endobj +<> endobj 675 0 obj -<> endobj +<> endobj 676 0 obj -<> endobj +<> endobj 677 0 obj -<> endobj +<> endobj 678 0 obj -<> endobj +<> endobj 679 0 obj -<> endobj +<> endobj 680 0 obj -<> endobj +<> endobj 681 0 obj -<> endobj +<> endobj 682 0 obj -<> endobj +<> endobj 683 0 obj -<> endobj +<> endobj 684 0 obj -<> endobj +<> endobj 685 0 obj -<> endobj +<> endobj 686 0 obj -<> endobj +<> endobj 687 0 obj -<> endobj +<> endobj 688 0 obj -<> endobj +<> endobj 689 0 obj <> endobj 690 0 obj -<> endobj +<> endobj 691 0 obj -<> endobj +<> endobj 692 0 obj -<> endobj +<> endobj 693 0 obj -<> endobj +<> endobj 694 0 obj -<> endobj +<> endobj 695 0 obj -<> endobj +<> endobj 696 0 obj -<> endobj +<> endobj 697 0 obj <> endobj 698 0 obj -<> endobj +<> endobj 699 0 obj <> endobj 700 0 obj -<> endobj +<> endobj 701 0 obj -<> endobj +<> endobj 702 0 obj -<> endobj +<> endobj 703 0 obj -<> endobj +<> endobj 704 0 obj +<> endobj +705 0 obj +<> endobj +706 0 obj +<> endobj +707 0 obj +<> endobj +708 0 obj +<> endobj +709 0 obj +<> endobj +710 0 obj +<> endobj +711 0 obj +<> endobj +712 0 obj +<> endobj +713 0 obj +<> endobj +714 0 obj +<> endobj +715 0 obj +<> endobj +716 0 obj +<> endobj +717 0 obj +<> endobj +718 0 obj +<> endobj +719 0 obj +<> endobj +720 0 obj +<> endobj +721 0 obj +<> endobj +722 0 obj +<> endobj +723 0 obj +<> endobj +724 0 obj +<> endobj +725 0 obj +<> endobj +726 0 obj +<> endobj +727 0 obj +<> endobj +728 0 obj +<> endobj +729 0 obj +<> endobj +730 0 obj +<> endobj +731 0 obj +<> endobj +732 0 obj +<> endobj +733 0 obj +<> endobj +734 0 obj +<> endobj +735 0 obj +<> endobj +736 0 obj +<> endobj +737 0 obj +<> endobj +738 0 obj +<> endobj +739 0 obj +<> endobj +740 0 obj <> endobj 25 0 obj @@ -31993,7 +32689,7 @@ endobj /Type /Outlines /First 26 0 R /Last 33 0 R -/Count 279 +/Count 291 >> endobj @@ -32014,8 +32710,8 @@ endobj /Parent 25 0 R /Prev 26 0 R /First 34 0 R -/Last 302 0 R -/Count 271 +/Last 314 0 R +/Count 283 >> endobj @@ -32079,116 +32775,80 @@ endobj 34 0 obj << -/Title (GND) +/Title (MIC_N) /Parent 33 0 R -/Next 85 0 R +/Next 38 0 R /First 35 0 R -/Last 84 0 R -/Count 50 +/Last 37 0 R +/Count 3 >> endobj -85 0 obj +38 0 obj << -/Title (VBAT-IN) +/Title (MIC_P) /Parent 33 0 R /Prev 34 0 R -/Next 89 0 R -/First 86 0 R -/Last 88 0 R +/Next 42 0 R +/First 39 0 R +/Last 41 0 R /Count 3 >> endobj -89 0 obj +42 0 obj << -/Title (VCC) +/Title (GND) /Parent 33 0 R -/Prev 85 0 R -/Next 94 0 R -/First 90 0 R -/Last 93 0 R -/Count 4 +/Prev 38 0 R +/Next 105 0 R +/First 43 0 R +/Last 104 0 R +/Count 62 >> endobj -94 0 obj +105 0 obj << -/Title (Charge) +/Title (IN_N) /Parent 33 0 R -/Prev 89 0 R -/Next 97 0 R -/First 95 0 R -/Last 96 0 R +/Prev 42 0 R +/Next 108 0 R +/First 106 0 R +/Last 107 0 R /Count 2 >> endobj -97 0 obj +108 0 obj << -/Title (Power_t) +/Title (IN_P) /Parent 33 0 R -/Prev 94 0 R -/Next 100 0 R -/First 98 0 R -/Last 99 0 R +/Prev 105 0 R +/Next 111 0 R +/First 109 0 R +/Last 110 0 R /Count 2 >> endobj -100 0 obj +111 0 obj << -/Title (TX) +/Title (OUT_N) /Parent 33 0 R -/Prev 97 0 R -/Next 103 0 R -/First 101 0 R -/Last 102 0 R -/Count 2 ->> -endobj - -103 0 obj -<< -/Title (RX) -/Parent 33 0 R -/Prev 100 0 R -/Next 106 0 R -/First 104 0 R -/Last 105 0 R -/Count 2 ->> -endobj - -106 0 obj -<< -/Title (ESP_D_P) -/Parent 33 0 R -/Prev 103 0 R -/Next 110 0 R -/First 107 0 R -/Last 109 0 R -/Count 3 ->> -endobj - -110 0 obj -<< -/Title (ESP_D_N) -/Parent 33 0 R -/Prev 106 0 R +/Prev 108 0 R /Next 114 0 R -/First 111 0 R +/First 112 0 R /Last 113 0 R -/Count 3 +/Count 2 >> endobj 114 0 obj << -/Title (BOOT) +/Title (VO_P) /Parent 33 0 R -/Prev 110 0 R +/Prev 111 0 R /Next 117 0 R /First 115 0 R /Last 116 0 R @@ -32198,7 +32858,7 @@ endobj 117 0 obj << -/Title (EN) +/Title (VO_N) /Parent 33 0 R /Prev 114 0 R /Next 120 0 R @@ -32210,165 +32870,165 @@ endobj 120 0 obj << -/Title (CLK) +/Title (ADCVREF) /Parent 33 0 R /Prev 117 0 R -/Next 124 0 R +/Next 123 0 R /First 121 0 R -/Last 123 0 R -/Count 3 +/Last 122 0 R +/Count 2 >> endobj -124 0 obj +123 0 obj << -/Title (MOSI) +/Title (PA_CTRL) /Parent 33 0 R /Prev 120 0 R -/Next 127 0 R -/First 125 0 R -/Last 126 0 R +/Next 126 0 R +/First 124 0 R +/Last 125 0 R /Count 2 >> endobj -127 0 obj +126 0 obj << -/Title (RES) +/Title (OUT_P) /Parent 33 0 R -/Prev 124 0 R -/Next 130 0 R -/First 128 0 R -/Last 129 0 R +/Prev 123 0 R +/Next 129 0 R +/First 127 0 R +/Last 128 0 R /Count 2 >> endobj -130 0 obj +129 0 obj << -/Title (DC) +/Title (ESP_D_N) /Parent 33 0 R -/Prev 127 0 R +/Prev 126 0 R /Next 133 0 R -/First 131 0 R +/First 130 0 R /Last 132 0 R -/Count 2 +/Count 3 >> endobj 133 0 obj << -/Title (CS) +/Title (ESP_D_P) /Parent 33 0 R -/Prev 130 0 R -/Next 136 0 R +/Prev 129 0 R +/Next 137 0 R /First 134 0 R -/Last 135 0 R -/Count 2 +/Last 136 0 R +/Count 3 >> endobj -136 0 obj -<< -/Title (BLK) -/Parent 33 0 R -/Prev 133 0 R -/Next 139 0 R -/First 137 0 R -/Last 138 0 R -/Count 2 ->> -endobj - -139 0 obj -<< -/Title (TP_SCL) -/Parent 33 0 R -/Prev 136 0 R -/Next 142 0 R -/First 140 0 R -/Last 141 0 R -/Count 2 ->> -endobj - -142 0 obj -<< -/Title (TP_SDA) -/Parent 33 0 R -/Prev 139 0 R -/Next 145 0 R -/First 143 0 R -/Last 144 0 R -/Count 2 ->> -endobj - -145 0 obj -<< -/Title (TP_INT) -/Parent 33 0 R -/Prev 142 0 R -/Next 148 0 R -/First 146 0 R -/Last 147 0 R -/Count 2 ->> -endobj - -148 0 obj -<< -/Title (PA_CTRL) -/Parent 33 0 R -/Prev 145 0 R -/Next 151 0 R -/First 149 0 R -/Last 150 0 R -/Count 2 ->> -endobj - -151 0 obj +137 0 obj << /Title (touch) /Parent 33 0 R -/Prev 148 0 R -/Next 154 0 R -/First 152 0 R -/Last 153 0 R +/Prev 133 0 R +/Next 140 0 R +/First 138 0 R +/Last 139 0 R /Count 2 >> endobj -154 0 obj +140 0 obj << -/Title (MIC_N) +/Title (RX) /Parent 33 0 R -/Prev 151 0 R -/Next 158 0 R -/First 155 0 R -/Last 157 0 R +/Prev 137 0 R +/Next 143 0 R +/First 141 0 R +/Last 142 0 R +/Count 2 +>> +endobj + +143 0 obj +<< +/Title (TX) +/Parent 33 0 R +/Prev 140 0 R +/Next 146 0 R +/First 144 0 R +/Last 145 0 R +/Count 2 +>> +endobj + +146 0 obj +<< +/Title (EN) +/Parent 33 0 R +/Prev 143 0 R +/Next 149 0 R +/First 147 0 R +/Last 148 0 R +/Count 2 +>> +endobj + +149 0 obj +<< +/Title (BOOT) +/Parent 33 0 R +/Prev 146 0 R +/Next 152 0 R +/First 150 0 R +/Last 151 0 R +/Count 2 +>> +endobj + +152 0 obj +<< +/Title (CLK) +/Parent 33 0 R +/Prev 149 0 R +/Next 156 0 R +/First 153 0 R +/Last 155 0 R /Count 3 >> endobj -158 0 obj +156 0 obj << -/Title (MIC_P) +/Title (TP_INT) /Parent 33 0 R -/Prev 154 0 R +/Prev 152 0 R +/Next 159 0 R +/First 157 0 R +/Last 158 0 R +/Count 2 +>> +endobj + +159 0 obj +<< +/Title (TP_SDA) +/Parent 33 0 R +/Prev 156 0 R /Next 162 0 R -/First 159 0 R +/First 160 0 R /Last 161 0 R -/Count 3 +/Count 2 >> endobj 162 0 obj << -/Title (IN_N) +/Title (TP_SCL) /Parent 33 0 R -/Prev 158 0 R +/Prev 159 0 R /Next 165 0 R /First 163 0 R /Last 164 0 R @@ -32378,7 +33038,7 @@ endobj 165 0 obj << -/Title (IN_P) +/Title (BLK) /Parent 33 0 R /Prev 162 0 R /Next 168 0 R @@ -32390,7 +33050,7 @@ endobj 168 0 obj << -/Title (OUT_N) +/Title (CS) /Parent 33 0 R /Prev 165 0 R /Next 171 0 R @@ -32402,7 +33062,7 @@ endobj 171 0 obj << -/Title (VO_P) +/Title (DC) /Parent 33 0 R /Prev 168 0 R /Next 174 0 R @@ -32414,7 +33074,7 @@ endobj 174 0 obj << -/Title (VO_N) +/Title (RES) /Parent 33 0 R /Prev 171 0 R /Next 177 0 R @@ -32426,7 +33086,7 @@ endobj 177 0 obj << -/Title (ADCVREF) +/Title (MOSI) /Parent 33 0 R /Prev 174 0 R /Next 180 0 R @@ -32438,117 +33098,81 @@ endobj 180 0 obj << -/Title (OUT_P) +/Title (VCC) /Parent 33 0 R /Prev 177 0 R -/Next 183 0 R +/Next 185 0 R /First 181 0 R -/Last 182 0 R -/Count 2 ->> -endobj - -183 0 obj -<< -/Title (3V3) -/Parent 33 0 R -/Prev 180 0 R -/Next 201 0 R -/First 184 0 R -/Last 200 0 R -/Count 17 ->> -endobj - -201 0 obj -<< -/Title (+5V) -/Parent 33 0 R -/Prev 183 0 R -/Next 206 0 R -/First 202 0 R -/Last 205 0 R +/Last 184 0 R /Count 4 >> endobj -206 0 obj +185 0 obj << -/Title ($2N20) +/Title (VBAT-IN) /Parent 33 0 R -/Prev 201 0 R -/Next 208 0 R -/First 207 0 R -/Last 207 0 R -/Count 1 +/Prev 180 0 R +/Next 189 0 R +/First 186 0 R +/Last 188 0 R +/Count 3 >> endobj -208 0 obj +189 0 obj << -/Title ($2N22) +/Title (Charge) /Parent 33 0 R -/Prev 206 0 R -/Next 210 0 R -/First 209 0 R -/Last 209 0 R -/Count 1 +/Prev 185 0 R +/Next 192 0 R +/First 190 0 R +/Last 191 0 R +/Count 2 >> endobj -210 0 obj +192 0 obj << -/Title ($2N26) +/Title (Power_t) /Parent 33 0 R -/Prev 208 0 R -/Next 212 0 R -/First 211 0 R -/Last 211 0 R -/Count 1 +/Prev 189 0 R +/Next 195 0 R +/First 193 0 R +/Last 194 0 R +/Count 2 >> endobj -212 0 obj +195 0 obj << -/Title ($2N32) +/Title (3V3) /Parent 33 0 R -/Prev 210 0 R -/Next 214 0 R -/First 213 0 R -/Last 213 0 R -/Count 1 +/Prev 192 0 R +/Next 213 0 R +/First 196 0 R +/Last 212 0 R +/Count 17 >> endobj -214 0 obj +213 0 obj << -/Title ($3N35) +/Title (+5V) /Parent 33 0 R -/Prev 212 0 R -/Next 216 0 R -/First 215 0 R -/Last 215 0 R -/Count 1 ->> -endobj - -216 0 obj -<< -/Title ($4N23) -/Parent 33 0 R -/Prev 214 0 R +/Prev 195 0 R /Next 218 0 R -/First 217 0 R +/First 214 0 R /Last 217 0 R -/Count 1 +/Count 4 >> endobj 218 0 obj << -/Title ($4N24) +/Title ($2N20) /Parent 33 0 R -/Prev 216 0 R +/Prev 213 0 R /Next 220 0 R /First 219 0 R /Last 219 0 R @@ -32558,7 +33182,7 @@ endobj 220 0 obj << -/Title ($4N34) +/Title ($2N22) /Parent 33 0 R /Prev 218 0 R /Next 222 0 R @@ -32570,7 +33194,7 @@ endobj 222 0 obj << -/Title ($4N36) +/Title ($2N26) /Parent 33 0 R /Prev 220 0 R /Next 224 0 R @@ -32582,7 +33206,7 @@ endobj 224 0 obj << -/Title ($4N38) +/Title ($2N32) /Parent 33 0 R /Prev 222 0 R /Next 226 0 R @@ -32594,7 +33218,7 @@ endobj 226 0 obj << -/Title ($4N41) +/Title ($3N35) /Parent 33 0 R /Prev 224 0 R /Next 228 0 R @@ -32606,7 +33230,7 @@ endobj 228 0 obj << -/Title ($4N52) +/Title ($4N23) /Parent 33 0 R /Prev 226 0 R /Next 230 0 R @@ -32618,7 +33242,7 @@ endobj 230 0 obj << -/Title ($6N11) +/Title ($4N24) /Parent 33 0 R /Prev 228 0 R /Next 232 0 R @@ -32630,7 +33254,7 @@ endobj 232 0 obj << -/Title ($6N12) +/Title ($4N34) /Parent 33 0 R /Prev 230 0 R /Next 234 0 R @@ -32642,7 +33266,7 @@ endobj 234 0 obj << -/Title ($6N40) +/Title ($4N36) /Parent 33 0 R /Prev 232 0 R /Next 236 0 R @@ -32654,7 +33278,7 @@ endobj 236 0 obj << -/Title ($6N41) +/Title ($4N38) /Parent 33 0 R /Prev 234 0 R /Next 238 0 R @@ -32666,7 +33290,7 @@ endobj 238 0 obj << -/Title ($6N60) +/Title ($4N41) /Parent 33 0 R /Prev 236 0 R /Next 240 0 R @@ -32678,49 +33302,73 @@ endobj 240 0 obj << -/Title (I2S_LRCK) +/Title ($4N52) /Parent 33 0 R /Prev 238 0 R -/Next 243 0 R +/Next 242 0 R /First 241 0 R -/Last 242 0 R -/Count 2 +/Last 241 0 R +/Count 1 >> endobj -243 0 obj +242 0 obj << -/Title (I2S_ASDOUT) +/Title ($6N11) /Parent 33 0 R /Prev 240 0 R +/Next 244 0 R +/First 243 0 R +/Last 243 0 R +/Count 1 +>> +endobj + +244 0 obj +<< +/Title ($6N12) +/Parent 33 0 R +/Prev 242 0 R /Next 246 0 R -/First 244 0 R +/First 245 0 R /Last 245 0 R -/Count 2 +/Count 1 >> endobj 246 0 obj << -/Title (I2S_DSDIN) +/Title ($6N40) /Parent 33 0 R -/Prev 243 0 R -/Next 249 0 R +/Prev 244 0 R +/Next 248 0 R /First 247 0 R -/Last 248 0 R -/Count 2 +/Last 247 0 R +/Count 1 >> endobj -249 0 obj +248 0 obj << -/Title (I2S_SCLK) +/Title ($6N41) /Parent 33 0 R /Prev 246 0 R +/Next 250 0 R +/First 249 0 R +/Last 249 0 R +/Count 1 +>> +endobj + +250 0 obj +<< +/Title ($6N60) +/Parent 33 0 R +/Prev 248 0 R /Next 252 0 R -/First 250 0 R +/First 251 0 R /Last 251 0 R -/Count 2 +/Count 1 >> endobj @@ -32728,7 +33376,7 @@ endobj << /Title (I2S_MCLK) /Parent 33 0 R -/Prev 249 0 R +/Prev 250 0 R /Next 255 0 R /First 253 0 R /Last 254 0 R @@ -32738,7 +33386,7 @@ endobj 255 0 obj << -/Title (IN1) +/Title (I2S_DSDIN) /Parent 33 0 R /Prev 252 0 R /Next 258 0 R @@ -32750,7 +33398,7 @@ endobj 258 0 obj << -/Title (IN2) +/Title (I2S_LRCK) /Parent 33 0 R /Prev 255 0 R /Next 261 0 R @@ -32762,7 +33410,7 @@ endobj 261 0 obj << -/Title (IN3) +/Title (I2S_ASDOUT) /Parent 33 0 R /Prev 258 0 R /Next 264 0 R @@ -32774,7 +33422,7 @@ endobj 264 0 obj << -/Title (IN4) +/Title (I2S_SCLK) /Parent 33 0 R /Prev 261 0 R /Next 267 0 R @@ -32786,81 +33434,81 @@ endobj 267 0 obj << -/Title (IO1_I2C_SDA) +/Title (IN1) /Parent 33 0 R /Prev 264 0 R -/Next 271 0 R +/Next 270 0 R /First 268 0 R -/Last 270 0 R +/Last 269 0 R +/Count 2 +>> +endobj + +270 0 obj +<< +/Title (IN2) +/Parent 33 0 R +/Prev 267 0 R +/Next 273 0 R +/First 271 0 R +/Last 272 0 R +/Count 2 +>> +endobj + +273 0 obj +<< +/Title (IN3) +/Parent 33 0 R +/Prev 270 0 R +/Next 276 0 R +/First 274 0 R +/Last 275 0 R +/Count 2 +>> +endobj + +276 0 obj +<< +/Title (IN4) +/Parent 33 0 R +/Prev 273 0 R +/Next 279 0 R +/First 277 0 R +/Last 278 0 R +/Count 2 +>> +endobj + +279 0 obj +<< +/Title (IO1_I2C_SDA) +/Parent 33 0 R +/Prev 276 0 R +/Next 283 0 R +/First 280 0 R +/Last 282 0 R /Count 3 >> endobj -271 0 obj +283 0 obj << /Title (IO2_I2C_SCL) /Parent 33 0 R -/Prev 267 0 R -/Next 275 0 R -/First 272 0 R -/Last 274 0 R -/Count 3 ->> -endobj - -275 0 obj -<< -/Title (M_1) -/Parent 33 0 R -/Prev 271 0 R -/Next 278 0 R -/First 276 0 R -/Last 277 0 R -/Count 2 ->> -endobj - -278 0 obj -<< -/Title (M_2) -/Parent 33 0 R -/Prev 275 0 R -/Next 281 0 R -/First 279 0 R -/Last 280 0 R -/Count 2 ->> -endobj - -281 0 obj -<< -/Title (M_3) -/Parent 33 0 R -/Prev 278 0 R -/Next 284 0 R -/First 282 0 R -/Last 283 0 R -/Count 2 ->> -endobj - -284 0 obj -<< -/Title (M_4) -/Parent 33 0 R -/Prev 281 0 R +/Prev 279 0 R /Next 287 0 R -/First 285 0 R +/First 284 0 R /Last 286 0 R -/Count 2 +/Count 3 >> endobj 287 0 obj << -/Title (MIC1_N) +/Title (M_1) /Parent 33 0 R -/Prev 284 0 R +/Prev 283 0 R /Next 290 0 R /First 288 0 R /Last 289 0 R @@ -32870,7 +33518,7 @@ endobj 290 0 obj << -/Title (MIC1_P) +/Title (M_2) /Parent 33 0 R /Prev 287 0 R /Next 293 0 R @@ -32882,7 +33530,7 @@ endobj 293 0 obj << -/Title (SW1) +/Title (M_3) /Parent 33 0 R /Prev 290 0 R /Next 296 0 R @@ -32894,7 +33542,7 @@ endobj 296 0 obj << -/Title (SW2) +/Title (M_4) /Parent 33 0 R /Prev 293 0 R /Next 299 0 R @@ -32906,7 +33554,7 @@ endobj 299 0 obj << -/Title (Vo1_N) +/Title (MIC1_N) /Parent 33 0 R /Prev 296 0 R /Next 302 0 R @@ -32918,1544 +33566,1640 @@ endobj 302 0 obj << -/Title (Vo1_P) +/Title (MIC1_P) /Parent 33 0 R /Prev 299 0 R +/Next 305 0 R /First 303 0 R /Last 304 0 R /Count 2 >> endobj +305 0 obj +<< +/Title (SW1) +/Parent 33 0 R +/Prev 302 0 R +/Next 308 0 R +/First 306 0 R +/Last 307 0 R +/Count 2 +>> +endobj + +308 0 obj +<< +/Title (SW2) +/Parent 33 0 R +/Prev 305 0 R +/Next 311 0 R +/First 309 0 R +/Last 310 0 R +/Count 2 +>> +endobj + +311 0 obj +<< +/Title (Vo1_N) +/Parent 33 0 R +/Prev 308 0 R +/Next 314 0 R +/First 312 0 R +/Last 313 0 R +/Count 2 +>> +endobj + +314 0 obj +<< +/Title (Vo1_P) +/Parent 33 0 R +/Prev 311 0 R +/First 315 0 R +/Last 316 0 R +/Count 2 +>> +endobj + 35 0 obj << -/Title ($2N1) +/Title ($4N1) /Parent 34 0 R /Next 36 0 R -/A 305 0 R +/A 317 0 R >> endobj 36 0 obj << -/Title ($2N14) +/Title ($4N39) /Parent 34 0 R /Prev 35 0 R /Next 37 0 R -/A 307 0 R +/A 319 0 R >> endobj 37 0 obj << -/Title ($2N17) +/Title ($4N57) /Parent 34 0 R /Prev 36 0 R -/Next 38 0 R -/A 309 0 R ->> -endobj - -38 0 obj -<< -/Title ($2N19) -/Parent 34 0 R -/Prev 37 0 R -/Next 39 0 R -/A 311 0 R +/A 321 0 R >> endobj 39 0 obj << -/Title ($2N21) -/Parent 34 0 R -/Prev 38 0 R +/Title ($4N2) +/Parent 38 0 R /Next 40 0 R -/A 313 0 R +/A 323 0 R >> endobj 40 0 obj << -/Title ($2N23) -/Parent 34 0 R +/Title ($4N40) +/Parent 38 0 R /Prev 39 0 R /Next 41 0 R -/A 315 0 R +/A 325 0 R >> endobj 41 0 obj << -/Title ($2N25) -/Parent 34 0 R +/Title ($4N58) +/Parent 38 0 R /Prev 40 0 R -/Next 42 0 R -/A 317 0 R ->> -endobj - -42 0 obj -<< -/Title ($2N27) -/Parent 34 0 R -/Prev 41 0 R -/Next 43 0 R -/A 319 0 R +/A 327 0 R >> endobj 43 0 obj << -/Title ($2N28) -/Parent 34 0 R -/Prev 42 0 R +/Title ($2N1) +/Parent 42 0 R /Next 44 0 R -/A 321 0 R +/A 329 0 R >> endobj 44 0 obj << -/Title ($2N33) -/Parent 34 0 R +/Title ($2N14) +/Parent 42 0 R /Prev 43 0 R /Next 45 0 R -/A 323 0 R +/A 331 0 R >> endobj 45 0 obj << -/Title ($2N35) -/Parent 34 0 R +/Title ($2N17) +/Parent 42 0 R /Prev 44 0 R /Next 46 0 R -/A 325 0 R +/A 333 0 R >> endobj 46 0 obj << -/Title ($2N36) -/Parent 34 0 R +/Title ($2N19) +/Parent 42 0 R /Prev 45 0 R /Next 47 0 R -/A 327 0 R +/A 335 0 R >> endobj 47 0 obj << -/Title ($2N37) -/Parent 34 0 R +/Title ($2N21) +/Parent 42 0 R /Prev 46 0 R /Next 48 0 R -/A 329 0 R +/A 337 0 R >> endobj 48 0 obj << -/Title ($2N40) -/Parent 34 0 R +/Title ($2N23) +/Parent 42 0 R /Prev 47 0 R /Next 49 0 R -/A 331 0 R +/A 339 0 R >> endobj 49 0 obj << -/Title ($3N7) -/Parent 34 0 R +/Title ($2N25) +/Parent 42 0 R /Prev 48 0 R /Next 50 0 R -/A 333 0 R +/A 341 0 R >> endobj 50 0 obj << -/Title ($3N29) -/Parent 34 0 R +/Title ($2N27) +/Parent 42 0 R /Prev 49 0 R /Next 51 0 R -/A 335 0 R +/A 343 0 R >> endobj 51 0 obj << -/Title ($3N31) -/Parent 34 0 R +/Title ($2N28) +/Parent 42 0 R /Prev 50 0 R /Next 52 0 R -/A 337 0 R +/A 345 0 R >> endobj 52 0 obj << -/Title ($3N34) -/Parent 34 0 R +/Title ($2N33) +/Parent 42 0 R /Prev 51 0 R /Next 53 0 R -/A 339 0 R +/A 347 0 R >> endobj 53 0 obj << -/Title ($4N3) -/Parent 34 0 R +/Title ($2N35) +/Parent 42 0 R /Prev 52 0 R /Next 54 0 R -/A 341 0 R +/A 349 0 R >> endobj 54 0 obj << -/Title ($4N5) -/Parent 34 0 R +/Title ($2N36) +/Parent 42 0 R /Prev 53 0 R /Next 55 0 R -/A 343 0 R +/A 351 0 R >> endobj 55 0 obj << -/Title ($4N6) -/Parent 34 0 R +/Title ($2N37) +/Parent 42 0 R /Prev 54 0 R /Next 56 0 R -/A 345 0 R +/A 353 0 R >> endobj 56 0 obj << -/Title ($4N7) -/Parent 34 0 R +/Title ($2N40) +/Parent 42 0 R /Prev 55 0 R /Next 57 0 R -/A 347 0 R +/A 355 0 R >> endobj 57 0 obj << -/Title ($4N9) -/Parent 34 0 R +/Title ($3N7) +/Parent 42 0 R /Prev 56 0 R /Next 58 0 R -/A 349 0 R +/A 357 0 R >> endobj 58 0 obj << -/Title ($4N10) -/Parent 34 0 R +/Title ($3N29) +/Parent 42 0 R /Prev 57 0 R /Next 59 0 R -/A 351 0 R +/A 359 0 R >> endobj 59 0 obj << -/Title ($4N11) -/Parent 34 0 R +/Title ($3N31) +/Parent 42 0 R /Prev 58 0 R /Next 60 0 R -/A 353 0 R +/A 361 0 R >> endobj 60 0 obj << -/Title ($4N13) -/Parent 34 0 R +/Title ($3N34) +/Parent 42 0 R /Prev 59 0 R /Next 61 0 R -/A 355 0 R +/A 363 0 R >> endobj 61 0 obj << -/Title ($4N14) -/Parent 34 0 R +/Title ($4N3) +/Parent 42 0 R /Prev 60 0 R /Next 62 0 R -/A 357 0 R +/A 365 0 R >> endobj 62 0 obj << -/Title ($4N51) -/Parent 34 0 R +/Title ($4N5) +/Parent 42 0 R /Prev 61 0 R /Next 63 0 R -/A 359 0 R +/A 367 0 R >> endobj 63 0 obj << -/Title ($4N55) -/Parent 34 0 R +/Title ($4N6) +/Parent 42 0 R /Prev 62 0 R /Next 64 0 R -/A 361 0 R +/A 369 0 R >> endobj 64 0 obj << -/Title ($4N64) -/Parent 34 0 R +/Title ($4N7) +/Parent 42 0 R /Prev 63 0 R /Next 65 0 R -/A 363 0 R +/A 371 0 R >> endobj 65 0 obj << -/Title ($4N65) -/Parent 34 0 R +/Title ($4N9) +/Parent 42 0 R /Prev 64 0 R /Next 66 0 R -/A 365 0 R +/A 373 0 R >> endobj 66 0 obj << -/Title ($4N67) -/Parent 34 0 R +/Title ($4N10) +/Parent 42 0 R /Prev 65 0 R /Next 67 0 R -/A 367 0 R +/A 375 0 R >> endobj 67 0 obj << -/Title ($4N68) -/Parent 34 0 R +/Title ($4N11) +/Parent 42 0 R /Prev 66 0 R /Next 68 0 R -/A 369 0 R +/A 377 0 R >> endobj 68 0 obj << -/Title ($5N1) -/Parent 34 0 R +/Title ($4N13) +/Parent 42 0 R /Prev 67 0 R /Next 69 0 R -/A 371 0 R +/A 379 0 R >> endobj 69 0 obj << -/Title ($6N6) -/Parent 34 0 R +/Title ($4N14) +/Parent 42 0 R /Prev 68 0 R /Next 70 0 R -/A 373 0 R +/A 381 0 R >> endobj 70 0 obj << -/Title ($6N7) -/Parent 34 0 R +/Title ($4N51) +/Parent 42 0 R /Prev 69 0 R /Next 71 0 R -/A 375 0 R +/A 383 0 R >> endobj 71 0 obj << -/Title ($6N8) -/Parent 34 0 R +/Title ($4N55) +/Parent 42 0 R /Prev 70 0 R /Next 72 0 R -/A 377 0 R +/A 385 0 R >> endobj 72 0 obj << -/Title ($6N9) -/Parent 34 0 R +/Title ($4N64) +/Parent 42 0 R /Prev 71 0 R /Next 73 0 R -/A 379 0 R +/A 387 0 R >> endobj 73 0 obj << -/Title ($6N10) -/Parent 34 0 R +/Title ($4N65) +/Parent 42 0 R /Prev 72 0 R /Next 74 0 R -/A 381 0 R +/A 389 0 R >> endobj 74 0 obj << -/Title ($6N15) -/Parent 34 0 R +/Title ($4N67) +/Parent 42 0 R /Prev 73 0 R /Next 75 0 R -/A 383 0 R +/A 391 0 R >> endobj 75 0 obj << -/Title ($6N16) -/Parent 34 0 R +/Title ($4N68) +/Parent 42 0 R /Prev 74 0 R /Next 76 0 R -/A 385 0 R +/A 393 0 R >> endobj 76 0 obj << -/Title ($6N21) -/Parent 34 0 R +/Title ($4N69) +/Parent 42 0 R /Prev 75 0 R /Next 77 0 R -/A 387 0 R +/A 395 0 R >> endobj 77 0 obj << -/Title ($6N22) -/Parent 34 0 R +/Title ($4N70) +/Parent 42 0 R /Prev 76 0 R /Next 78 0 R -/A 389 0 R +/A 397 0 R >> endobj 78 0 obj << -/Title ($6N27) -/Parent 34 0 R +/Title ($4N71) +/Parent 42 0 R /Prev 77 0 R /Next 79 0 R -/A 391 0 R +/A 399 0 R >> endobj 79 0 obj << -/Title ($6N28) -/Parent 34 0 R +/Title ($4N72) +/Parent 42 0 R /Prev 78 0 R /Next 80 0 R -/A 393 0 R +/A 401 0 R >> endobj 80 0 obj << -/Title ($6N43) -/Parent 34 0 R +/Title ($5N1) +/Parent 42 0 R /Prev 79 0 R /Next 81 0 R -/A 395 0 R +/A 403 0 R >> endobj 81 0 obj << -/Title ($6N46) -/Parent 34 0 R +/Title ($6N6) +/Parent 42 0 R /Prev 80 0 R /Next 82 0 R -/A 397 0 R +/A 405 0 R >> endobj 82 0 obj << -/Title ($6N48) -/Parent 34 0 R +/Title ($6N7) +/Parent 42 0 R /Prev 81 0 R /Next 83 0 R -/A 399 0 R +/A 407 0 R >> endobj 83 0 obj << -/Title ($6N54) -/Parent 34 0 R +/Title ($6N8) +/Parent 42 0 R /Prev 82 0 R /Next 84 0 R -/A 401 0 R +/A 409 0 R >> endobj 84 0 obj << -/Title ($6N71) -/Parent 34 0 R +/Title ($6N9) +/Parent 42 0 R /Prev 83 0 R -/A 403 0 R +/Next 85 0 R +/A 411 0 R +>> +endobj + +85 0 obj +<< +/Title ($6N10) +/Parent 42 0 R +/Prev 84 0 R +/Next 86 0 R +/A 413 0 R >> endobj 86 0 obj << -/Title ($2N2) -/Parent 85 0 R +/Title ($6N15) +/Parent 42 0 R +/Prev 85 0 R /Next 87 0 R -/A 405 0 R +/A 415 0 R >> endobj 87 0 obj << -/Title ($2N13) -/Parent 85 0 R +/Title ($6N16) +/Parent 42 0 R /Prev 86 0 R /Next 88 0 R -/A 407 0 R +/A 417 0 R >> endobj 88 0 obj << -/Title ($2N39) -/Parent 85 0 R +/Title ($6N21) +/Parent 42 0 R /Prev 87 0 R -/A 409 0 R +/Next 89 0 R +/A 419 0 R +>> +endobj + +89 0 obj +<< +/Title ($6N22) +/Parent 42 0 R +/Prev 88 0 R +/Next 90 0 R +/A 421 0 R >> endobj 90 0 obj << -/Title ($2N15) -/Parent 89 0 R +/Title ($6N27) +/Parent 42 0 R +/Prev 89 0 R /Next 91 0 R -/A 411 0 R +/A 423 0 R >> endobj 91 0 obj << -/Title ($2N18) -/Parent 89 0 R +/Title ($6N28) +/Parent 42 0 R /Prev 90 0 R /Next 92 0 R -/A 413 0 R +/A 425 0 R >> endobj 92 0 obj << -/Title ($5N2) -/Parent 89 0 R +/Title ($6N43) +/Parent 42 0 R /Prev 91 0 R /Next 93 0 R -/A 415 0 R +/A 427 0 R >> endobj 93 0 obj << -/Title ($6N72) -/Parent 89 0 R +/Title ($6N46) +/Parent 42 0 R /Prev 92 0 R -/A 417 0 R +/Next 94 0 R +/A 429 0 R +>> +endobj + +94 0 obj +<< +/Title ($6N48) +/Parent 42 0 R +/Prev 93 0 R +/Next 95 0 R +/A 431 0 R >> endobj 95 0 obj << -/Title ($2N34) -/Parent 94 0 R +/Title ($6N54) +/Parent 42 0 R +/Prev 94 0 R /Next 96 0 R -/A 419 0 R +/A 433 0 R >> endobj 96 0 obj << -/Title ($3N54) -/Parent 94 0 R +/Title ($6N71) +/Parent 42 0 R /Prev 95 0 R -/A 421 0 R +/Next 97 0 R +/A 435 0 R +>> +endobj + +97 0 obj +<< +/Title ($6N77) +/Parent 42 0 R +/Prev 96 0 R +/Next 98 0 R +/A 437 0 R >> endobj 98 0 obj << -/Title ($2N41) -/Parent 97 0 R +/Title ($6N78) +/Parent 42 0 R +/Prev 97 0 R /Next 99 0 R -/A 423 0 R +/A 439 0 R >> endobj 99 0 obj << -/Title ($3N62) -/Parent 97 0 R +/Title ($6N79) +/Parent 42 0 R /Prev 98 0 R -/A 425 0 R +/Next 100 0 R +/A 441 0 R +>> +endobj + +100 0 obj +<< +/Title ($6N80) +/Parent 42 0 R +/Prev 99 0 R +/Next 101 0 R +/A 443 0 R >> endobj 101 0 obj << -/Title ($3N8) -/Parent 100 0 R +/Title ($6N81) +/Parent 42 0 R +/Prev 100 0 R /Next 102 0 R -/A 427 0 R +/A 445 0 R >> endobj 102 0 obj << -/Title ($6N50) -/Parent 100 0 R +/Title ($6N82) +/Parent 42 0 R /Prev 101 0 R -/A 429 0 R +/Next 103 0 R +/A 447 0 R +>> +endobj + +103 0 obj +<< +/Title ($6N83) +/Parent 42 0 R +/Prev 102 0 R +/Next 104 0 R +/A 449 0 R >> endobj 104 0 obj << -/Title ($3N9) -/Parent 103 0 R -/Next 105 0 R -/A 431 0 R +/Title ($6N84) +/Parent 42 0 R +/Prev 103 0 R +/A 451 0 R >> endobj -105 0 obj +106 0 obj << -/Title ($6N49) -/Parent 103 0 R -/Prev 104 0 R -/A 433 0 R +/Title ($4N17) +/Parent 105 0 R +/Next 107 0 R +/A 453 0 R >> endobj 107 0 obj << -/Title ($3N17) -/Parent 106 0 R -/Next 108 0 R -/A 435 0 R ->> -endobj - -108 0 obj -<< -/Title ($6N18) -/Parent 106 0 R -/Prev 107 0 R -/Next 109 0 R -/A 437 0 R +/Title ($4N53) +/Parent 105 0 R +/Prev 106 0 R +/A 455 0 R >> endobj 109 0 obj << -/Title ($6N19) -/Parent 106 0 R -/Prev 108 0 R -/A 439 0 R +/Title ($4N18) +/Parent 108 0 R +/Next 110 0 R +/A 457 0 R >> endobj -111 0 obj +110 0 obj << -/Title ($3N18) -/Parent 110 0 R -/Next 112 0 R -/A 441 0 R +/Title ($4N54) +/Parent 108 0 R +/Prev 109 0 R +/A 459 0 R >> endobj 112 0 obj << -/Title ($6N17) -/Parent 110 0 R -/Prev 111 0 R +/Title ($4N25) +/Parent 111 0 R /Next 113 0 R -/A 443 0 R +/A 461 0 R >> endobj 113 0 obj << -/Title ($6N20) -/Parent 110 0 R +/Title ($4N50) +/Parent 111 0 R /Prev 112 0 R -/A 445 0 R +/A 463 0 R >> endobj 115 0 obj << -/Title ($3N30) +/Title ($4N30) /Parent 114 0 R /Next 116 0 R -/A 447 0 R +/A 465 0 R >> endobj 116 0 obj << -/Title ($6N53) +/Title ($4N33) /Parent 114 0 R /Prev 115 0 R -/A 449 0 R +/A 467 0 R >> endobj 118 0 obj << -/Title ($3N33) +/Title ($4N31) /Parent 117 0 R /Next 119 0 R -/A 451 0 R +/A 469 0 R >> endobj 119 0 obj << -/Title ($6N52) +/Title ($4N32) /Parent 117 0 R /Prev 118 0 R -/A 453 0 R +/A 471 0 R >> endobj 121 0 obj << -/Title ($3N37) +/Title ($4N35) /Parent 120 0 R /Next 122 0 R -/A 455 0 R +/A 473 0 R >> endobj 122 0 obj << -/Title ($6N56) +/Title ($4N37) /Parent 120 0 R /Prev 121 0 R -/Next 123 0 R -/A 457 0 R +/A 475 0 R >> endobj -123 0 obj +124 0 obj << -/Title ($6N69) -/Parent 120 0 R -/Prev 122 0 R -/A 459 0 R +/Title ($3N51) +/Parent 123 0 R +/Next 125 0 R +/A 477 0 R >> endobj 125 0 obj << -/Title ($3N38) -/Parent 124 0 R -/Next 126 0 R -/A 461 0 R +/Title ($4N42) +/Parent 123 0 R +/Prev 124 0 R +/A 479 0 R >> endobj -126 0 obj +127 0 obj << -/Title ($6N68) -/Parent 124 0 R -/Prev 125 0 R -/A 463 0 R +/Title ($4N48) +/Parent 126 0 R +/Next 128 0 R +/A 481 0 R >> endobj 128 0 obj << -/Title ($3N39) -/Parent 127 0 R -/Next 129 0 R -/A 465 0 R +/Title ($4N49) +/Parent 126 0 R +/Prev 127 0 R +/A 483 0 R >> endobj -129 0 obj +130 0 obj << -/Title ($6N67) -/Parent 127 0 R -/Prev 128 0 R -/A 467 0 R +/Title ($3N18) +/Parent 129 0 R +/Next 131 0 R +/A 485 0 R >> endobj 131 0 obj << -/Title ($3N40) -/Parent 130 0 R +/Title ($6N17) +/Parent 129 0 R +/Prev 130 0 R /Next 132 0 R -/A 469 0 R +/A 487 0 R >> endobj 132 0 obj << -/Title ($6N66) -/Parent 130 0 R +/Title ($6N20) +/Parent 129 0 R /Prev 131 0 R -/A 471 0 R +/A 489 0 R >> endobj 134 0 obj << -/Title ($3N41) +/Title ($3N17) /Parent 133 0 R /Next 135 0 R -/A 473 0 R +/A 491 0 R >> endobj 135 0 obj << -/Title ($6N65) +/Title ($6N18) /Parent 133 0 R /Prev 134 0 R -/A 475 0 R +/Next 136 0 R +/A 493 0 R >> endobj -137 0 obj +136 0 obj << -/Title ($3N42) -/Parent 136 0 R -/Next 138 0 R -/A 477 0 R +/Title ($6N19) +/Parent 133 0 R +/Prev 135 0 R +/A 495 0 R >> endobj 138 0 obj << -/Title ($6N64) -/Parent 136 0 R -/Prev 137 0 R -/A 479 0 R +/Title ($3N63) +/Parent 137 0 R +/Next 139 0 R +/A 497 0 R >> endobj -140 0 obj +139 0 obj << -/Title ($3N43) -/Parent 139 0 R -/Next 141 0 R -/A 481 0 R +/Title ($6N42) +/Parent 137 0 R +/Prev 138 0 R +/A 499 0 R >> endobj 141 0 obj << -/Title ($6N63) -/Parent 139 0 R -/Prev 140 0 R -/A 483 0 R +/Title ($3N9) +/Parent 140 0 R +/Next 142 0 R +/A 501 0 R >> endobj -143 0 obj +142 0 obj << -/Title ($3N44) -/Parent 142 0 R -/Next 144 0 R -/A 485 0 R +/Title ($6N49) +/Parent 140 0 R +/Prev 141 0 R +/A 503 0 R >> endobj 144 0 obj << -/Title ($6N62) -/Parent 142 0 R -/Prev 143 0 R -/A 487 0 R +/Title ($3N8) +/Parent 143 0 R +/Next 145 0 R +/A 505 0 R >> endobj -146 0 obj +145 0 obj << -/Title ($3N45) -/Parent 145 0 R -/Next 147 0 R -/A 489 0 R +/Title ($6N50) +/Parent 143 0 R +/Prev 144 0 R +/A 507 0 R >> endobj 147 0 obj << -/Title ($6N61) -/Parent 145 0 R -/Prev 146 0 R -/A 491 0 R +/Title ($3N33) +/Parent 146 0 R +/Next 148 0 R +/A 509 0 R >> endobj -149 0 obj +148 0 obj << -/Title ($3N51) -/Parent 148 0 R -/Next 150 0 R -/A 493 0 R +/Title ($6N52) +/Parent 146 0 R +/Prev 147 0 R +/A 511 0 R >> endobj 150 0 obj << -/Title ($4N42) -/Parent 148 0 R -/Prev 149 0 R -/A 495 0 R +/Title ($3N30) +/Parent 149 0 R +/Next 151 0 R +/A 513 0 R >> endobj -152 0 obj +151 0 obj << -/Title ($3N63) -/Parent 151 0 R -/Next 153 0 R -/A 497 0 R +/Title ($6N53) +/Parent 149 0 R +/Prev 150 0 R +/A 515 0 R >> endobj 153 0 obj << -/Title ($6N42) -/Parent 151 0 R -/Prev 152 0 R -/A 499 0 R +/Title ($3N37) +/Parent 152 0 R +/Next 154 0 R +/A 517 0 R +>> +endobj + +154 0 obj +<< +/Title ($6N56) +/Parent 152 0 R +/Prev 153 0 R +/Next 155 0 R +/A 519 0 R >> endobj 155 0 obj << -/Title ($4N1) -/Parent 154 0 R -/Next 156 0 R -/A 501 0 R ->> -endobj - -156 0 obj -<< -/Title ($4N39) -/Parent 154 0 R -/Prev 155 0 R -/Next 157 0 R -/A 503 0 R +/Title ($6N69) +/Parent 152 0 R +/Prev 154 0 R +/A 521 0 R >> endobj 157 0 obj << -/Title ($4N57) -/Parent 154 0 R -/Prev 156 0 R -/A 505 0 R +/Title ($3N45) +/Parent 156 0 R +/Next 158 0 R +/A 523 0 R >> endobj -159 0 obj +158 0 obj << -/Title ($4N2) -/Parent 158 0 R -/Next 160 0 R -/A 507 0 R +/Title ($6N61) +/Parent 156 0 R +/Prev 157 0 R +/A 525 0 R >> endobj 160 0 obj << -/Title ($4N40) -/Parent 158 0 R -/Prev 159 0 R +/Title ($3N44) +/Parent 159 0 R /Next 161 0 R -/A 509 0 R +/A 527 0 R >> endobj 161 0 obj << -/Title ($4N58) -/Parent 158 0 R +/Title ($6N62) +/Parent 159 0 R /Prev 160 0 R -/A 511 0 R +/A 529 0 R >> endobj 163 0 obj << -/Title ($4N17) +/Title ($3N43) /Parent 162 0 R /Next 164 0 R -/A 513 0 R +/A 531 0 R >> endobj 164 0 obj << -/Title ($4N53) +/Title ($6N63) /Parent 162 0 R /Prev 163 0 R -/A 515 0 R +/A 533 0 R >> endobj 166 0 obj << -/Title ($4N18) +/Title ($3N42) /Parent 165 0 R /Next 167 0 R -/A 517 0 R +/A 535 0 R >> endobj 167 0 obj << -/Title ($4N54) +/Title ($6N64) /Parent 165 0 R /Prev 166 0 R -/A 519 0 R +/A 537 0 R >> endobj 169 0 obj << -/Title ($4N25) +/Title ($3N41) /Parent 168 0 R /Next 170 0 R -/A 521 0 R +/A 539 0 R >> endobj 170 0 obj << -/Title ($4N50) +/Title ($6N65) /Parent 168 0 R /Prev 169 0 R -/A 523 0 R +/A 541 0 R >> endobj 172 0 obj << -/Title ($4N30) +/Title ($3N40) /Parent 171 0 R /Next 173 0 R -/A 525 0 R +/A 543 0 R >> endobj 173 0 obj << -/Title ($4N33) +/Title ($6N66) /Parent 171 0 R /Prev 172 0 R -/A 527 0 R +/A 545 0 R >> endobj 175 0 obj << -/Title ($4N31) +/Title ($3N39) /Parent 174 0 R /Next 176 0 R -/A 529 0 R +/A 547 0 R >> endobj 176 0 obj << -/Title ($4N32) +/Title ($6N67) /Parent 174 0 R /Prev 175 0 R -/A 531 0 R +/A 549 0 R >> endobj 178 0 obj << -/Title ($4N35) +/Title ($3N38) /Parent 177 0 R /Next 179 0 R -/A 533 0 R +/A 551 0 R >> endobj 179 0 obj << -/Title ($4N37) +/Title ($6N68) /Parent 177 0 R /Prev 178 0 R -/A 535 0 R +/A 553 0 R >> endobj 181 0 obj << -/Title ($4N48) +/Title ($2N15) /Parent 180 0 R /Next 182 0 R -/A 537 0 R +/A 555 0 R >> endobj 182 0 obj << -/Title ($4N49) +/Title ($2N18) /Parent 180 0 R /Prev 181 0 R -/A 539 0 R +/Next 183 0 R +/A 557 0 R +>> +endobj + +183 0 obj +<< +/Title ($5N2) +/Parent 180 0 R +/Prev 182 0 R +/Next 184 0 R +/A 559 0 R >> endobj 184 0 obj << -/Title ($2N24) -/Parent 183 0 R -/Next 185 0 R -/A 541 0 R ->> -endobj - -185 0 obj -<< -/Title ($2N31) -/Parent 183 0 R -/Prev 184 0 R -/Next 186 0 R -/A 543 0 R +/Title ($6N72) +/Parent 180 0 R +/Prev 183 0 R +/A 561 0 R >> endobj 186 0 obj << -/Title ($3N4) -/Parent 183 0 R -/Prev 185 0 R +/Title ($2N2) +/Parent 185 0 R /Next 187 0 R -/A 545 0 R +/A 563 0 R >> endobj 187 0 obj << -/Title ($3N36) -/Parent 183 0 R +/Title ($2N13) +/Parent 185 0 R /Prev 186 0 R /Next 188 0 R -/A 547 0 R +/A 565 0 R >> endobj 188 0 obj << -/Title ($4N8) -/Parent 183 0 R +/Title ($2N39) +/Parent 185 0 R /Prev 187 0 R -/Next 189 0 R -/A 549 0 R ->> -endobj - -189 0 obj -<< -/Title ($4N12) -/Parent 183 0 R -/Prev 188 0 R -/Next 190 0 R -/A 551 0 R +/A 567 0 R >> endobj 190 0 obj << -/Title ($4N15) -/Parent 183 0 R -/Prev 189 0 R +/Title ($2N34) +/Parent 189 0 R /Next 191 0 R -/A 553 0 R +/A 569 0 R >> endobj 191 0 obj << -/Title ($4N16) -/Parent 183 0 R +/Title ($3N54) +/Parent 189 0 R /Prev 190 0 R -/Next 192 0 R -/A 555 0 R ->> -endobj - -192 0 obj -<< -/Title ($4N45) -/Parent 183 0 R -/Prev 191 0 R -/Next 193 0 R -/A 557 0 R +/A 571 0 R >> endobj 193 0 obj << -/Title ($4N56) -/Parent 183 0 R -/Prev 192 0 R +/Title ($2N41) +/Parent 192 0 R /Next 194 0 R -/A 559 0 R +/A 573 0 R >> endobj 194 0 obj << -/Title ($4N66) -/Parent 183 0 R +/Title ($3N62) +/Parent 192 0 R /Prev 193 0 R -/Next 195 0 R -/A 561 0 R ->> -endobj - -195 0 obj -<< -/Title ($6N23) -/Parent 183 0 R -/Prev 194 0 R -/Next 196 0 R -/A 563 0 R +/A 575 0 R >> endobj 196 0 obj << -/Title ($6N24) -/Parent 183 0 R -/Prev 195 0 R +/Title ($2N24) +/Parent 195 0 R /Next 197 0 R -/A 565 0 R +/A 577 0 R >> endobj 197 0 obj << -/Title ($6N47) -/Parent 183 0 R +/Title ($2N31) +/Parent 195 0 R /Prev 196 0 R /Next 198 0 R -/A 567 0 R +/A 579 0 R >> endobj 198 0 obj << -/Title ($6N55) -/Parent 183 0 R +/Title ($3N4) +/Parent 195 0 R /Prev 197 0 R /Next 199 0 R -/A 569 0 R +/A 581 0 R >> endobj 199 0 obj << -/Title ($6N59) -/Parent 183 0 R +/Title ($3N36) +/Parent 195 0 R /Prev 198 0 R /Next 200 0 R -/A 571 0 R +/A 583 0 R >> endobj 200 0 obj << -/Title ($6N70) -/Parent 183 0 R +/Title ($4N8) +/Parent 195 0 R /Prev 199 0 R -/A 573 0 R +/Next 201 0 R +/A 585 0 R +>> +endobj + +201 0 obj +<< +/Title ($4N12) +/Parent 195 0 R +/Prev 200 0 R +/Next 202 0 R +/A 587 0 R >> endobj 202 0 obj << -/Title ($2N16) -/Parent 201 0 R +/Title ($4N15) +/Parent 195 0 R +/Prev 201 0 R /Next 203 0 R -/A 575 0 R +/A 589 0 R >> endobj 203 0 obj << -/Title ($2N38) -/Parent 201 0 R +/Title ($4N16) +/Parent 195 0 R /Prev 202 0 R /Next 204 0 R -/A 577 0 R +/A 591 0 R >> endobj 204 0 obj << -/Title ($6N13) -/Parent 201 0 R +/Title ($4N45) +/Parent 195 0 R /Prev 203 0 R /Next 205 0 R -/A 579 0 R +/A 593 0 R >> endobj 205 0 obj << -/Title ($6N14) -/Parent 201 0 R +/Title ($4N56) +/Parent 195 0 R /Prev 204 0 R -/A 581 0 R +/Next 206 0 R +/A 595 0 R +>> +endobj + +206 0 obj +<< +/Title ($4N66) +/Parent 195 0 R +/Prev 205 0 R +/Next 207 0 R +/A 597 0 R >> endobj 207 0 obj << -/Title ($2N20) -/Parent 206 0 R -/A 583 0 R +/Title ($6N23) +/Parent 195 0 R +/Prev 206 0 R +/Next 208 0 R +/A 599 0 R +>> +endobj + +208 0 obj +<< +/Title ($6N24) +/Parent 195 0 R +/Prev 207 0 R +/Next 209 0 R +/A 601 0 R >> endobj 209 0 obj << -/Title ($2N22) -/Parent 208 0 R -/A 585 0 R +/Title ($6N47) +/Parent 195 0 R +/Prev 208 0 R +/Next 210 0 R +/A 603 0 R +>> +endobj + +210 0 obj +<< +/Title ($6N55) +/Parent 195 0 R +/Prev 209 0 R +/Next 211 0 R +/A 605 0 R >> endobj 211 0 obj << -/Title ($2N26) -/Parent 210 0 R -/A 587 0 R +/Title ($6N59) +/Parent 195 0 R +/Prev 210 0 R +/Next 212 0 R +/A 607 0 R >> endobj -213 0 obj +212 0 obj << -/Title ($2N32) -/Parent 212 0 R -/A 589 0 R +/Title ($6N70) +/Parent 195 0 R +/Prev 211 0 R +/A 609 0 R +>> +endobj + +214 0 obj +<< +/Title ($2N16) +/Parent 213 0 R +/Next 215 0 R +/A 611 0 R >> endobj 215 0 obj << -/Title ($3N35) -/Parent 214 0 R -/A 591 0 R +/Title ($2N38) +/Parent 213 0 R +/Prev 214 0 R +/Next 216 0 R +/A 613 0 R +>> +endobj + +216 0 obj +<< +/Title ($6N13) +/Parent 213 0 R +/Prev 215 0 R +/Next 217 0 R +/A 615 0 R >> endobj 217 0 obj << -/Title ($4N23) -/Parent 216 0 R -/A 593 0 R +/Title ($6N14) +/Parent 213 0 R +/Prev 216 0 R +/A 617 0 R >> endobj 219 0 obj << -/Title ($4N24) +/Title ($2N20) /Parent 218 0 R -/A 595 0 R +/A 619 0 R >> endobj 221 0 obj << -/Title ($4N34) +/Title ($2N22) /Parent 220 0 R -/A 597 0 R +/A 621 0 R >> endobj 223 0 obj << -/Title ($4N36) +/Title ($2N26) /Parent 222 0 R -/A 599 0 R +/A 623 0 R >> endobj 225 0 obj << -/Title ($4N38) +/Title ($2N32) /Parent 224 0 R -/A 601 0 R +/A 625 0 R >> endobj 227 0 obj << -/Title ($4N41) +/Title ($3N35) /Parent 226 0 R -/A 603 0 R +/A 627 0 R >> endobj 229 0 obj << -/Title ($4N52) +/Title ($4N23) /Parent 228 0 R -/A 605 0 R +/A 629 0 R >> endobj 231 0 obj << -/Title ($6N11) +/Title ($4N24) /Parent 230 0 R -/A 607 0 R +/A 631 0 R >> endobj 233 0 obj << -/Title ($6N12) +/Title ($4N34) /Parent 232 0 R -/A 609 0 R +/A 633 0 R >> endobj 235 0 obj << -/Title ($6N40) +/Title ($4N36) /Parent 234 0 R -/A 611 0 R +/A 635 0 R >> endobj 237 0 obj << -/Title ($6N41) +/Title ($4N38) /Parent 236 0 R -/A 613 0 R +/A 637 0 R >> endobj 239 0 obj << -/Title ($6N60) +/Title ($4N41) /Parent 238 0 R -/A 615 0 R +/A 639 0 R >> endobj 241 0 obj << -/Title ($3N10) +/Title ($4N52) /Parent 240 0 R -/Next 242 0 R -/A 617 0 R +/A 641 0 R >> endobj -242 0 obj +243 0 obj << -/Title ($4N59) -/Parent 240 0 R -/Prev 241 0 R -/A 619 0 R ->> -endobj - -244 0 obj -<< -/Title ($3N11) -/Parent 243 0 R -/Next 245 0 R -/A 621 0 R +/Title ($6N11) +/Parent 242 0 R +/A 643 0 R >> endobj 245 0 obj << -/Title ($4N60) -/Parent 243 0 R -/Prev 244 0 R -/A 623 0 R +/Title ($6N12) +/Parent 244 0 R +/A 645 0 R >> endobj 247 0 obj << -/Title ($3N12) +/Title ($6N40) /Parent 246 0 R -/Next 248 0 R -/A 625 0 R +/A 647 0 R >> endobj -248 0 obj +249 0 obj << -/Title ($4N44) -/Parent 246 0 R -/Prev 247 0 R -/A 627 0 R ->> -endobj - -250 0 obj -<< -/Title ($3N13) -/Parent 249 0 R -/Next 251 0 R -/A 629 0 R +/Title ($6N41) +/Parent 248 0 R +/A 649 0 R >> endobj 251 0 obj << -/Title ($4N61) -/Parent 249 0 R -/Prev 250 0 R -/A 631 0 R +/Title ($6N60) +/Parent 250 0 R +/A 651 0 R >> endobj @@ -34464,7 +35208,7 @@ endobj /Title ($3N14) /Parent 252 0 R /Next 254 0 R -/A 633 0 R +/A 653 0 R >> endobj @@ -34473,325 +35217,397 @@ endobj /Title ($4N43) /Parent 252 0 R /Prev 253 0 R -/A 635 0 R +/A 655 0 R >> endobj 256 0 obj << -/Title ($3N67) +/Title ($3N12) /Parent 255 0 R /Next 257 0 R -/A 637 0 R +/A 657 0 R >> endobj 257 0 obj << -/Title ($5N10) +/Title ($4N44) /Parent 255 0 R /Prev 256 0 R -/A 639 0 R +/A 659 0 R >> endobj 259 0 obj << -/Title ($3N66) +/Title ($3N10) /Parent 258 0 R /Next 260 0 R -/A 641 0 R +/A 661 0 R >> endobj 260 0 obj << -/Title ($5N9) +/Title ($4N59) /Parent 258 0 R /Prev 259 0 R -/A 643 0 R +/A 663 0 R >> endobj 262 0 obj << -/Title ($3N65) +/Title ($3N11) /Parent 261 0 R /Next 263 0 R -/A 645 0 R +/A 665 0 R >> endobj 263 0 obj << -/Title ($5N8) +/Title ($4N60) /Parent 261 0 R /Prev 262 0 R -/A 647 0 R +/A 667 0 R >> endobj 265 0 obj << -/Title ($3N64) +/Title ($3N13) /Parent 264 0 R /Next 266 0 R -/A 649 0 R +/A 669 0 R >> endobj 266 0 obj << -/Title ($5N7) +/Title ($4N61) /Parent 264 0 R /Prev 265 0 R -/A 651 0 R +/A 671 0 R >> endobj 268 0 obj << -/Title ($3N16) +/Title ($3N67) /Parent 267 0 R /Next 269 0 R -/A 653 0 R +/A 673 0 R >> endobj 269 0 obj << -/Title ($4N46) +/Title ($5N10) /Parent 267 0 R /Prev 268 0 R -/Next 270 0 R -/A 655 0 R +/A 675 0 R >> endobj -270 0 obj +271 0 obj << -/Title ($4N63) -/Parent 267 0 R -/Prev 269 0 R -/A 657 0 R +/Title ($3N66) +/Parent 270 0 R +/Next 272 0 R +/A 677 0 R >> endobj 272 0 obj << -/Title ($3N15) -/Parent 271 0 R -/Next 273 0 R -/A 659 0 R ->> -endobj - -273 0 obj -<< -/Title ($4N47) -/Parent 271 0 R -/Prev 272 0 R -/Next 274 0 R -/A 661 0 R +/Title ($5N9) +/Parent 270 0 R +/Prev 271 0 R +/A 679 0 R >> endobj 274 0 obj << -/Title ($4N62) -/Parent 271 0 R -/Prev 273 0 R -/A 663 0 R +/Title ($3N65) +/Parent 273 0 R +/Next 275 0 R +/A 681 0 R >> endobj -276 0 obj +275 0 obj << -/Title ($5N6) -/Parent 275 0 R -/Next 277 0 R -/A 665 0 R +/Title ($5N8) +/Parent 273 0 R +/Prev 274 0 R +/A 683 0 R >> endobj 277 0 obj << -/Title ($6N76) -/Parent 275 0 R -/Prev 276 0 R -/A 667 0 R +/Title ($3N64) +/Parent 276 0 R +/Next 278 0 R +/A 685 0 R >> endobj -279 0 obj +278 0 obj << -/Title ($5N5) -/Parent 278 0 R -/Next 280 0 R -/A 669 0 R +/Title ($5N7) +/Parent 276 0 R +/Prev 277 0 R +/A 687 0 R >> endobj 280 0 obj << -/Title ($6N75) -/Parent 278 0 R -/Prev 279 0 R -/A 671 0 R +/Title ($3N16) +/Parent 279 0 R +/Next 281 0 R +/A 689 0 R +>> +endobj + +281 0 obj +<< +/Title ($4N46) +/Parent 279 0 R +/Prev 280 0 R +/Next 282 0 R +/A 691 0 R >> endobj 282 0 obj << -/Title ($5N4) -/Parent 281 0 R -/Next 283 0 R -/A 673 0 R +/Title ($4N63) +/Parent 279 0 R +/Prev 281 0 R +/A 693 0 R >> endobj -283 0 obj +284 0 obj << -/Title ($6N74) -/Parent 281 0 R -/Prev 282 0 R -/A 675 0 R +/Title ($3N15) +/Parent 283 0 R +/Next 285 0 R +/A 695 0 R >> endobj 285 0 obj << -/Title ($5N3) -/Parent 284 0 R +/Title ($4N47) +/Parent 283 0 R +/Prev 284 0 R /Next 286 0 R -/A 677 0 R +/A 697 0 R >> endobj 286 0 obj << -/Title ($6N73) -/Parent 284 0 R +/Title ($4N62) +/Parent 283 0 R /Prev 285 0 R -/A 679 0 R +/A 699 0 R >> endobj 288 0 obj << -/Title ($4N19) +/Title ($5N6) /Parent 287 0 R /Next 289 0 R -/A 681 0 R +/A 701 0 R >> endobj 289 0 obj << -/Title ($4N21) +/Title ($6N76) /Parent 287 0 R /Prev 288 0 R -/A 683 0 R +/A 703 0 R >> endobj 291 0 obj << -/Title ($4N20) +/Title ($5N5) /Parent 290 0 R /Next 292 0 R -/A 685 0 R +/A 705 0 R >> endobj 292 0 obj << -/Title ($4N22) +/Title ($6N75) /Parent 290 0 R /Prev 291 0 R -/A 687 0 R +/A 707 0 R >> endobj 294 0 obj << -/Title ($3N60) +/Title ($5N4) /Parent 293 0 R /Next 295 0 R -/A 689 0 R +/A 709 0 R >> endobj 295 0 obj << -/Title ($6N25) +/Title ($6N74) /Parent 293 0 R /Prev 294 0 R -/A 691 0 R +/A 711 0 R >> endobj 297 0 obj << -/Title ($3N61) +/Title ($5N3) /Parent 296 0 R /Next 298 0 R -/A 693 0 R +/A 713 0 R >> endobj 298 0 obj << -/Title ($6N26) +/Title ($6N73) /Parent 296 0 R /Prev 297 0 R -/A 695 0 R +/A 715 0 R >> endobj 300 0 obj << -/Title ($4N26) +/Title ($4N19) /Parent 299 0 R /Next 301 0 R -/A 697 0 R +/A 717 0 R >> endobj 301 0 obj << -/Title ($4N28) +/Title ($4N21) /Parent 299 0 R /Prev 300 0 R -/A 699 0 R +/A 719 0 R >> endobj 303 0 obj << -/Title ($4N27) +/Title ($4N20) /Parent 302 0 R /Next 304 0 R -/A 701 0 R +/A 721 0 R >> endobj 304 0 obj << -/Title ($4N29) +/Title ($4N22) /Parent 302 0 R /Prev 303 0 R -/A 703 0 R +/A 723 0 R >> endobj -705 0 obj +306 0 obj +<< +/Title ($3N60) +/Parent 305 0 R +/Next 307 0 R +/A 725 0 R +>> +endobj + +307 0 obj +<< +/Title ($6N25) +/Parent 305 0 R +/Prev 306 0 R +/A 727 0 R +>> +endobj + +309 0 obj +<< +/Title ($3N61) +/Parent 308 0 R +/Next 310 0 R +/A 729 0 R +>> +endobj + +310 0 obj +<< +/Title ($6N26) +/Parent 308 0 R +/Prev 309 0 R +/A 731 0 R +>> +endobj + +312 0 obj +<< +/Title ($4N26) +/Parent 311 0 R +/Next 313 0 R +/A 733 0 R +>> +endobj + +313 0 obj +<< +/Title ($4N28) +/Parent 311 0 R +/Prev 312 0 R +/A 735 0 R +>> +endobj + +315 0 obj +<< +/Title ($4N27) +/Parent 314 0 R +/Next 316 0 R +/A 737 0 R +>> +endobj + +316 0 obj +<< +/Title ($4N29) +/Parent 314 0 R +/Prev 315 0 R +/A 739 0 R +>> +endobj + +741 0 obj << /Producer (jsPDF 0.0.0) -/CreationDate (D:20260724101132+08'00') +/CreationDate (D:20260803142553+08'00') >> endobj -706 0 obj +742 0 obj << /Type /Catalog /Pages 1 0 R @@ -34801,721 +35617,757 @@ endobj >> endobj xref -0 707 +0 743 0000000000 65535 f -0000391156 00000 n -0000733652 00000 n +0000397448 00000 n +0000739944 00000 n 0000000015 00000 n 0000000124 00000 n 0000027006 00000 n 0000027115 00000 n -0000094224 00000 n -0000094333 00000 n -0000156599 00000 n -0000156711 00000 n -0000266598 00000 n -0000266709 00000 n -0000305120 00000 n -0000305231 00000 n -0000391245 00000 n -0000391411 00000 n -0000391592 00000 n -0000391710 00000 n -0000391880 00000 n -0000392926 00000 n -0000393092 00000 n -0000393286 00000 n -0000393404 00000 n -0000615292 00000 n -0000831758 00000 n -0000831836 00000 n -0000832043 00000 n -0000832179 00000 n -0000832334 00000 n -0000832485 00000 n -0000832640 00000 n -0000832796 00000 n -0000831939 00000 n -0000832949 00000 n -0000841483 00000 n -0000841560 00000 n -0000841651 00000 n -0000841742 00000 n -0000841833 00000 n -0000841924 00000 n -0000842015 00000 n -0000842106 00000 n -0000842197 00000 n -0000842288 00000 n -0000842379 00000 n -0000842470 00000 n -0000842561 00000 n -0000842652 00000 n -0000842743 00000 n -0000842833 00000 n -0000842924 00000 n -0000843015 00000 n -0000843106 00000 n -0000843196 00000 n -0000843286 00000 n -0000843376 00000 n -0000843466 00000 n -0000843556 00000 n -0000843647 00000 n -0000843738 00000 n -0000843829 00000 n -0000843920 00000 n -0000844011 00000 n -0000844102 00000 n -0000844193 00000 n -0000844284 00000 n -0000844375 00000 n -0000844466 00000 n -0000844556 00000 n -0000844646 00000 n -0000844736 00000 n -0000844826 00000 n -0000844916 00000 n -0000845007 00000 n -0000845098 00000 n -0000845189 00000 n -0000845280 00000 n -0000845371 00000 n -0000845462 00000 n -0000845553 00000 n -0000845644 00000 n -0000845735 00000 n -0000845826 00000 n -0000845917 00000 n -0000833051 00000 n -0000845995 00000 n -0000846072 00000 n -0000846163 00000 n -0000833169 00000 n -0000846241 00000 n -0000846319 00000 n -0000846410 00000 n -0000846500 00000 n -0000833283 00000 n -0000846578 00000 n -0000846656 00000 n -0000833400 00000 n -0000846734 00000 n -0000846812 00000 n -0000833519 00000 n -0000846890 00000 n -0000846970 00000 n -0000833636 00000 n -0000847051 00000 n -0000847131 00000 n -0000833754 00000 n -0000847212 00000 n -0000847293 00000 n -0000847388 00000 n -0000833877 00000 n -0000847469 00000 n -0000847550 00000 n -0000847645 00000 n -0000834000 00000 n -0000847726 00000 n -0000847807 00000 n -0000834120 00000 n -0000847888 00000 n -0000847969 00000 n -0000834238 00000 n -0000848050 00000 n -0000848131 00000 n -0000848226 00000 n -0000834357 00000 n -0000848307 00000 n -0000848388 00000 n -0000834477 00000 n -0000848469 00000 n -0000848550 00000 n -0000834596 00000 n -0000848631 00000 n -0000848712 00000 n -0000834714 00000 n +0000094295 00000 n +0000094404 00000 n +0000156670 00000 n +0000156782 00000 n +0000268731 00000 n +0000268842 00000 n +0000307253 00000 n +0000307364 00000 n +0000397537 00000 n +0000397703 00000 n +0000397884 00000 n +0000398002 00000 n +0000398172 00000 n +0000399218 00000 n +0000399384 00000 n +0000399578 00000 n +0000399696 00000 n +0000621584 00000 n +0000844260 00000 n +0000844338 00000 n +0000844545 00000 n +0000844681 00000 n +0000844836 00000 n +0000844987 00000 n +0000845142 00000 n +0000845298 00000 n +0000844441 00000 n +0000845451 00000 n +0000853996 00000 n +0000854073 00000 n +0000854164 00000 n +0000845554 00000 n +0000854242 00000 n +0000854319 00000 n +0000854410 00000 n +0000845670 00000 n +0000854488 00000 n +0000854565 00000 n +0000854656 00000 n +0000854747 00000 n +0000854838 00000 n +0000854929 00000 n +0000855020 00000 n +0000855111 00000 n +0000855202 00000 n +0000855293 00000 n +0000855384 00000 n +0000855475 00000 n +0000855566 00000 n +0000855657 00000 n +0000855748 00000 n +0000855838 00000 n +0000855929 00000 n +0000856020 00000 n +0000856111 00000 n +0000856201 00000 n +0000856291 00000 n +0000856381 00000 n +0000856471 00000 n +0000856561 00000 n +0000856652 00000 n +0000856743 00000 n +0000856834 00000 n +0000856925 00000 n +0000857016 00000 n +0000857107 00000 n +0000857198 00000 n +0000857289 00000 n +0000857380 00000 n +0000857471 00000 n +0000857562 00000 n +0000857653 00000 n +0000857744 00000 n +0000857835 00000 n +0000857925 00000 n +0000858015 00000 n +0000858105 00000 n +0000858195 00000 n +0000858285 00000 n +0000858376 00000 n +0000858467 00000 n +0000858558 00000 n +0000858649 00000 n +0000858740 00000 n +0000858831 00000 n +0000858922 00000 n +0000859013 00000 n +0000859104 00000 n +0000859195 00000 n +0000859286 00000 n +0000859377 00000 n +0000859468 00000 n +0000859559 00000 n +0000859651 00000 n +0000859744 00000 n +0000859838 00000 n +0000859932 00000 n +0000860026 00000 n +0000845787 00000 n +0000860106 00000 n +0000860187 00000 n +0000845906 00000 n +0000860268 00000 n +0000860349 00000 n +0000846026 00000 n +0000860430 00000 n +0000860511 00000 n +0000846147 00000 n +0000860592 00000 n +0000860673 00000 n +0000846267 00000 n +0000860754 00000 n +0000860835 00000 n +0000846387 00000 n +0000860916 00000 n +0000860997 00000 n +0000846510 00000 n +0000861078 00000 n +0000861159 00000 n +0000846633 00000 n +0000861240 00000 n +0000861321 00000 n +0000846754 00000 n +0000861402 00000 n +0000861483 00000 n +0000861578 00000 n +0000846877 00000 n +0000861659 00000 n +0000861740 00000 n +0000861835 00000 n +0000847000 00000 n +0000861916 00000 n +0000861997 00000 n +0000847121 00000 n +0000862078 00000 n +0000862158 00000 n +0000847239 00000 n +0000862239 00000 n +0000862319 00000 n +0000847357 00000 n +0000862400 00000 n +0000862481 00000 n +0000847475 00000 n +0000862562 00000 n +0000862643 00000 n +0000847595 00000 n +0000862724 00000 n +0000862805 00000 n +0000862900 00000 n +0000847714 00000 n +0000862981 00000 n +0000863062 00000 n +0000847836 00000 n +0000863143 00000 n +0000863224 00000 n +0000847958 00000 n +0000863305 00000 n +0000863386 00000 n +0000848080 00000 n +0000863467 00000 n +0000863548 00000 n +0000848199 00000 n +0000863629 00000 n +0000863710 00000 n +0000848317 00000 n +0000863791 00000 n +0000863872 00000 n +0000848435 00000 n +0000863953 00000 n +0000864034 00000 n +0000848554 00000 n +0000864115 00000 n +0000864196 00000 n +0000848674 00000 n +0000864277 00000 n +0000864358 00000 n +0000864453 00000 n +0000864547 00000 n 0000848793 00000 n -0000848874 00000 n -0000834832 00000 n -0000848955 00000 n -0000849036 00000 n -0000834951 00000 n -0000849117 00000 n -0000849198 00000 n -0000835073 00000 n -0000849279 00000 n -0000849360 00000 n -0000835195 00000 n -0000849441 00000 n -0000849522 00000 n -0000835317 00000 n -0000849603 00000 n -0000849684 00000 n -0000835440 00000 n -0000849765 00000 n -0000849846 00000 n -0000835561 00000 n -0000849927 00000 n -0000850007 00000 n -0000850102 00000 n -0000835682 00000 n -0000850183 00000 n -0000850263 00000 n -0000850358 00000 n -0000835803 00000 n -0000850439 00000 n -0000850520 00000 n -0000835923 00000 n -0000850601 00000 n -0000850682 00000 n -0000836043 00000 n -0000850763 00000 n -0000850844 00000 n -0000836164 00000 n -0000850925 00000 n -0000851006 00000 n -0000836284 00000 n -0000851087 00000 n -0000851168 00000 n -0000836404 00000 n -0000851249 00000 n -0000851330 00000 n -0000836527 00000 n -0000851411 00000 n -0000851492 00000 n -0000836648 00000 n -0000851573 00000 n -0000851654 00000 n -0000851749 00000 n -0000851843 00000 n -0000851938 00000 n -0000852032 00000 n -0000852127 00000 n -0000852222 00000 n -0000852317 00000 n -0000852412 00000 n -0000852507 00000 n -0000852602 00000 n -0000852697 00000 n -0000852792 00000 n -0000852887 00000 n -0000852982 00000 n -0000853077 00000 n -0000836768 00000 n -0000853158 00000 n -0000853239 00000 n -0000853334 00000 n -0000853429 00000 n -0000836887 00000 n -0000853510 00000 n -0000837008 00000 n -0000853577 00000 n -0000837129 00000 n -0000853644 00000 n -0000837250 00000 n -0000853711 00000 n -0000837371 00000 n -0000853778 00000 n -0000837492 00000 n -0000853845 00000 n -0000837613 00000 n -0000853912 00000 n -0000837734 00000 n -0000853979 00000 n -0000837855 00000 n -0000854046 00000 n -0000837976 00000 n -0000854113 00000 n -0000838097 00000 n -0000854180 00000 n -0000838218 00000 n -0000854247 00000 n -0000838339 00000 n -0000854314 00000 n -0000838460 00000 n -0000854381 00000 n -0000838581 00000 n -0000854448 00000 n -0000838702 00000 n -0000854515 00000 n -0000838823 00000 n -0000854582 00000 n -0000838944 00000 n -0000854649 00000 n -0000854730 00000 n -0000839068 00000 n -0000854811 00000 n -0000854892 00000 n -0000839194 00000 n -0000854973 00000 n -0000855054 00000 n -0000839319 00000 n -0000855135 00000 n -0000855216 00000 n -0000839443 00000 n -0000855297 00000 n -0000855378 00000 n -0000839567 00000 n -0000855459 00000 n -0000855540 00000 n -0000839686 00000 n -0000855621 00000 n -0000855702 00000 n -0000839805 00000 n -0000855782 00000 n -0000855863 00000 n -0000839924 00000 n -0000855943 00000 n -0000856024 00000 n -0000840043 00000 n -0000856104 00000 n -0000856185 00000 n -0000856280 00000 n -0000840170 00000 n -0000856361 00000 n -0000856442 00000 n -0000856537 00000 n -0000840297 00000 n -0000856618 00000 n -0000856698 00000 n -0000840416 00000 n -0000856779 00000 n -0000856859 00000 n -0000840535 00000 n -0000856940 00000 n -0000857020 00000 n -0000840654 00000 n -0000857101 00000 n -0000857181 00000 n -0000840773 00000 n -0000857262 00000 n -0000857343 00000 n -0000840895 00000 n -0000857424 00000 n -0000857505 00000 n -0000841017 00000 n -0000857586 00000 n -0000857667 00000 n -0000841136 00000 n -0000857748 00000 n -0000857829 00000 n -0000841255 00000 n -0000857910 00000 n -0000857991 00000 n -0000841376 00000 n -0000858072 00000 n -0000858153 00000 n -0000733801 00000 n -0000733864 00000 n -0000734298 00000 n -0000734361 00000 n -0000734796 00000 n -0000734859 00000 n -0000735270 00000 n -0000735333 00000 n -0000735791 00000 n -0000735854 00000 n -0000736301 00000 n -0000736364 00000 n -0000736797 00000 n -0000736860 00000 n -0000737281 00000 n -0000737344 00000 n -0000737778 00000 n -0000737841 00000 n -0000738299 00000 n -0000738362 00000 n -0000738821 00000 n -0000738884 00000 n -0000739302 00000 n -0000739365 00000 n -0000739812 00000 n -0000739875 00000 n -0000740320 00000 n -0000740383 00000 n -0000740806 00000 n -0000740869 00000 n -0000741278 00000 n -0000741341 00000 n -0000741788 00000 n -0000741851 00000 n -0000742259 00000 n -0000742322 00000 n -0000742792 00000 n -0000742855 00000 n -0000743277 00000 n -0000743340 00000 n -0000743745 00000 n -0000743808 00000 n -0000744237 00000 n -0000744300 00000 n -0000744718 00000 n -0000744781 00000 n -0000745200 00000 n -0000745263 00000 n -0000745696 00000 n -0000745759 00000 n -0000746167 00000 n -0000746230 00000 n -0000746664 00000 n -0000746727 00000 n -0000747134 00000 n -0000747197 00000 n -0000747615 00000 n -0000747678 00000 n -0000748108 00000 n -0000748171 00000 n -0000748593 00000 n -0000748656 00000 n -0000749064 00000 n -0000749127 00000 n -0000749534 00000 n -0000749597 00000 n -0000750043 00000 n -0000750106 00000 n -0000750563 00000 n -0000750626 00000 n -0000751061 00000 n -0000751124 00000 n -0000751571 00000 n -0000751634 00000 n -0000752103 00000 n -0000752166 00000 n -0000752599 00000 n -0000752662 00000 n -0000753057 00000 n -0000753120 00000 n -0000753578 00000 n -0000753641 00000 n -0000754087 00000 n -0000754150 00000 n -0000754596 00000 n -0000754659 00000 n -0000755081 00000 n -0000755144 00000 n -0000755555 00000 n -0000755618 00000 n -0000756029 00000 n -0000756092 00000 n -0000756525 00000 n -0000756588 00000 n -0000756985 00000 n -0000757048 00000 n -0000757479 00000 n -0000757542 00000 n -0000757953 00000 n -0000758016 00000 n -0000758475 00000 n -0000758538 00000 n -0000758983 00000 n -0000759046 00000 n -0000759457 00000 n -0000759520 00000 n -0000759930 00000 n -0000759993 00000 n -0000760440 00000 n -0000760503 00000 n -0000760900 00000 n -0000760963 00000 n -0000761384 00000 n -0000761447 00000 n -0000761903 00000 n -0000761966 00000 n -0000762375 00000 n -0000762438 00000 n -0000762871 00000 n -0000762934 00000 n -0000763331 00000 n -0000763394 00000 n -0000763815 00000 n -0000763878 00000 n -0000764288 00000 n -0000764351 00000 n -0000764746 00000 n -0000764809 00000 n -0000765231 00000 n -0000765294 00000 n -0000765689 00000 n -0000765752 00000 n -0000766199 00000 n -0000766262 00000 n -0000766721 00000 n -0000766784 00000 n -0000767181 00000 n -0000767244 00000 n -0000767703 00000 n -0000767766 00000 n -0000768213 00000 n -0000768276 00000 n -0000768709 00000 n -0000768772 00000 n -0000769181 00000 n -0000769244 00000 n -0000769675 00000 n -0000769738 00000 n -0000770182 00000 n -0000770245 00000 n -0000770664 00000 n -0000770727 00000 n -0000771160 00000 n -0000771223 00000 n -0000771642 00000 n -0000771705 00000 n -0000772152 00000 n -0000772215 00000 n -0000772612 00000 n -0000772675 00000 n -0000773098 00000 n -0000773161 00000 n -0000773570 00000 n -0000773633 00000 n -0000774068 00000 n -0000774131 00000 n -0000774552 00000 n -0000774615 00000 n -0000775074 00000 n -0000775137 00000 n -0000775570 00000 n -0000775633 00000 n -0000776068 00000 n -0000776131 00000 n -0000776564 00000 n -0000776627 00000 n -0000777074 00000 n -0000777137 00000 n -0000777534 00000 n -0000777597 00000 n -0000778020 00000 n -0000778083 00000 n -0000778492 00000 n -0000778555 00000 n -0000778990 00000 n -0000779053 00000 n -0000779474 00000 n -0000779537 00000 n -0000779984 00000 n -0000780047 00000 n -0000780480 00000 n -0000780543 00000 n -0000780935 00000 n -0000780998 00000 n -0000781419 00000 n -0000781482 00000 n -0000781926 00000 n -0000781989 00000 n -0000782410 00000 n -0000782473 00000 n -0000782880 00000 n -0000782943 00000 n -0000783338 00000 n -0000783401 00000 n -0000783822 00000 n -0000783885 00000 n -0000784292 00000 n -0000784355 00000 n -0000784750 00000 n -0000784813 00000 n -0000785246 00000 n -0000785309 00000 n -0000785725 00000 n -0000785788 00000 n -0000786233 00000 n -0000786296 00000 n -0000786688 00000 n -0000786751 00000 n -0000787146 00000 n -0000787209 00000 n -0000787622 00000 n -0000787685 00000 n -0000788089 00000 n -0000788152 00000 n -0000788594 00000 n -0000788657 00000 n -0000789073 00000 n -0000789136 00000 n -0000789590 00000 n -0000789653 00000 n -0000790042 00000 n -0000790105 00000 n -0000790538 00000 n -0000790601 00000 n -0000791020 00000 n -0000791083 00000 n -0000791484 00000 n -0000791547 00000 n -0000791979 00000 n -0000792042 00000 n -0000792477 00000 n -0000792540 00000 n -0000792974 00000 n -0000793037 00000 n -0000793472 00000 n -0000793535 00000 n -0000793932 00000 n -0000793995 00000 n -0000794418 00000 n -0000794481 00000 n -0000794899 00000 n -0000794962 00000 n -0000795351 00000 n -0000795414 00000 n -0000795842 00000 n -0000795905 00000 n -0000796323 00000 n -0000796386 00000 n -0000796793 00000 n -0000796856 00000 n -0000797302 00000 n -0000797365 00000 n -0000797835 00000 n -0000797898 00000 n -0000798305 00000 n -0000798368 00000 n -0000798803 00000 n -0000798866 00000 n -0000799285 00000 n -0000799348 00000 n -0000799783 00000 n -0000799846 00000 n -0000800255 00000 n -0000800318 00000 n -0000800753 00000 n -0000800816 00000 n -0000801263 00000 n -0000801326 00000 n -0000801809 00000 n -0000801872 00000 n -0000802300 00000 n -0000802363 00000 n -0000802759 00000 n -0000802822 00000 n -0000803286 00000 n -0000803349 00000 n -0000803796 00000 n -0000803859 00000 n -0000804306 00000 n -0000804369 00000 n -0000804790 00000 n -0000804853 00000 n -0000805286 00000 n -0000805349 00000 n -0000805774 00000 n -0000805837 00000 n -0000806226 00000 n -0000806289 00000 n -0000806715 00000 n -0000806778 00000 n -0000807209 00000 n -0000807272 00000 n -0000807691 00000 n -0000807754 00000 n -0000808225 00000 n -0000808288 00000 n -0000808723 00000 n -0000808786 00000 n -0000809206 00000 n -0000809269 00000 n -0000809716 00000 n -0000809779 00000 n -0000810238 00000 n -0000810301 00000 n -0000810735 00000 n -0000810798 00000 n -0000811228 00000 n -0000811291 00000 n -0000811689 00000 n -0000811752 00000 n -0000812194 00000 n -0000812257 00000 n -0000812691 00000 n -0000812754 00000 n -0000813172 00000 n -0000813235 00000 n -0000813657 00000 n -0000813720 00000 n -0000814126 00000 n -0000814189 00000 n -0000814599 00000 n -0000814662 00000 n -0000815104 00000 n -0000815167 00000 n -0000815588 00000 n -0000815651 00000 n -0000816086 00000 n -0000816149 00000 n -0000816594 00000 n -0000816657 00000 n -0000817080 00000 n -0000817143 00000 n -0000817576 00000 n -0000817639 00000 n -0000818050 00000 n -0000818113 00000 n -0000818534 00000 n -0000818597 00000 n -0000819044 00000 n -0000819107 00000 n -0000819541 00000 n -0000819604 00000 n -0000820010 00000 n -0000820073 00000 n -0000820504 00000 n -0000820567 00000 n -0000821001 00000 n -0000821064 00000 n -0000821470 00000 n -0000821533 00000 n -0000821939 00000 n -0000822002 00000 n -0000822461 00000 n -0000822524 00000 n -0000822957 00000 n -0000823020 00000 n -0000823467 00000 n -0000823530 00000 n -0000823927 00000 n -0000823990 00000 n -0000824425 00000 n -0000824488 00000 n -0000824897 00000 n -0000824960 00000 n -0000825431 00000 n -0000825494 00000 n -0000825915 00000 n -0000825978 00000 n -0000826385 00000 n -0000826448 00000 n -0000826843 00000 n -0000826906 00000 n -0000827325 00000 n -0000827388 00000 n -0000827783 00000 n -0000827846 00000 n -0000828267 00000 n -0000828330 00000 n -0000828799 00000 n -0000828862 00000 n -0000829283 00000 n -0000829346 00000 n -0000829779 00000 n -0000829842 00000 n -0000830259 00000 n -0000830322 00000 n -0000830774 00000 n -0000830837 00000 n -0000831266 00000 n -0000831329 00000 n -0000858234 00000 n -0000858321 00000 n +0000864628 00000 n +0000864708 00000 n +0000864803 00000 n +0000848916 00000 n +0000864884 00000 n +0000864965 00000 n +0000849038 00000 n +0000865046 00000 n +0000865127 00000 n +0000849161 00000 n +0000865208 00000 n +0000865289 00000 n +0000865384 00000 n +0000865478 00000 n +0000865573 00000 n +0000865667 00000 n +0000865762 00000 n +0000865857 00000 n +0000865952 00000 n +0000866047 00000 n +0000866142 00000 n +0000866237 00000 n +0000866332 00000 n +0000866427 00000 n +0000866522 00000 n +0000866617 00000 n +0000866712 00000 n +0000849281 00000 n +0000866793 00000 n +0000866874 00000 n +0000866969 00000 n +0000867064 00000 n +0000849400 00000 n +0000867145 00000 n +0000849521 00000 n +0000867212 00000 n +0000849642 00000 n +0000867279 00000 n +0000849763 00000 n +0000867346 00000 n +0000849884 00000 n +0000867413 00000 n +0000850005 00000 n +0000867480 00000 n +0000850126 00000 n +0000867547 00000 n +0000850247 00000 n +0000867614 00000 n +0000850368 00000 n +0000867681 00000 n +0000850489 00000 n +0000867748 00000 n +0000850610 00000 n +0000867815 00000 n +0000850731 00000 n +0000867882 00000 n +0000850852 00000 n +0000867949 00000 n +0000850973 00000 n +0000868016 00000 n +0000851094 00000 n +0000868083 00000 n +0000851215 00000 n +0000868150 00000 n +0000851336 00000 n +0000868217 00000 n +0000851457 00000 n +0000868284 00000 n +0000868365 00000 n +0000851581 00000 n +0000868446 00000 n +0000868527 00000 n +0000851706 00000 n +0000868608 00000 n +0000868689 00000 n +0000851830 00000 n +0000868770 00000 n +0000868851 00000 n +0000851956 00000 n +0000868932 00000 n +0000869013 00000 n +0000852080 00000 n +0000869094 00000 n +0000869175 00000 n +0000852199 00000 n +0000869256 00000 n +0000869337 00000 n +0000852318 00000 n +0000869417 00000 n +0000869498 00000 n +0000852437 00000 n +0000869578 00000 n +0000869659 00000 n +0000852556 00000 n +0000869739 00000 n +0000869820 00000 n +0000869915 00000 n +0000852683 00000 n +0000869996 00000 n +0000870077 00000 n +0000870172 00000 n +0000852810 00000 n +0000870253 00000 n +0000870333 00000 n +0000852929 00000 n +0000870414 00000 n +0000870494 00000 n +0000853048 00000 n +0000870575 00000 n +0000870655 00000 n +0000853167 00000 n +0000870736 00000 n +0000870816 00000 n +0000853286 00000 n +0000870897 00000 n +0000870978 00000 n +0000853408 00000 n +0000871059 00000 n +0000871140 00000 n +0000853530 00000 n +0000871221 00000 n +0000871302 00000 n +0000853649 00000 n +0000871383 00000 n +0000871464 00000 n +0000853768 00000 n +0000871545 00000 n +0000871626 00000 n +0000853889 00000 n +0000871707 00000 n +0000871788 00000 n +0000740093 00000 n +0000740156 00000 n +0000740577 00000 n +0000740640 00000 n +0000741047 00000 n +0000741110 00000 n +0000741505 00000 n +0000741568 00000 n +0000741989 00000 n +0000742052 00000 n +0000742459 00000 n +0000742522 00000 n +0000742917 00000 n +0000742980 00000 n +0000743402 00000 n +0000743465 00000 n +0000743900 00000 n +0000743963 00000 n +0000744374 00000 n +0000744437 00000 n +0000744895 00000 n +0000744958 00000 n +0000745405 00000 n +0000745468 00000 n +0000745901 00000 n +0000745964 00000 n +0000746385 00000 n +0000746448 00000 n +0000746882 00000 n +0000746945 00000 n +0000747403 00000 n +0000747466 00000 n +0000747925 00000 n +0000747988 00000 n +0000748411 00000 n +0000748474 00000 n +0000748921 00000 n +0000748984 00000 n +0000749443 00000 n +0000749506 00000 n +0000749929 00000 n +0000749992 00000 n +0000750401 00000 n +0000750464 00000 n +0000750911 00000 n +0000750974 00000 n +0000751382 00000 n +0000751445 00000 n +0000751915 00000 n +0000751978 00000 n +0000752400 00000 n +0000752463 00000 n +0000752868 00000 n +0000752931 00000 n +0000753360 00000 n +0000753423 00000 n +0000753841 00000 n +0000753904 00000 n +0000754323 00000 n +0000754386 00000 n +0000754819 00000 n +0000754882 00000 n +0000755290 00000 n +0000755353 00000 n +0000755787 00000 n +0000755850 00000 n +0000756257 00000 n +0000756320 00000 n +0000756738 00000 n +0000756801 00000 n +0000757231 00000 n +0000757294 00000 n +0000757716 00000 n +0000757779 00000 n +0000758187 00000 n +0000758250 00000 n +0000758657 00000 n +0000758720 00000 n +0000759166 00000 n +0000759229 00000 n +0000759687 00000 n +0000759750 00000 n +0000760197 00000 n +0000760260 00000 n +0000760702 00000 n +0000760765 00000 n +0000761182 00000 n +0000761245 00000 n +0000761702 00000 n +0000761765 00000 n +0000762200 00000 n +0000762263 00000 n +0000762710 00000 n +0000762773 00000 n +0000763242 00000 n +0000763305 00000 n +0000763738 00000 n +0000763801 00000 n +0000764196 00000 n +0000764259 00000 n +0000764717 00000 n +0000764780 00000 n +0000765226 00000 n +0000765289 00000 n +0000765735 00000 n +0000765798 00000 n +0000766245 00000 n +0000766308 00000 n +0000766719 00000 n +0000766782 00000 n +0000767193 00000 n +0000767256 00000 n +0000767690 00000 n +0000767753 00000 n +0000768186 00000 n +0000768249 00000 n +0000768680 00000 n +0000768743 00000 n +0000769154 00000 n +0000769217 00000 n +0000769676 00000 n +0000769739 00000 n +0000770161 00000 n +0000770224 00000 n +0000770670 00000 n +0000770733 00000 n +0000771179 00000 n +0000771242 00000 n +0000771689 00000 n +0000771752 00000 n +0000772186 00000 n +0000772249 00000 n +0000772695 00000 n +0000772758 00000 n +0000773205 00000 n +0000773268 00000 n +0000773703 00000 n +0000773766 00000 n +0000774199 00000 n +0000774262 00000 n +0000774678 00000 n +0000774741 00000 n +0000775186 00000 n +0000775249 00000 n +0000775641 00000 n +0000775704 00000 n +0000776099 00000 n +0000776162 00000 n +0000776575 00000 n +0000776638 00000 n +0000777042 00000 n +0000777105 00000 n +0000777547 00000 n +0000777610 00000 n +0000778026 00000 n +0000778089 00000 n +0000778543 00000 n +0000778606 00000 n +0000778995 00000 n +0000779058 00000 n +0000779491 00000 n +0000779554 00000 n +0000779987 00000 n +0000780050 00000 n +0000780442 00000 n +0000780505 00000 n +0000780924 00000 n +0000780987 00000 n +0000781388 00000 n +0000781451 00000 n +0000781910 00000 n +0000781973 00000 n +0000782420 00000 n +0000782483 00000 n +0000782916 00000 n +0000782979 00000 n +0000783426 00000 n +0000783489 00000 n +0000783948 00000 n +0000784011 00000 n +0000784408 00000 n +0000784471 00000 n +0000784892 00000 n +0000784955 00000 n +0000785402 00000 n +0000785465 00000 n +0000785887 00000 n +0000785950 00000 n +0000786345 00000 n +0000786408 00000 n +0000786818 00000 n +0000786881 00000 n +0000787276 00000 n +0000787339 00000 n +0000787783 00000 n +0000787846 00000 n +0000788265 00000 n +0000788328 00000 n +0000788737 00000 n +0000788800 00000 n +0000789231 00000 n +0000789294 00000 n +0000789727 00000 n +0000789790 00000 n +0000790209 00000 n +0000790272 00000 n +0000790719 00000 n +0000790782 00000 n +0000791203 00000 n +0000791266 00000 n +0000791713 00000 n +0000791776 00000 n +0000792185 00000 n +0000792248 00000 n +0000792683 00000 n +0000792746 00000 n +0000793143 00000 n +0000793206 00000 n +0000793629 00000 n +0000793692 00000 n +0000794125 00000 n +0000794188 00000 n +0000794635 00000 n +0000794698 00000 n +0000795131 00000 n +0000795194 00000 n +0000795629 00000 n +0000795692 00000 n +0000796113 00000 n +0000796176 00000 n +0000796635 00000 n +0000796698 00000 n +0000797107 00000 n +0000797170 00000 n +0000797605 00000 n +0000797668 00000 n +0000798065 00000 n +0000798128 00000 n +0000798551 00000 n +0000798614 00000 n +0000799061 00000 n +0000799124 00000 n +0000799521 00000 n +0000799584 00000 n +0000800005 00000 n +0000800068 00000 n +0000800524 00000 n +0000800587 00000 n +0000801032 00000 n +0000801095 00000 n +0000801506 00000 n +0000801569 00000 n +0000802003 00000 n +0000802066 00000 n +0000802475 00000 n +0000802538 00000 n +0000802971 00000 n +0000803034 00000 n +0000803431 00000 n +0000803494 00000 n +0000803915 00000 n +0000803978 00000 n +0000804410 00000 n +0000804473 00000 n +0000804908 00000 n +0000804971 00000 n +0000805405 00000 n +0000805468 00000 n +0000805903 00000 n +0000805966 00000 n +0000806363 00000 n +0000806426 00000 n +0000806849 00000 n +0000806912 00000 n +0000807330 00000 n +0000807393 00000 n +0000807782 00000 n +0000807845 00000 n +0000808273 00000 n +0000808336 00000 n +0000808754 00000 n +0000808817 00000 n +0000809224 00000 n +0000809287 00000 n +0000809733 00000 n +0000809796 00000 n +0000810266 00000 n +0000810329 00000 n +0000810736 00000 n +0000810799 00000 n +0000811234 00000 n +0000811297 00000 n +0000811728 00000 n +0000811791 00000 n +0000812226 00000 n +0000812289 00000 n +0000812698 00000 n +0000812761 00000 n +0000813231 00000 n +0000813294 00000 n +0000813741 00000 n +0000813804 00000 n +0000814287 00000 n +0000814350 00000 n +0000814778 00000 n +0000814841 00000 n +0000815237 00000 n +0000815300 00000 n +0000815764 00000 n +0000815827 00000 n +0000816309 00000 n +0000816372 00000 n +0000816819 00000 n +0000816882 00000 n +0000817303 00000 n +0000817366 00000 n +0000817799 00000 n +0000817862 00000 n +0000818287 00000 n +0000818350 00000 n +0000818739 00000 n +0000818802 00000 n +0000819228 00000 n +0000819291 00000 n +0000819722 00000 n +0000819785 00000 n +0000820204 00000 n +0000820267 00000 n +0000820738 00000 n +0000820801 00000 n +0000821236 00000 n +0000821299 00000 n +0000821731 00000 n +0000821794 00000 n +0000822241 00000 n +0000822304 00000 n +0000822762 00000 n +0000822825 00000 n +0000823235 00000 n +0000823298 00000 n +0000823740 00000 n +0000823803 00000 n +0000824237 00000 n +0000824300 00000 n +0000824718 00000 n +0000824781 00000 n +0000825215 00000 n +0000825278 00000 n +0000825708 00000 n +0000825771 00000 n +0000826169 00000 n +0000826232 00000 n +0000826674 00000 n +0000826737 00000 n +0000827159 00000 n +0000827222 00000 n +0000827628 00000 n +0000827691 00000 n +0000828112 00000 n +0000828175 00000 n +0000828610 00000 n +0000828673 00000 n +0000829118 00000 n +0000829181 00000 n +0000829604 00000 n +0000829667 00000 n +0000830100 00000 n +0000830163 00000 n +0000830574 00000 n +0000830637 00000 n +0000831058 00000 n +0000831121 00000 n +0000831568 00000 n +0000831631 00000 n +0000832065 00000 n +0000832128 00000 n +0000832534 00000 n +0000832597 00000 n +0000833028 00000 n +0000833091 00000 n +0000833525 00000 n +0000833588 00000 n +0000833994 00000 n +0000834057 00000 n +0000834463 00000 n +0000834526 00000 n +0000834985 00000 n +0000835048 00000 n +0000835481 00000 n +0000835544 00000 n +0000835991 00000 n +0000836054 00000 n +0000836451 00000 n +0000836514 00000 n +0000836949 00000 n +0000837012 00000 n +0000837421 00000 n +0000837484 00000 n +0000837955 00000 n +0000838018 00000 n +0000838439 00000 n +0000838502 00000 n +0000838909 00000 n +0000838972 00000 n +0000839367 00000 n +0000839430 00000 n +0000839849 00000 n +0000839912 00000 n +0000840307 00000 n +0000840370 00000 n +0000840791 00000 n +0000840854 00000 n +0000841323 00000 n +0000841386 00000 n +0000841807 00000 n +0000841870 00000 n +0000842281 00000 n +0000842344 00000 n +0000842761 00000 n +0000842824 00000 n +0000843276 00000 n +0000843339 00000 n +0000843768 00000 n +0000843831 00000 n +0000871869 00000 n +0000871956 00000 n trailer << -/Size 707 -/Root 706 0 R -/Info 705 0 R -/ID [ <0436E599AB72278414925D1D7EBB6D95> <0436E599AB72278414925D1D7EBB6D95> ] +/Size 743 +/Root 742 0 R +/Info 741 0 R +/ID [ <45D5E5147DDDDD5DF4CA7748D53B7629> <45D5E5147DDDDD5DF4CA7748D53B7629> ] >> startxref -858443 +872078 %%EOF diff --git a/docs/backlight-pwm-brightness-control.zh-CN.md b/docs/backlight-pwm-brightness-control.zh-CN.md new file mode 100644 index 0000000..2d47bd5 --- /dev/null +++ b/docs/backlight-pwm-brightness-control.zh-CN.md @@ -0,0 +1,268 @@ +# LCD 背光 PWM 亮度控制实现说明 + +## 1. 问题背景与原因 + +原有 LCD 背光使用 GPIO7 作为普通数字输出,初始化时仅根据背光有效电平写入高电平或低电平: + +```c +gpio_set_level(CONFIG_TQ_SCREEN_BACKLIGHT_GPIO, + CONFIG_TQ_SCREEN_BACKLIGHT_ACTIVE_HIGH ? 1 : 0); +``` + +这种实现只能控制背光完全开启或完全关闭,不能设置中间亮度。设备进入轻待机后,即使减少 LCD 图片刷新,背光仍保持全亮,屏幕主要功耗无法有效降低。 + +项目配置中原先只有背光 GPIO 和有效电平配置,也没有可以通过 `sdkconfig` 设置默认亮度的选项。 + +## 2. 解决方式 + +本次将 GPIO7 背光控制从普通 GPIO 输出改为 ESP32-S3 LEDC PWM 输出,并增加以下能力: + +1. PWM 频率固定为 20 kHz,避免肉眼可见闪烁,并降低进入可闻频段的风险。 +2. 使用 10 bit PWM 分辨率,内部占空比范围为 0~1023。 +3. 支持 0~100 的亮度百分比。 +4. 兼容高电平有效和低电平有效两种背光电路。 +5. 增加 Kconfig 配置,可以通过 `sdkconfig` 设置开机默认亮度。 +6. 增加运行时亮度接口,为后续轻待机降亮度和唤醒恢复亮度提供基础能力。 +7. 使用独立互斥锁串行化运行时亮度更新,避免并发修改 LEDC 占空比。 + +## 3. 配置方式 + +新增配置项: + +```ini +CONFIG_TQ_SCREEN_BACKLIGHT_BRIGHTNESS_PERCENT=100 +``` + +取值范围: + +| 配置值 | 效果 | +| --- | --- | +| `0` | 背光关闭 | +| `30` | 约 30% PWM 占空比 | +| `50` | 约 50% PWM 占空比 | +| `100` | 背光全亮 | + +Kconfig 定义如下: + +```kconfig +config TQ_SCREEN_BACKLIGHT_BRIGHTNESS_PERCENT + int "Default backlight brightness (percent)" + default 100 + range 0 100 + depends on TQ_SCREEN_ENABLE + help + Initial LCD backlight PWM duty after screen initialization. + Set 0 to turn the backlight off or 100 for full brightness. +``` + +可以通过以下菜单修改: + +```text +Example Configuration + -> TQ Screen + -> Default backlight brightness (percent) +``` + +也可以直接修改 `sdkconfig` 中的配置值。 + +## 4. 具体代码 + +### 4.1 PWM 参数 + +```c +#define LCD_DIRECT_BACKLIGHT_PWM_FREQUENCY_HZ 20000U +#define LCD_DIRECT_BACKLIGHT_PWM_DUTY_RESOLUTION LEDC_TIMER_10_BIT +#define LCD_DIRECT_BACKLIGHT_PWM_DUTY_MAX ((1U << 10U) - 1U) +#define LCD_DIRECT_BACKLIGHT_PWM_TIMER LEDC_TIMER_0 +#define LCD_DIRECT_BACKLIGHT_PWM_CHANNEL LEDC_CHANNEL_0 +#define LCD_DIRECT_BACKLIGHT_LOCK_TIMEOUT_MS 100U +``` + +### 4.2 百分比转换为 PWM 占空比 + +```c +static uint32_t lcd_direct_backlight_duty(uint8_t brightness_percent) +{ + uint32_t duty = + ((uint32_t)brightness_percent * LCD_DIRECT_BACKLIGHT_PWM_DUTY_MAX + 50U) / 100U; +#if !CONFIG_TQ_SCREEN_BACKLIGHT_ACTIVE_HIGH + duty = LCD_DIRECT_BACKLIGHT_PWM_DUTY_MAX - duty; +#endif + return duty; +} +``` + +计算中加入 50 后再除以 100,用于百分比转换时四舍五入。低电平有效的硬件会自动反转占空比。 + +### 4.3 LEDC PWM 初始化 + +```c +static esp_err_t lcd_direct_backlight_init(void) +{ + const ledc_timer_config_t timer_config = { + .speed_mode = LEDC_LOW_SPEED_MODE, + .duty_resolution = LCD_DIRECT_BACKLIGHT_PWM_DUTY_RESOLUTION, + .timer_num = LCD_DIRECT_BACKLIGHT_PWM_TIMER, + .freq_hz = LCD_DIRECT_BACKLIGHT_PWM_FREQUENCY_HZ, + .clk_cfg = LEDC_AUTO_CLK, + }; + ESP_RETURN_ON_ERROR(ledc_timer_config(&timer_config), + TAG, + "backlight PWM timer config failed"); + + const ledc_channel_config_t channel_config = { + .gpio_num = CONFIG_TQ_SCREEN_BACKLIGHT_GPIO, + .speed_mode = LEDC_LOW_SPEED_MODE, + .channel = LCD_DIRECT_BACKLIGHT_PWM_CHANNEL, + .intr_type = LEDC_INTR_DISABLE, + .timer_sel = LCD_DIRECT_BACKLIGHT_PWM_TIMER, + .duty = lcd_direct_backlight_duty( + CONFIG_TQ_SCREEN_BACKLIGHT_BRIGHTNESS_PERCENT), + .hpoint = 0, + }; + ESP_RETURN_ON_ERROR(ledc_channel_config(&channel_config), + TAG, + "backlight PWM channel config failed"); + return ESP_OK; +} +``` + +LCD GPIO 初始化不再把背光引脚注册为普通 GPIO 输出,而是在 DC 和复位引脚初始化后调用 PWM 初始化: + +```c +static esp_err_t lcd_direct_gpio_init(void) +{ + uint64_t pin_mask = (1ULL << CONFIG_TQ_SCREEN_DC_GPIO); + if (CONFIG_TQ_SCREEN_RESET_PIN >= 0) { + pin_mask |= (1ULL << CONFIG_TQ_SCREEN_RESET_PIN); + } + + gpio_config_t gpio_config_data = { + .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, + }; + ESP_RETURN_ON_ERROR(gpio_config(&gpio_config_data), TAG, "gpio config failed"); + + gpio_set_level(CONFIG_TQ_SCREEN_DC_GPIO, 0); + if (CONFIG_TQ_SCREEN_RESET_PIN >= 0) { + gpio_set_level(CONFIG_TQ_SCREEN_RESET_PIN, 1); + } + return lcd_direct_backlight_init(); +} +``` + +### 4.4 运行时亮度接口 + +平台公共接口: + +```c +esp_err_t platform_lcd_set_backlight_brightness(uint8_t brightness_percent); +``` + +底层实现: + +```c +esp_err_t lcd_direct_set_backlight_brightness(uint8_t brightness_percent) +{ + ESP_RETURN_ON_FALSE(brightness_percent <= 100U, + ESP_ERR_INVALID_ARG, + TAG, + "backlight brightness must be between 0 and 100 percent"); + ESP_RETURN_ON_FALSE(s_initialized && s_backlight_lock != NULL, + ESP_ERR_INVALID_STATE, + TAG, + "LCD backlight is not initialized"); + ESP_RETURN_ON_FALSE( + xSemaphoreTake(s_backlight_lock, + pdMS_TO_TICKS(LCD_DIRECT_BACKLIGHT_LOCK_TIMEOUT_MS)) == pdTRUE, + ESP_ERR_TIMEOUT, + TAG, + "backlight lock timed out"); + + esp_err_t err = ledc_set_duty( + LEDC_LOW_SPEED_MODE, + LCD_DIRECT_BACKLIGHT_PWM_CHANNEL, + lcd_direct_backlight_duty(brightness_percent)); + if (err == ESP_OK) { + err = ledc_update_duty(LEDC_LOW_SPEED_MODE, + LCD_DIRECT_BACKLIGHT_PWM_CHANNEL); + } + xSemaphoreGive(s_backlight_lock); + return err; +} +``` + +后续轻待机可以调用: + +```c +platform_lcd_set_backlight_brightness(35); +``` + +唤醒后恢复默认亮度可以调用: + +```c +platform_lcd_set_backlight_brightness( + CONFIG_TQ_SCREEN_BACKLIGHT_BRIGHTNESS_PERCENT); +``` + +## 5. 解决成果 + +1. LCD 背光从开关控制升级为 0~100% PWM 亮度控制。 +2. 默认亮度可以通过 `sdkconfig` 配置,不需要修改驱动源码。 +3. 默认值保持 100%,升级后现有设备的默认显示亮度不变。 +4. 已提供运行时接口,可以直接支持后续轻待机和唤醒状态切换。 +5. 高电平有效和低电平有效配置均得到支持。 +6. 使用 ESP-IDF 5.5.4 完整编译成功。 +7. 生成固件大小为 `0x211bf0` 字节,最小应用分区仍有约 41% 空余空间。 + +对应代码提交: + +```text +988dc92 feat(platform): add PWM backlight brightness control +``` + +## 6. 后续影响与注意事项 + +### 6.1 LEDC 资源占用 + +当前实现固定使用: + +```text +LEDC low-speed timer 0 +LEDC channel 0 +``` + +项目后续增加蜂鸣器、其他 LED 调光或舵机 PWM 时,不能重复占用该定时器和通道,或者需要增加统一的 LEDC 资源分配策略。 + +### 6.2 亮度百分比不是视觉线性 + +当前配置值与 PWM 占空比线性对应,但人眼对亮度的感知不是线性的。例如 50% 占空比不一定看起来正好是全亮的一半。后续如果需要更自然的渐亮、渐暗效果,可以增加 Gamma 校正表。 + +### 6.3 需要实机确认调光范围 + +软件编译已经通过,但仍需要在真实屏幕上确认: + +1. 20 kHz 下是否存在异常噪声。 +2. 低亮度时背光是否稳定。 +3. 10%、20%、30%、50% 等档位的视觉效果和实际电流。 +4. 背光接口所在硬件是否对高频 PWM 有额外限制。 + +### 6.4 对 LCD 刷新无影响 + +PWM只控制背光供电占空比,不改变 ST7789 的 SPI 时钟、图片刷新频率、颜色数据和显存内容。调整亮度不会中断当前表情动画。 + +### 6.5 对轻待机功能的影响 + +本次只提供背光控制基础能力,尚未实现5分钟无交互计时和自动降亮度。后续轻待机逻辑需要在业务状态层调用运行时接口,并处理以下状态: + +1. 无交互5分钟后降低亮度。 +2. 唤醒词、PTT按键或抽签开关触发时恢复亮度。 +3. OTA、配网、低电量提示等特殊状态是否覆盖待机亮度。 +4. 多个状态同时请求亮度时的优先级。 + +### 6.6 现有架构检查告警 + +项目架构检查仍会报告电池任务中存在 `portMAX_DELAY` 无界等待。该告警在本次改动之前已经存在,与背光 PWM 实现无关;固件完整编译不受影响。 diff --git a/docs/ble-reprovisioning-memory-protection.zh-CN.md b/docs/ble-reprovisioning-memory-protection.zh-CN.md new file mode 100644 index 0000000..4e8e81c --- /dev/null +++ b/docs/ble-reprovisioning-memory-protection.zh-CN.md @@ -0,0 +1,426 @@ +# BLE 重新配网内存保护提交说明 + +## 一、提交信息 + +```text +提交:6495f0e1077adb8a9be15e6a3960430d9329c544 +标题:fix(connectivity): harden BLE reprovisioning under memory pressure +``` + +## 二、问题原因 + +设备运行期间 Wi-Fi 断开且候选网络重连失败后,会自动切换到小程序 BLE 配网。 +问题发生时,音频、本地语音命令、WebSocket、LCD 等运行时资源已经运行较长时间, +内部 SRAM 和 DMA 内存存在占用及碎片化。 + +BLE 控制器虽然配置为让 NimBLE Host 优先使用 PSRAM,但控制器底层仍需要 +`INTERNAL + DMA` 属性的连续内存。故障日志显示: + +```text +Entering mini-program BLE reprovisioning: reason=wifi_reconnect_failed +E BLE_INIT: Malloc failed +BLE assert emi.c 164, param 00000000 00001000 +Guru Meditation Error: Core 0 panic'ed (Interrupt wdt timeout on CPU0) +``` + +实际故障链路为: + +```text +Wi-Fi 重连失败 + -> 运行态直接初始化 BLE + -> BLE 控制器内部连续 DMA 内存申请失败 + -> BLE ROM 断言 + -> CPU0 卡在断言路径 + -> 中断看门狗超时 + -> Panic 后软件重启 +``` + +此外,原流程先创建 4096 字节配网任务,再初始化 BLE。该任务会提前占用一块内部 +内存,进一步降低 BLE 控制器初始化时的连续内存余量。 + +在首次修复后,BLE 已能正常启动并广播,但设备会静默等待小程序连接。如果同时停止 +LCD 和其他业务任务,用户会误认为设备已经卡死。因此最终提交还补充了配网二维码和 +周期性等待日志。 + +## 三、解决方式 + +### 3.1 进入 BLE 前收敛运行时资源 + +运行时重新配网开始前执行以下操作: + +1. 打开运行时停止门,阻止新业务继续启动。 +2. 清除 PTT 活动状态。 +3. 有界停止本地唤醒及其录音路径。 +4. 停止多模态客户端和 WebSocket。 +5. 停止下行播放并完整释放音频管线。 +6. 将 LCD 切换到配网二维码界面。 +7. 停止 Wi-Fi STA。 + +### 3.2 BLE 初始化前增加内存门槛 + +读取以下三项数据: + +- 内部内存总空闲量。 +- 内部内存最大连续块。 +- DMA 内存最大连续块。 + +只有满足以下默认门槛才允许启动 BLE: + +```text +free_internal >= 49152 +largest_dma >= 12288 +``` + +这些门槛在 Kconfig 中可调整。 + +### 3.3 调整 BLE 和配网任务启动顺序 + +新顺序为: + +```text +创建队列和事件组 + -> 检查内存 + -> 初始化 BLE/NimBLE 并开始广播 + -> 创建 4096 字节配网任务 +``` + +BLE 优先获得所需的内部连续内存。若 BLE 已启动但任务创建失败,会停止 BLE 并回收 +资源。 + +### 3.4 内存不足时执行轻量恢复启动 + +运行时释放资源后仍不满足门槛时,不再冒险调用 BLE,而是: + +```text +写入 RTC no-init 一次性标志 + -> 等待 100 ms + -> 软件重启 + -> 下一次启动消费标志 + -> 跳过高内存启动项 + -> 直接进入 BLE 配网 +``` + +RTC 标志不写 Flash,不产生 NVS 擦写损耗。标志由固定 Magic 和按位取反值共同校验, +降低随机 RTC 数据被误识别的可能性。 + +恢复启动阶段保留电池安全服务,但延后启动音、LCD 渲染任务、Factory CLI 和转盘 +校准。BLE 初始化成功后再启动 LCD 显示配网界面。 + +### 3.5 防止恢复启动循环 + +RTC 标志只消费一次。若轻量恢复启动中的 BLE 仍启动失败,会清除恢复模式并继续正常 +启动,不会持续自动重启。 + +### 3.6 增加可见等待状态 + +运行时切换 BLE 时保留 LCD,并显示配网二维码。配网任务每 10 秒打印: + +```text +BLE provisioning active; waiting for mini-program credentials +``` + +这样可以区分“正常等待小程序连接”和“设备线程卡死”。 + +## 四、具体代码 + +### 4.1 内存门槛配置 + +```text +config TQ_MINI_PROGRAM_PROVISIONING_MIN_FREE_INTERNAL_BYTES + int "Minimum free internal memory before BLE provisioning" + depends on TQ_MINI_PROGRAM_PROVISIONING_ENABLE + default 49152 + range 16384 131072 + +config TQ_MINI_PROGRAM_PROVISIONING_MIN_LARGEST_DMA_BYTES + int "Minimum largest DMA block before BLE provisioning" + depends on TQ_MINI_PROGRAM_PROVISIONING_ENABLE + default 12288 + range 4096 65536 +``` + +默认配置: + +```text +CONFIG_TQ_MINI_PROGRAM_PROVISIONING_MIN_FREE_INTERNAL_BYTES=49152 +CONFIG_TQ_MINI_PROGRAM_PROVISIONING_MIN_LARGEST_DMA_BYTES=12288 +``` + +### 4.2 RTC 一次性恢复标志 + +```c +#define CONTROL_PLANE_PROVISIONING_RECOVERY_MAGIC 0x5451424cU + +static bool s_provisioning_recovery_boot; +RTC_NOINIT_ATTR static uint32_t s_provisioning_recovery_marker; +RTC_NOINIT_ATTR static uint32_t s_provisioning_recovery_marker_inverse; + +bool control_plane_take_mini_program_recovery_boot_request(void) +{ + bool requested = + s_provisioning_recovery_marker == + CONTROL_PLANE_PROVISIONING_RECOVERY_MAGIC && + s_provisioning_recovery_marker_inverse == + ~CONTROL_PLANE_PROVISIONING_RECOVERY_MAGIC; + s_provisioning_recovery_marker = 0U; + s_provisioning_recovery_marker_inverse = 0U; + s_provisioning_recovery_boot = requested; + if (requested) { + ESP_LOGW(TAG, "Consumed one-shot low-memory BLE recovery boot request"); + } + return requested; +} +``` + +### 4.3 记录标志并受控重启 + +```c +static void control_plane_request_provisioning_recovery_restart( + const domain_ota_memory_headroom_t *headroom) +{ + s_provisioning_recovery_marker = + CONTROL_PLANE_PROVISIONING_RECOVERY_MAGIC; + s_provisioning_recovery_marker_inverse = + ~CONTROL_PLANE_PROVISIONING_RECOVERY_MAGIC; + ESP_LOGW(TAG, + "Restarting into low-memory BLE recovery boot: " + "free_internal=%u largest_internal=%u largest_dma=%u", + headroom != NULL ? (unsigned)headroom->free_internal : 0U, + headroom != NULL ? (unsigned)headroom->largest_internal : 0U, + headroom != NULL ? (unsigned)headroom->largest_dma : 0U); + vTaskDelay(pdMS_TO_TICKS(CONTROL_PLANE_PROVISIONING_RESTART_DELAY_MS)); + domain_runtime_restart(); +} +``` + +### 4.4 BLE 启动前内存检查 + +```c +static esp_err_t control_plane_check_provisioning_memory_headroom(void) +{ + domain_ota_memory_headroom_t headroom = {0}; + esp_err_t err = domain_runtime_ota_get_memory_headroom(&headroom); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Read BLE provisioning memory headroom failed: %s", + esp_err_to_name(err)); + return err; + } + + ESP_LOGI(TAG, + "BLE provisioning memory headroom: " + "free_internal=%u largest_internal=%u largest_dma=%u", + (unsigned)headroom.free_internal, + (unsigned)headroom.largest_internal, + (unsigned)headroom.largest_dma); + + if (headroom.free_internal >= + CONFIG_TQ_MINI_PROGRAM_PROVISIONING_MIN_FREE_INTERNAL_BYTES && + headroom.largest_dma >= + CONFIG_TQ_MINI_PROGRAM_PROVISIONING_MIN_LARGEST_DMA_BYTES) { + return ESP_OK; + } + + ESP_LOGE(TAG, + "BLE provisioning memory headroom insufficient: " + "need free_internal>=%u largest_dma>=%u", + (unsigned)CONFIG_TQ_MINI_PROGRAM_PROVISIONING_MIN_FREE_INTERNAL_BYTES, + (unsigned)CONFIG_TQ_MINI_PROGRAM_PROVISIONING_MIN_LARGEST_DMA_BYTES); + if (!s_provisioning_recovery_boot) { + control_plane_request_provisioning_recovery_restart(&headroom); + } + return ESP_ERR_NO_MEM; +} +``` + +### 4.5 进入 BLE 前释放资源 + +```c +esp_err_t control_plane_enter_mini_program_reprovisioning(const char *reason) +{ + if (control_plane_is_mini_program_provisioning_running()) { + return ESP_OK; + } + + ESP_LOGW(TAG, "Entering mini-program BLE reprovisioning: reason=%s", + reason != NULL ? reason : "unspecified"); + control_plane_wifi_set_keep_awake(true); + control_plane_set_runtime_stop_requested(true); + control_plane_set_ptt_active(false); + (void)control_plane_local_wake_stop( + CONTROL_PLANE_PROVISIONING_RUNTIME_QUIESCE_MS); + (void)control_plane_stop_multimodal_client(); + (void)domain_runtime_multimodal_audio_stop_downstream_playback(); + domain_runtime_multimodal_audio_deinit(); + control_plane_show_provisioning_ui(); + + esp_err_t err = domain_runtime_wifi_portal_stop_sta(); + if (err == ESP_OK) { + err = control_plane_start_mini_program_provisioning(); + } + if (err != ESP_OK) { + control_plane_wifi_set_keep_awake(false); + if (!s_provisioning_recovery_boot) { + control_plane_request_provisioning_recovery_restart(NULL); + } + } + return err; +} +``` + +### 4.6 BLE 优先于配网任务启动 + +```c +esp_err_t err = control_plane_check_provisioning_memory_headroom(); +if (err != ESP_OK) { + return err; +} + +err = domain_runtime_mini_program_provisioning_start( + control_plane_provisioning_event_callback, + NULL); +if (err != ESP_OK) { + return err; +} + +BaseType_t created = xTaskCreate(control_plane_provisioning_worker, + "mini_prog_wifi", + CONTROL_PLANE_PROVISIONING_TASK_STACK_BYTES, + NULL, + CONTROL_PLANE_PROVISIONING_TASK_PRIORITY, + &s_provisioning_task); +if (created != pdPASS) { + s_provisioning_task = NULL; + ESP_LOGE(TAG, + "Create mini-program provisioning worker failed after BLE start"); + (void)domain_runtime_mini_program_provisioning_stop( + CONTROL_PLANE_PROVISIONING_TRANSPORT_STOP_MS); + return ESP_ERR_NO_MEM; +} +``` + +### 4.7 配网等待心跳 + +```c +if (xQueueReceive(s_provisioning_queue, + &event, + pdMS_TO_TICKS(CONTROL_PLANE_PROVISIONING_POLL_MS)) != pdTRUE) { + TickType_t now_tick = xTaskGetTickCount(); + if ((now_tick - last_wait_log_tick) >= wait_log_ticks) { + ESP_LOGI(TAG, + "BLE provisioning active; waiting for mini-program credentials"); + last_wait_log_tick = now_tick; + } + continue; +} +``` + +### 4.8 恢复启动跳过高内存步骤 + +```c +bool provisioning_recovery_boot = + control_plane_take_mini_program_recovery_boot_request(); + +if (provisioning_recovery_boot) { + app_composition_log_stage_skipped(APP_COMPOSITION_BOOT_STAGE_AUDIO_INIT, + "audio_init", + "ble_recovery_boot"); + app_composition_log_stage_skipped(APP_COMPOSITION_BOOT_STAGE_BOOT_SOUND, + "boot_sound", + "ble_recovery_boot"); + app_composition_log_stage_skipped(APP_COMPOSITION_BOOT_STAGE_AUDIO_READY, + "audio_ready", + "ble_recovery_boot"); +} else { + esp_err_t boot_sound_err = + control_plane_play_boot_sound(CONFIG_TQ_BOOT_SOUND_TIMEOUT_MS); + if (boot_sound_err != ESP_OK) { + ESP_LOGW(TAG, "Boot sound unavailable, continue boot: %s", + esp_err_to_name(boot_sound_err)); + } +} +``` + +Factory CLI、LCD 渲染任务和转盘校准也使用同一个恢复启动条件延后。其中电池服务 +不跳过,确保低电量保护在恢复配网期间仍然有效。 + +### 4.9 恢复配网成功后回到正常启动 + +```c +if (provisioning_succeeded && stop_err == ESP_OK && + s_provisioning_recovery_boot) { + ESP_LOGI(TAG, + "BLE recovery provisioning completed; restarting into normal runtime"); + s_provisioning_recovery_boot = false; + vTaskDelay(pdMS_TO_TICKS(CONTROL_PLANE_PROVISIONING_RESTART_DELAY_MS)); + domain_runtime_restart(); +} +``` + +## 五、解决成果 + +1. 实测运行时 Wi-Fi 重连失败后,内部内存从约 35 KB 恢复到约 66 KB。 +2. DMA 最大连续块从约 10.5 KB 提升到约 18 KB。 +3. BLE 控制器成功初始化,不再出现 `BLE_INIT: Malloc failed`。 +4. 不再进入 BLE ROM assert 和 CPU0 中断看门狗复位路径。 +5. BLE 能正常以 `TalkingQ-LuckyCat-*` 名称广播并等待小程序连接。 +6. 配网等待期间显示二维码,并每 10 秒输出健康心跳,不再表现为静默卡死。 +7. 内存仍不足时不会调用危险的 BLE 初始化,而是受控重启进入轻量模式。 +8. 恢复标志不写 Flash,避免额外存储磨损。 +9. 已使用 ESP-IDF 5.5.4 完整构建并成功链接。 +10. 最终固件大小为 `0x2190f0`,最小应用分区仍剩余约 40%。 + +## 六、后续影响与注意事项 + +### 6.1 运行时进入配网后业务会暂停 + +进入 BLE 配网时会停止本地唤醒、多模态会话和音频管线,并关闭运行时业务门。这是 +为了释放连续内部内存,也是预期行为。配网成功后恢复启动会重启设备,随后恢复完整 +业务。 + +### 6.2 低内存情况下会多一次软件重启 + +如果运行时资源释放后仍未达到门槛,用户会看到一次受控软件重启。这比 BLE ROM +断言导致的异常重启更可控,并且下一次启动会直接进入配网模式。 + +### 6.3 Factory CLI 在轻量恢复启动中暂不可用 + +为了保留 BLE 内存余量,轻量恢复启动会延后 Factory CLI 任务。正常启动和普通运行 +期间不受影响。配网完成并重启后 Factory CLI 会恢复。 + +### 6.4 LCD 行为 + +普通运行时切入 BLE 会保留 LCD 并显示二维码。轻量恢复启动会先初始化 BLE,再启动 +LCD 渲染,避免 LCD 任务提前消耗 BLE 所需内存。 + +### 6.5 RTC 标志的生命周期 + +标志只保证跨软件重启保留,不是持久化配置。完全断电后标志可能丢失,这是可接受的, +因为完全断电本身已经重新整理内存,下一次启动通常不需要轻量恢复路径。 + +### 6.6 内存门槛需要硬件压力测试 + +48 KB 和 12 KB 是依据故障日志和当前 BLE 配置设定的保守值。若后续增加 BLE 服务、 +连接数或控制器功能,应重新测量初始化峰值并调整门槛。 + +### 6.7 已知无关日志 + +停止本地命令时可能出现: + +```text +MN_COMMAND: The mn commands is not initialized +``` + +该日志来自 MultiNet 命令表的冗余清理,不会阻塞 BLE,也不是本次卡死或复位原因。 +可以在后续独立提交中优化,避免与本次连接恢复修复混合。 + +### 6.8 建议验证 + +- 无保存 Wi-Fi 时正常开机进入 BLE 配网。 +- 设备联网后关闭路由器,等待候选遍历失败并进入 BLE。 +- 使用小程序扫描广播、连接设备、读取设备信息并写入 Wi-Fi。 +- 配网成功后确认设备重启并正常连接新 Wi-Fi。 +- 在音频播放、本地唤醒运行和低内部内存条件下重复测试。 +- 连续执行至少 20 次 Wi-Fi/BLE 模式切换。 +- 确认不再出现 `BLE_INIT: Malloc failed`、ROM assert、Guru Meditation 或 + `Interrupt wdt timeout on CPU0`。 + diff --git a/docs/controlled-low-battery-shutdown.zh-CN.md b/docs/controlled-low-battery-shutdown.zh-CN.md new file mode 100644 index 0000000..083d06c --- /dev/null +++ b/docs/controlled-low-battery-shutdown.zh-CN.md @@ -0,0 +1,366 @@ +# 极低电量受控关机方案与实现说明 + +## 1. 文档范围 + +本文说明提交 `23f4969 feat(power): add controlled low-battery shutdown` 引入的极低电量受控关机功能,包括问题原因、解决方式、关键代码、实现成果和后续影响。 + +本次实现中的“关机”是完成业务收尾后进入 ESP32-S3 Deep Sleep。由于当前硬件未启用电源保持脚,软件不能切断整块电路板的物理电源,因此它不等同于硬件完全断电。 + +## 2. 问题原因 + +### 2.1 原有临界电量状态只有检测,没有处理流程 + +原电量管理已经能够将电量划分为正常、低电量和临界电量,并在电量低于默认 5% 时进入 `CRITICAL` 状态。但是临界状态对应的处理钩子是空实现,只保留了接口和注释,不会停止负载、播放关机提示或进入休眠。 + +因此,电池继续放电时,设备最终只能依赖以下非受控结果结束运行: + +- ESP32-S3 供电电压跌破工作范围后复位或掉电; +- 锂电池保护板达到保护阈值后切断电池输出; +- 音频、电机、Wi-Fi 等负载产生瞬态压降,提前触发复位。 + +这些情况都无法保证音频、网络、Flash 写入和外设驱动处于安全状态。 + +### 2.2 只使用电量百分比不够可靠 + +电量百分比由电池电压曲线估算,存在 ADC 误差、滤波延迟和负载压降影响。如果只依赖一个百分比阈值,可能发生误关机或关机过晚。 + +因此受控关机同时使用: + +- 估算电量百分比; +- 滤波后的稳定电压; +- 未滤波的最新有效电压; +- 充电状态; +- 连续采样次数; +- 高负载采样屏蔽状态。 + +### 2.3 Deep Sleep 不会自动关闭板外外设 + +ESP32-S3 进入 Deep Sleep 后,CPU、FreeRTOS 任务、Wi-Fi 和大部分片上外设停止运行,但 PA、Codec、屏幕背光、电机驱动等板外器件不会因为 CPU 休眠而自动断电。 + +如果没有提前关闭并保持相关 GPIO 的安全电平,外设可能继续耗电,甚至因 GPIO 悬空再次开启。 + +### 2.4 Deep Sleep 唤醒相当于一次重新启动 + +Deep Sleep 唤醒后会重新经过 Bootloader 和 `app_main()`,不会从休眠函数后继续执行。如果低电量尚未恢复就直接执行开机音、亮屏、联网等完整启动流程,可能再次产生大负载并形成“唤醒—掉压—复位”的循环。 + +## 3. 解决方式 + +### 3.1 关机触发条件 + +实现了普通临界条件和紧急电压条件两条路径。 + +普通临界条件必须同时满足: + +```text +未处于充电状态 +电量百分比 <= 5% +滤波电压 <= 3450mV +当前样本有效 +当前不处于高负载或高负载冷却屏蔽期 +连续确认3次 +``` + +进入候选状态后采样间隔缩短为1秒,因此正常情况下约需3秒完成确认。 + +紧急条件为: + +```text +未处于充电状态 +最新有效原始电压 <= 3350mV +连续确认2次 +``` + +紧急条件不等待高负载屏蔽窗口结束,避免真实电压已经过低时仍继续运行。普通条件和紧急条件最终进入同一套关机流程,都会播放完整的低电关机语音。 + +只要检测到正在充电,两组确认计数都会清零,不会发起低电关机。 + +### 3.2 防止重复关机 + +使用统一状态机保证关机只发起一次: + +```c +typedef enum { + BATTERY_POWER_STATE_RUNNING = 0, + BATTERY_POWER_STATE_SHUTDOWN_REQUESTED, + BATTERY_POWER_STATE_SHUTTING_DOWN, +} battery_power_state_t; + +static battery_power_state_t s_power_state = BATTERY_POWER_STATE_RUNNING; +``` + +只有 `RUNNING` 状态能够创建关机任务。任务创建前先切换到 `SHUTDOWN_REQUESTED`,任务启动后切换到 `SHUTTING_DOWN`,避免多个采样周期重复创建任务。 + +### 3.3 关机执行顺序 + +受控关机任务执行以下步骤: + +1. 设置全局 runtime stop 状态,阻止网络和业务任务重新拉起; +2. 停止本地唤醒、OTA、服务器会话、按键事件和系统事件; +3. 等待并释放电机保持电流; +4. 停止当前正在播放的下行音频; +5. 初始化必要的本地音频通路; +6. 完整播放 `shutdown_power_empty.opus`; +7. 提示音失败时每1秒重试,不进入 Deep Sleep; +8. 提示音播放完成后关闭 PA、Codec、背光和 Wi-Fi; +9. 保存最小化关机记录; +10. 将高耗电外设 GPIO 拉低并保持; +11. 配置唤醒 GPIO; +12. 进入 Deep Sleep。 + +### 3.4 强制完成关机语音 + +提示音播放接口会等待 Opus 数据全部播放完成后才返回 `ESP_OK`。关机任务利用该同步语义保证“播完以后才能关机”。 + +核心代码如下: + +```c +(void)domain_runtime_multimodal_audio_stop_downstream_playback(); +for (;;) { + esp_err_t audio_init_err = domain_runtime_multimodal_audio_init(); + esp_err_t prompt_err = audio_init_err == ESP_OK + ? domain_runtime_behavior_play_prompt_file( + CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE) + : audio_init_err; + if (prompt_err == ESP_OK) { + break; + } + + ESP_LOGE(TAG, + "Shutdown prompt did not complete; retrying: %s", + esp_err_to_name(prompt_err)); + vTaskDelay(pdMS_TO_TICKS(1000)); +} +``` + +这意味着: + +- 文件不存在、音频初始化失败或播放未完成时,不会进入 Deep Sleep; +- 软件会持续重试; +- 这是产品要求优先于快速休眠的明确策略。 + +### 3.5 外设关闭和 GPIO 保持 + +提示音完成后先关闭运行中的外设: + +```c +(void)domain_runtime_multimodal_audio_set_speaker_output_enabled(false); +domain_runtime_multimodal_audio_deinit(); +(void)domain_runtime_set_lcd_backlight_brightness(0U); +(void)domain_runtime_wifi_portal_stop_sta(); +``` + +进入 Deep Sleep 前,将以下 GPIO 设为输出低电平并启用保持: + +- PA 使能 GPIO; +- 屏幕背光 GPIO; +- 步进电机 IN1、IN2、IN3、IN4 GPIO。 + +核心实现如下: + +```c +static void platform_power_set_output_low_and_hold(int gpio) +{ + if (gpio < 0 || gpio >= GPIO_NUM_MAX) { + return; + } + (void)gpio_hold_dis((gpio_num_t)gpio); + (void)gpio_set_direction((gpio_num_t)gpio, GPIO_MODE_OUTPUT); + (void)gpio_set_level((gpio_num_t)gpio, 0); + (void)gpio_hold_en((gpio_num_t)gpio); +} +``` + +所有目标 GPIO 设置完成后调用: + +```c +gpio_deep_sleep_hold_en(); +``` + +这样可避免 ESP32-S3 休眠后引脚悬空导致 PA、背光或电机驱动继续耗电。 + +### 3.6 Deep Sleep 唤醒源 + +当前默认只启用 TP4057 充电状态 GPIO10 作为低电平唤醒源。低电关机后插入充电器,TP4057 开始充电并将 GPIO10 拉低,从而唤醒 ESP32-S3。 + +实体按键唤醒已经做成配置项,但默认值为 `-1`,即暂不启用: + +```text +CONFIG_TQ_BATTERY_SHUTDOWN_WAKE_BUTTON_GPIO=-1 +``` + +唤醒源使用 EXT1 的任意低电平模式: + +```c +esp_err_t wake_err = esp_sleep_enable_ext1_wakeup_io( + wake_mask, + ESP_EXT1_WAKEUP_ANY_LOW); +if (wake_err != ESP_OK) { + return wake_err; +} + +esp_deep_sleep_start(); +``` + +当以后需要启用实体按键时,应填写支持 Deep Sleep 唤醒的 GPIO,并确保按键按下时为低电平。 + +### 3.7 唤醒后的启动保护 + +在存储初始化完成后、开机音和屏幕初始化之前执行电池启动保护: + +```c +esp_err_t battery_guard_err = control_plane_battery_apply_boot_guard(); +if (battery_guard_err != ESP_OK && + battery_guard_err != ESP_ERR_NOT_SUPPORTED) { + ESP_LOGW(TAG, + "Battery boot guard failed, continue boot: %s", + esp_err_to_name(battery_guard_err)); +} +``` + +保护逻辑首先判断本次启动是否来自 Deep Sleep,然后读取充电状态和电池电压: + +```c +if (status.charge_state == DOMAIN_BATTERY_CHARGE_CHARGING || + (status.valid && + status.voltage_mv >= CONFIG_TQ_BATTERY_WAKE_RECOVERY_VOLTAGE_MV)) { + domain_battery_complete_wakeup(); + return ESP_OK; +} + +return domain_battery_prepare_shutdown(); +``` + +默认恢复阈值为 3550mV: + +- 正在充电:允许正常启动; +- 未充电但电压不低于 3550mV:允许正常启动; +- 未充电且电压低于 3550mV:在开机音、亮屏和联网前重新进入 Deep Sleep。 + +正常启动被允许后会解除 PA、背光和电机 GPIO 的 Deep Sleep 保持,否则这些外设无法重新初始化。 + +### 3.8 最小化关机记录 + +进入 Deep Sleep 前以尽力而为方式写入一条短小 NVS 记录: + +```c +typedef struct { + uint8_t version; + uint8_t reason; + uint8_t percentage; + uint8_t reserved; + int32_t voltage_mv; +} battery_shutdown_record_t; +``` + +保存位置: + +```text +namespace: battery +key: last_shutdown +``` + +写入内容包括记录版本、关机原因、电量百分比和滤波电压。写入失败不会阻止设备进入 Deep Sleep,避免存储异常造成无法关机。 + +### 3.9 默认配置 + +```text +受控关机功能:启用 +普通关机电压:3450mV +紧急关机电压:3350mV +唤醒恢复电压:3550mV +临界电量百分比:5% +普通确认次数:3次 +紧急确认次数:2次 +确认期间采样间隔:1秒 +实体按键唤醒 GPIO:-1(关闭) +关机提示音:/littlefs/audio_prompts/shutdown_power_empty.opus +充电唤醒 GPIO:GPIO10,低电平有效 +``` + +编译期间还会检查阈值关系: + +```c +#if CONFIG_TQ_BATTERY_EMERGENCY_VOLTAGE_MV >= CONFIG_TQ_BATTERY_SHUTDOWN_VOLTAGE_MV +#error "Emergency battery voltage must be below the controlled shutdown threshold" +#endif + +#if CONFIG_TQ_BATTERY_WAKE_RECOVERY_VOLTAGE_MV <= CONFIG_TQ_BATTERY_SHUTDOWN_VOLTAGE_MV +#error "Battery wake recovery voltage must exceed the shutdown threshold" +#endif +``` + +## 4. 解决成果 + +本次修改达成以下结果: + +- 临界电量不再仅记录日志,而是进入完整受控关机流程; +- 普通阈值结合百分比、滤波电压、有效采样和连续确认,降低误触发概率; +- 紧急电压通道避免电池已经极低时仍长时间等待; +- 充电状态会阻止尚未触发的低电关机; +- 关机状态只允许发起一次,不会创建多个关机任务; +- 关机语音必须完整播放,失败时持续重试; +- 网络、音频、电机、PA、Codec 和背光在休眠前有序关闭; +- 高耗电外设控制 GPIO 在 Deep Sleep 中保持低电平; +- GPIO10 可在插入充电器并开始充电后唤醒设备; +- 低电量未恢复时不会执行完整开机流程; +- 保存最小化低电关机诊断记录; +- 实体按键唤醒接口已经预留且可配置; +- ESP-IDF v5.5.4 完整构建通过; +- 生成固件大小约 `0x2173a0` 字节,应用分区剩余约 40%。 + +## 5. 后续影响与风险 + +### 5.1 仍然不是物理断电 + +当前电源保持 GPIO 为 `-1`,Deep Sleep 只能降低 ESP32-S3 和受控外设的功耗,不能切断板级电源。电池分压电阻、充电芯片以及其他常供电电路仍有静态电流。 + +如果产品要求长期零待机或接近零待机,需要增加负载开关、MOS 管或电源锁存电路,由 ESP32-S3 在关机末尾释放电源保持信号。 + +### 5.2 关机语音优先策略可能增加掉电风险 + +产品要求任何低电关机都必须完整播放语音,因此实现不会因为电压极低而跳过语音。PA 和扬声器属于高负载,电池状态特别差时可能在语音播放期间发生电压跌落或硬件保护板提前切断。 + +如果台架测试发现 3350mV 附近无法稳定播放,应优先调整紧急阈值,使关机更早触发,而不是跳过提示音。例如可以将紧急阈值提高到 3400mV 或根据实测负载压降进一步校准。 + +### 5.3 提示音资源异常会阻止 Deep Sleep + +因为要求“播完才能关机”,提示文件缺失、损坏、Codec 初始化失败或播放通路持续故障时,软件会每秒重试而不会休眠。这符合当前产品决策,但可能导致电池最终依靠保护板断电。 + +量产前必须验证 LittleFS 中始终包含 `shutdown_power_empty.opus`,并验证关机场景下音频通路可初始化。 + +### 5.4 GPIO10 只能在实际充电时可靠唤醒 + +TP4057 状态脚低电平表示正在充电,高电平可能表示充满或未插电。低电休眠后插入充电器通常会进入充电并拉低 GPIO10,因此可以唤醒;但如果外围电路或电池状态导致 TP4057 没有进入充电,GPIO10 不会产生有效低电平唤醒。 + +实体按键唤醒尚未启用,当前无法仅通过按键唤醒 Deep Sleep 中的设备。 + +### 5.5 Deep Sleep 唤醒是新的软件生命周期 + +唤醒后会重新执行 Bootloader 和 `app_main()`,运行内存、任务状态和普通全局变量不会保留。需要跨休眠保留的数据必须写入 NVS、LittleFS 或 RTC 内存。 + +### 5.6 NVS 写入带来少量 Flash 磨损 + +每次受控低电关机会写入一次 `last_shutdown` 记录。正常产品使用中低电关机次数有限,影响很小;如果测试环境频繁循环触发低电关机,应注意写入次数。 + +### 5.7 需要真机验证的项目 + +软件构建通过不等于硬件功耗和唤醒行为已经验证。至少需要完成以下测试: + +1. 3450mV 普通路径是否稳定完成3次确认; +2. 3350mV 紧急路径是否稳定完成2次确认; +3. 两条路径是否都完整播放关机语音; +4. 语音播放期间 3.3V 电源是否发生复位级压降; +5. PA、背光和四路电机 GPIO 在 Deep Sleep 中是否保持低电平; +6. Deep Sleep 整机电流是否符合产品目标; +7. 休眠后插入充电器是否由 GPIO10 稳定唤醒; +8. 电压低于3550mV且未充电时是否在开机音前重新休眠; +9. 充电状态下即使电压低于3550mV是否允许正常启动; +10. 提示音文件异常时是否按预期持续重试并输出日志; +11. NVS 中的 `battery/last_shutdown` 是否记录正确; +12. 多次 Deep Sleep 唤醒后 GPIO 保持是否正确解除,PA、背光和电机能否正常恢复。 + +## 6. 结论 + +本次修改把原来的“临界电量状态检测”扩展为完整的“检测、业务收尾、强制语音提示、外设低功耗处理、Deep Sleep、充电唤醒和启动保护”闭环。 + +它显著降低了极低电量下随机复位、外设持续耗电和反复启动的风险,但受当前硬件电源架构限制,仍属于软件低功耗关机。最终阈值和可靠性需要结合扬声器播放时的实际电压跌落及整机 Deep Sleep 电流进行台架校准。 diff --git a/docs/factory-cli-wifi-management.zh-CN.md b/docs/factory-cli-wifi-management.zh-CN.md new file mode 100644 index 0000000..ab52acf --- /dev/null +++ b/docs/factory-cli-wifi-management.zh-CN.md @@ -0,0 +1,384 @@ +# Factory CLI Wi-Fi 管理功能提交说明 + +## 一、提交信息 + +```text +提交:140e90447cc75ef5f1b0a56684cc0195a191cf63 +标题:feat(factory-cli): add Wi-Fi credential management commands +``` + +本提交为 Factory CLI 增加以下三个命令: + +```text +get_wifi +set_wifi "" "" +reset_wifi +``` + +## 二、问题原因 + +设备原有 Factory CLI 主要提供设备身份写入、鉴权材料读取、Challenge +签名和日志管理等生产功能,没有提供 Wi-Fi 凭据的查询、设置和清除接口。 + +这会带来以下问题: + +1. 生产或售后工具无法通过统一的串口协议查看设备保存了哪些 Wi-Fi。 +2. 无法通过 Factory CLI 写入或更新 Wi-Fi,只能依赖小程序配网或物理操作。 +3. 无法通过 Factory CLI 执行与物理 Wi-Fi Reset 相同的清除操作。 +4. 原有参数解析仅按空白字符切分,不能正确处理带空格的 SSID 或密码。 +5. Wi-Fi 凭据由底层 `SsidManager` 管理,上层不能直接访问 NVS,否则会破坏 + `control_plane -> domain -> platform` 的分层依赖关系。 + +## 三、解决方式 + +### 3.1 增加三个 Factory CLI 命令 + +命令行为如下: + +| 命令 | 行为 | +| --- | --- | +| `get_wifi` | 按持久化优先级返回已保存 SSID,最多 10 个,不返回密码 | +| `set_wifi` | 新增或更新 Wi-Fi;同名 SSID 更新密码并提升到最高优先级 | +| `reset_wifi` | 清空全部已保存 Wi-Fi,回复 `OK` 后重启设备 | + +`set_wifi` 只更新持久化凭据,不会立即断开或切换当前 Wi-Fi 连接。省略密码或 +传入空字符串表示开放网络。 + +### 3.2 支持带空格参数 + +参数解析器增加单引号、双引号和反斜杠转义支持。例如: + +```text +set_wifi "Office WiFi" "network password" +set_wifi "Guest Network" "" +``` + +未闭合引号或参数数量超限时返回: + +```text +ERROR invalid_command_syntax +``` + +### 3.3 按项目分层读取 Wi-Fi 列表 + +读取路径为: + +```text +Factory CLI + -> Domain Wi-Fi 快照接口 + -> Platform Wi-Fi 门面 + -> Wi-Fi Portal Bridge + -> SsidManager +``` + +上层只获取固定长度的 SSID 快照,不读取密码,也不直接访问 NVS。 + +### 3.4 保持旧 Factory CLI 协议兼容 + +原有命令名称、UART0、CR/LF 行结束方式以及以下返回格式均未改变: + +```text +OK +ERROR +<一行 JSON> +OK +``` + +## 四、具体代码 + +### 4.1 Wi-Fi 快照数据结构 + +```c +#define DOMAIN_WIFI_SSID_MAX_LEN 33 +#define DOMAIN_WIFI_SAVED_NETWORK_MAX_COUNT 10 + +typedef struct { + size_t count; + char ssids[DOMAIN_WIFI_SAVED_NETWORK_MAX_COUNT][DOMAIN_WIFI_SSID_MAX_LEN]; +} domain_wifi_saved_networks_t; + +esp_err_t domain_runtime_wifi_portal_get_saved_networks( + domain_wifi_saved_networks_t *out_networks); +``` + +该结构固定最多保存 10 个 SSID,每个 SSID 最多 32 字节并保留一个字符串结束符。 +结构中不包含密码字段,从接口层面避免密码泄露。 + +### 4.2 Domain 到 Platform 的快照映射 + +```c +esp_err_t domain_runtime_wifi_portal_get_saved_networks( + domain_wifi_saved_networks_t *out_networks) +{ + if (out_networks == NULL) { + return ESP_ERR_INVALID_ARG; + } + + memset(out_networks, 0, sizeof(*out_networks)); + return platform_wifi_portal_get_saved_ssids( + &out_networks->ssids[0][0], + sizeof(out_networks->ssids[0]), + DOMAIN_WIFI_SAVED_NETWORK_MAX_COUNT, + &out_networks->count); +} +``` + +### 4.3 Platform 层复制保存的 SSID + +```cpp +esp_err_t wifi_portal_get_saved_ssids(char *out_ssids, + size_t ssid_stride, + size_t capacity, + size_t *out_count) +{ + if (out_count == nullptr || + (capacity > 0U && (out_ssids == nullptr || ssid_stride < 33U))) { + return ESP_ERR_INVALID_ARG; + } + + install_wifi_storage_hooks(); + ESP_RETURN_ON_ERROR(wifi_portal_lock(), TAG, "Failed to lock Wi-Fi portal"); + PortalUnlockGuard guard; + + const auto &ssid_list = SsidManager::GetInstance().GetSsidList(); + *out_count = ssid_list.size(); + if (ssid_list.size() > capacity) { + return ESP_ERR_INVALID_SIZE; + } + + for (size_t i = 0; i < ssid_list.size(); ++i) { + strlcpy(out_ssids + (i * ssid_stride), + ssid_list[i].ssid.c_str(), + ssid_stride); + } + return ESP_OK; +} +``` + +读取过程使用 Wi-Fi Portal 互斥锁,避免连接、保存和查询同时操作列表时产生竞态。 + +### 4.4 带引号和转义的参数解析 + +```c +static size_t split_args(char *line, char *argv[], size_t max_args) +{ + size_t argc = 0; + char *read_cursor = line; + char *write_cursor = line; + + while (*read_cursor != '\0') { + while (*read_cursor != '\0' && isspace((unsigned char)(*read_cursor))) { + read_cursor++; + } + if (*read_cursor == '\0') { + break; + } + if (argc >= max_args) { + return SIZE_MAX; + } + + argv[argc++] = write_cursor; + char quote = '\0'; + while (*read_cursor != '\0') { + char ch = *read_cursor; + if (quote != '\0') { + if (ch == quote) { + quote = '\0'; + read_cursor++; + continue; + } + } else { + if (isspace((unsigned char)ch)) { + break; + } + if (ch == '\'' || ch == '"') { + quote = ch; + read_cursor++; + continue; + } + } + + if (ch == '\\' && read_cursor[1] != '\0') { + read_cursor++; + ch = *read_cursor; + } + *write_cursor++ = ch; + read_cursor++; + } + + if (quote != '\0') { + return SIZE_MAX; + } + while (*read_cursor != '\0' && isspace((unsigned char)(*read_cursor))) { + read_cursor++; + } + *write_cursor++ = '\0'; + } + return argc; +} +``` + +### 4.5 `get_wifi` 命令处理 + +```c +static void handle_get_wifi(void) +{ + domain_wifi_saved_networks_t networks = {0}; + esp_err_t err = domain_runtime_wifi_portal_get_saved_networks(&networks); + if (err != ESP_OK) { + cli_write_error("wifi_read_failed"); + return; + } + + char json[FACTORY_CLI_WIFI_JSON_MAX_LEN] = {0}; + size_t offset = 0; + char prefix[48] = {0}; + snprintf(prefix, sizeof(prefix), + "{\"count\":%u,\"networks\":[", (unsigned)networks.count); + bool valid = json_append_text(json, sizeof(json), &offset, prefix); + + for (size_t i = 0; valid && i < networks.count; ++i) { + char item_prefix[40] = {0}; + snprintf(item_prefix, sizeof(item_prefix), + "%s{\"priority\":%u,\"ssid\":", + i == 0U ? "" : ",", (unsigned)i); + valid = json_append_text(json, sizeof(json), &offset, item_prefix) && + json_append_escaped_string(json, sizeof(json), &offset, + networks.ssids[i]) && + json_append_text(json, sizeof(json), &offset, "}"); + } + + valid = valid && json_append_text(json, sizeof(json), &offset, "]}"); + if (!valid) { + cli_write_error("wifi_response_too_large"); + return; + } + + cli_write_line(json); + cli_write_ok(); +} +``` + +返回示例: + +```json +{"count":2,"networks":[{"priority":0,"ssid":"Office WiFi"},{"priority":1,"ssid":"Backup"}]} +``` + +随后返回: + +```text +OK +``` + +### 4.6 `set_wifi` 命令处理 + +```c +static void handle_set_wifi(size_t argc, char *argv[]) +{ + if (argc < 2 || is_text_empty(argv[1])) { + cli_write_error("missing_wifi_ssid"); + return; + } + if (argc > 3) { + cli_write_error("invalid_wifi_arguments"); + return; + } + + const char *password = argc >= 3 ? argv[2] : ""; + esp_err_t err = domain_runtime_wifi_portal_save_credentials(argv[1], password); + if (err != ESP_OK) { + cli_write_error(err == ESP_ERR_INVALID_ARG ? + "invalid_wifi_credentials" : + "wifi_save_failed"); + return; + } + + cli_write_ok(); +} +``` + +底层保存规则保持不变: + +- 新 SSID 插入列表首位。 +- 同名 SSID 覆盖密码并提升到首位。 +- 列表已满 10 个时,新增 SSID 会淘汰最低优先级项。 +- SSID 最大 32 字节,密码最大 64 字节。 + +### 4.7 `reset_wifi` 命令处理 + +```c +static void handle_reset_wifi(void) +{ + esp_err_t err = domain_runtime_wifi_portal_clear_saved_credentials(); + if (err != ESP_OK) { + cli_write_error("wifi_reset_failed"); + return; + } + + cli_write_ok(); + vTaskDelay(pdMS_TO_TICKS(FACTORY_CLI_RESET_REPLY_DELAY_MS)); + domain_runtime_restart(); +} +``` + +清除成功后先发送 `OK`,等待 100 ms 给 UART 发送数据,然后重启设备。该操作与 +物理 Wi-Fi Reset 的持久化效果一致,会删除全部 10 个 Wi-Fi 槽位。 + +### 4.8 命令注册 + +```c +} else if (strcmp(cmd, "get_wifi") == 0) { + handle_get_wifi(); +} else if (strcmp(cmd, "set_wifi") == 0) { + handle_set_wifi(argc, argv); +} else if (strcmp(cmd, "reset_wifi") == 0) { + handle_reset_wifi(); +} +``` + +## 五、解决成果 + +1. Factory CLI 现在可以完整执行 Wi-Fi 查询、设置和清除操作。 +2. 最多可查询和管理 10 个已保存 Wi-Fi,返回顺序即连接优先级。 +3. 查询结果不包含密码,避免生产工具无意获取敏感信息。 +4. 支持带空格的 SSID、密码、开放网络及常见转义字符。 +5. 同名 SSID 可以更新密码,并自动提升为最高连接优先级。 +6. 清除 Wi-Fi 后会可靠回复并重启,与物理复位语义一致。 +7. 原有 Factory CLI 命令、UART 协议和回包格式保持不变。 +8. 已使用 ESP-IDF 5.5.4 完整构建并成功链接。 + +## 六、后续影响与注意事项 + +### 6.1 对原有生产工具的影响 + +正常生产工具使用的 UUID、序列号和 Base64URL 鉴权参数不受影响。新增命令通过 +完整命令名匹配,不会误触发旧命令。 + +参数解析现在会将引号视为分组符、将反斜杠视为转义符。如果旧工具在设备 ID 或 +序列号中发送字面量单引号、双引号或反斜杠,解析结果可能与旧版本不同。当前标准 +生产协议不使用这些字符,因此正常流程不存在兼容性问题。 + +### 6.2 `set_wifi` 不立即联网 + +命令成功只表示凭据已持久化,不表示设备已经连接到该网络,也不会中断当前连接。 +新凭据会在后续连接或重连流程中参与候选遍历。 + +### 6.3 `reset_wifi` 会重启 + +生产工具收到 `OK` 后应允许串口断开和设备重新枚举,不应继续在同一启动周期发送 +后续命令。 + +### 6.4 密码不可读取 + +这是有意的安全限制。若生产工具需要验证密码,只能通过实际连接结果验证,不能通过 +`get_wifi` 回读。 + +### 6.5 建议验证 + +- 使用原有生产工具跑完身份写入和鉴权流程。 +- 设置普通、带空格、中文和开放网络 SSID。 +- 对同一 SSID 连续设置不同密码,确认仅保留一条且优先级为 0。 +- 填满 10 个网络后增加第 11 个,确认淘汰最低优先级项。 +- 执行 `reset_wifi`,确认收到 `OK`、设备重启且列表为空。 + diff --git a/docs/knowledge vault/bugfix-cloud-chouqian-session-readiness.zh-CN.md b/docs/knowledge vault/bugfix-cloud-chouqian-session-readiness.zh-CN.md new file mode 100644 index 0000000..5432cb9 --- /dev/null +++ b/docs/knowledge vault/bugfix-cloud-chouqian-session-readiness.zh-CN.md @@ -0,0 +1,312 @@ +# 云端抽签首次请求失败:会话准备问题与修复说明 + +## 1. 对应提交 + +```text +d386de2 fix(multimodal): prepare session for cloud fortune draw +``` + +本文说明设备启动后,必须先完成一次语音对话,SW2 抽签按钮才能成功请求云端的问题。本提交同时处理了后续发现的系统事件任务栈溢出;栈问题单独记录在《云端抽签触发系统事件任务栈溢出:问题与修复说明》中。 + +## 2. 问题现象 + +设备已经连接 Wi-Fi 和多模态 WebSocket,但尚未进行语音对话时,第一次按下 SW2 会出现: + +```text +cp_system_event: key event key=SW2 type=SHORT_PRESS ... +control_plane_mm_rt: Cloud chouqian request completed: request send failed +cp_system_event: Cloud chouqian request skipped: multimodal session unavailable or request pending +``` + +完成一次 PTT 语音对话后,再按 SW2 则可以正常发送抽签请求。 + +问题发生时的关键运行状态为: + +```text +wifi=1 ws(conn=1 starting=0 stopping=0) task=0 listening=0 +``` + +这表示网络和 WebSocket 已经可用,但云端多模态 task 尚未创建,服务端也没有进入 Listening 状态。 + +## 3. 问题原因 + +### 3.1 WebSocket 连接不等于多模态会话就绪 + +原抽签逻辑收到 SW2 短按后,直接构造并发送 `RequestToRespond`: + +```c +const control_plane_multimodal_respond_request_t request = { + .type = CONTROL_PLANE_MULTIMODAL_RESPOND_TYPE_PROMPT, + .text = "我要抽签", + .parameters_json = NULL, +}; + +esp_err_t err = control_plane_multimodal_send_request_to_respond(&request); +``` + +但发送接口要求以下条件全部满足: + +```c +if (task_id[0] == '\0' || dialog_id[0] == '\0' || !server_listening) { + return ESP_ERR_INVALID_STATE; +} +``` + +设备刚启动时只有 WebSocket transport 已连接,`task_id`、`dialog_id` 和 `server_listening` 尚未准备好,因此请求返回 `ESP_ERR_INVALID_STATE`。 + +### 3.2 首次语音流程隐式完成了会话初始化 + +PTT 状态机在发现没有 active task 时会先发送 `Start`: + +```text +PTT_STATE_WAIT_WIFI + -> PTT_STATE_WAIT_START_SENT + -> 发送 Start + -> 等待 task 和 dialog + -> 等待 DialogStateChanged(Listening) + -> 发送 SpeechStart +``` + +所以用户观察到的“必须先语音一次”,本质上不是抽签依赖语音内容,而是语音流程替抽签完成了缺失的会话准备步骤。 + +### 3.3 原错误日志合并了两种状态 + +上层将所有 `ESP_ERR_INVALID_STATE` 都记录为: + +```text +multimodal session unavailable or request pending +``` + +本次失败实际属于 `multimodal session unavailable`,不是已有抽签请求仍处于 pending。 + +## 4. 解决方式 + +### 4.1 将抽签改为异步请求 + +SW2 事件不再要求 Prompt 当场发送成功,而是登记一个 pending 请求: + +```c +esp_err_t control_plane_request_cloud_chouqian(void) +{ + uint32_t requested_ms = control_plane_now_ms(); + + portENTER_CRITICAL(&s_multimodal_runtime_lock); + if (s_cloud_chouqian_pending) { + portEXIT_CRITICAL(&s_multimodal_runtime_lock); + return ESP_ERR_INVALID_STATE; + } + s_cloud_chouqian_pending = true; + s_cloud_chouqian_action_in_progress = false; + s_cloud_chouqian_prompt_sent = false; + s_cloud_chouqian_requested_ms = requested_ms; + s_model_command_dispatched = false; + portEXIT_CRITICAL(&s_multimodal_runtime_lock); + + ESP_LOGI(TAG, "Cloud chouqian request queued"); + control_plane_wake_multimodal_watchdog(); + return ESP_OK; +} +``` + +相关状态含义如下: + +```c +static bool s_cloud_chouqian_pending; +static bool s_cloud_chouqian_action_in_progress; +static bool s_cloud_chouqian_prompt_sent; +static uint32_t s_cloud_chouqian_requested_ms; +``` + +- `pending`:本轮按钮抽签尚未结束; +- `action_in_progress`:正在执行 Start 或 Prompt 发送,防止并发重复操作; +- `prompt_sent`:Prompt 已发送,后续只等待云端结果和响应结束; +- `requested_ms`:请求登记时间,用于限制会话准备时长。 + +### 4.2 根据会话真实状态推进请求 + +抽签请求由 multimodal watchdog 查询 session service 的状态快照后推进: + +```c +control_plane_multimodal_runtime_state_t state = {0}; +esp_err_t state_err = control_plane_multimodal_get_runtime_state(&state); +if (state_err != ESP_OK) { + return; +} + +if (!state.connected) { + (void)control_plane_schedule_websocket_wait_task(); + return; +} + +bool should_send_start = !state.has_active_task; +bool should_send_prompt = state.has_active_task && state.server_listening; +if (!should_send_start && !should_send_prompt) { + return; +} +``` + +处理规则为: + +```text +WebSocket 未连接 + -> 请求连接或重连 + +WebSocket 已连接,但没有 active task + -> 发送 Start + +task 已存在,但尚未 Listening + -> 等待服务端事件 + +task 已存在且服务端处于 Listening + -> 发送“我要抽签” Prompt +``` + +### 4.3 缺少 task 时自动发送 Start + +```c +if (should_send_start) { + esp_err_t start_err = control_plane_multimodal_send_start(); + + portENTER_CRITICAL(&s_multimodal_runtime_lock); + s_cloud_chouqian_action_in_progress = false; + portEXIT_CRITICAL(&s_multimodal_runtime_lock); + + if (start_err == ESP_OK) { + ESP_LOGI(TAG, "Cloud chouqian waiting for multimodal session"); + } else if (start_err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, + "Cloud chouqian session start failed: %s", + esp_err_to_name(start_err)); + control_plane_clear_cloud_chouqian_pending("session start failed"); + } + return; +} +``` + +`Start` 只负责创建云端 task。抽签不会发送 `SpeechStart`,不会启动麦克风,也不会上传音频。 + +### 4.4 Listening 后发送抽签 Prompt + +```c +const control_plane_multimodal_respond_request_t request = { + .type = CONTROL_PLANE_MULTIMODAL_RESPOND_TYPE_PROMPT, + .text = "我要抽签", + .parameters_json = NULL, +}; +esp_err_t request_err = + control_plane_multimodal_send_request_to_respond(&request); + +if (request_err != ESP_OK) { + control_plane_clear_cloud_chouqian_pending("request send failed"); + return; +} + +ESP_LOGI(TAG, "Cloud chouqian prompt sent"); +``` + +`prompt_sent` 在发送前置位,防止 watchdog 周期检查和会话事件同时触发重复发送。 + +### 4.5 由会话事件继续推进 + +服务端确认 task 或进入 Listening 后,仅唤醒 watchdog: + +```c +case CONTROL_PLANE_MULTIMODAL_EVENT_TASK_STARTED: + control_plane_wake_multimodal_watchdog(); + break; + +case CONTROL_PLANE_MULTIMODAL_EVENT_SERVER_LISTENING: + control_plane_wake_multimodal_watchdog(); + break; +``` + +`dialog_id` 在 session service 解析消息时先保存,再发出 Listening 事件,因此 watchdog 被唤醒后能够满足 Prompt 的发送条件。 + +### 4.6 增加准备超时和统一清理 + +会话准备最长允许 10 秒: + +```c +#define CONTROL_PLANE_CLOUD_CHOUQIAN_PREPARE_TIMEOUT_MS 10000U + +if ((now_ms - requested_ms) >= + CONTROL_PLANE_CLOUD_CHOUQIAN_PREPARE_TIMEOUT_MS) { + control_plane_clear_cloud_chouqian_pending("session prepare timeout"); + return; +} +``` + +断线、transport error、task failed、output error、响应结束和客户端停止时,原有清理入口会同时复位新增状态: + +```c +s_cloud_chouqian_pending = false; +s_cloud_chouqian_action_in_progress = false; +s_cloud_chouqian_prompt_sent = false; +s_cloud_chouqian_requested_ms = 0U; +``` + +## 5. 修复后的流程 + +```text +SW2 短按 + -> 登记抽签请求 + -> 唤醒 multimodal watchdog + -> 检查 WebSocket + -> 没有 task 时发送 Start + -> 等待 task_id、dialog_id 和 Listening + -> 发送“我要抽签” + -> 接收模型抽签指令 + -> 展示对应签型并播放 TTS + -> RespondingEnded 后清除 pending +``` + +预期关键日志为: + +```text +control_plane_mm_rt: Cloud chouqian request queued +cp_mm_session: Start sent (...) +control_plane_mm_rt: Cloud chouqian waiting for multimodal session +cp_mm_session: Cloud dialog entered Listening +control_plane_mm_rt: Cloud chouqian prompt sent +control_plane_mm_rt: Cloud chouqian result received: <签型> +control_plane_mm_rt: Cloud chouqian request completed: response ended +``` + +如果会话原本已经处于 Listening,则会跳过 `Start` 和等待过程,直接发送 Prompt。 + +## 6. 解决成果 + +- 首次启动后不需要先进行语音对话即可发起云端抽签; +- 抽签复用了多模态会话准备能力,但没有复用录音和音频上行; +- 请求准备、Prompt 发送和响应等待具备明确状态; +- watchdog 和事件并发触发时不会重复发送 Prompt; +- 会话无法在 10 秒内准备完成时可以自动退出 pending; +- transport 和 task 异常仍会通过统一路径释放抽签状态; +- ESP-IDF v5.5.4 完整构建通过; +- 生成的应用固件大小为 `0x20f450` 字节,最小应用分区剩余约 41%。 + +## 7. 后续影响与边界 + +### 7.1 对语音流程的影响 + +抽签创建的 task 与语音流程使用同一 session service。抽签结束后,如果 task 仍有效,后续 PTT 可以直接复用该 task,不需要重复发送 Start。抽签本身不会启动 ASR 或麦克风采集。 + +### 7.2 对并发按键的影响 + +一轮抽签从排队到响应结束期间,新的 SW2 抽签请求仍返回 `ESP_ERR_INVALID_STATE`,用于避免同一 task 内并发多轮抽签响应。 + +### 7.3 对断线恢复的影响 + +请求登记时如果 WebSocket 尚未连接,会触发已有的连接调度。若网络和鉴权无法在 10 秒内完成,请求会以 `session prepare timeout` 结束,用户需要网络恢复后重新按键。 + +### 7.4 对云端资源的影响 + +以前只有首次语音会创建云端 task;修复后首次抽签也可以创建 task。因此只使用抽签功能的设备会更早创建云端会话,但不会额外建立第二条 WebSocket 或第二个并行 task。 + +### 7.5 仍需关注的验证项 + +- 冷启动后第一次按 SW2 的真实设备端到端结果; +- 弱网环境下 10 秒准备超时是否符合产品体验; +- 抽签准备期间同时触发 PTT 时的交互优先级; +- 云端返回的抽签设备指令是否始终包含有效签型索引。 + diff --git a/docs/knowledge vault/bugfix-cloud-chouqian-system-event-stack-overflow.zh-CN.md b/docs/knowledge vault/bugfix-cloud-chouqian-system-event-stack-overflow.zh-CN.md new file mode 100644 index 0000000..8f120d8 --- /dev/null +++ b/docs/knowledge vault/bugfix-cloud-chouqian-system-event-stack-overflow.zh-CN.md @@ -0,0 +1,257 @@ +# 云端抽签触发系统事件任务栈溢出:问题与修复说明 + +## 1. 对应提交 + +```text +d386de2 fix(multimodal): prepare session for cloud fortune draw +``` + +本文说明首次抽签能够自动创建多模态会话后,按下 SW2 导致 `cp_system_event` 任务栈溢出的问题,以及最终采用的异步 worker 方案。 + +## 2. 问题现象 + +修复“首次抽签缺少会话”后,设备按下 SW2 可以进入 `Start` 发送,但随即触发 FreeRTOS 栈保护: + +```text +control_plane_mm_rt: Cloud chouqian request queued +cp_mm_session: Start sent (...) +***ERROR*** A stack overflow in task cp_system_event has been detected. +``` + +回溯显示: + +```text +panic_abort +esp_system_abort +vApplicationStackOverflowHook +vTaskSwitchContext +``` + +栈已经被破坏,因此回溯无法继续还原完整的业务调用链,但任务名明确指向 `cp_system_event`。 + +## 3. 问题原因 + +### 3.1 系统事件任务栈较小 + +系统事件任务配置为 4096 字节: + +```c +#ifndef CONFIG_TQ_SYSTEM_EVENT_TASK_STACK +#define CONFIG_TQ_SYSTEM_EVENT_TASK_STACK 4096 +#endif + +xTaskCreatePinnedToCore(control_plane_system_event_task, + "cp_system_event", + CONFIG_TQ_SYSTEM_EVENT_TASK_STACK, + NULL, + 4, + &s_system_event_task, + CONTROL_PLANE_SYSTEM_EVENT_TASK_CORE); +``` + +该任务原本只负责读取事件队列、识别按键类型并分发轻量控制命令,不适合承担协议报文构造和同步网络发送。 + +### 3.2 首版修复在按键调用栈内执行了 Start + +首版会话准备逻辑在登记请求后立即调用推进函数: + +```c +ESP_LOGI(TAG, "Cloud chouqian request queued"); +control_plane_try_cloud_chouqian_request(); +return ESP_OK; +``` + +实际调用链变成: + +```text +cp_system_event + -> control_plane_dispatch_key_event + -> control_plane_request_cloud_chouqian + -> control_plane_try_cloud_chouqian_request + -> control_plane_multimodal_send_start + -> 创建 cJSON 协议对象 + -> 序列化 Start JSON + -> WebSocket 文本发送与日志格式化 +``` + +`Start` 包含音频、下行、voice、client info、workspace 和 app 等参数。虽然 JSON 节点和序列化文本主要使用 heap,但多层函数调用、局部结构体、格式化和网络发送仍会消耗任务栈,最终超过 `cp_system_event` 的 4096 字节限制。 + +### 3.3 单纯扩大系统事件栈不是合适的职责划分 + +直接扩大 `cp_system_event` 栈可以缓解当前崩溃,但仍有以下问题: + +- 所有按键和系统事件都被迫为少数网络操作长期预留大栈; +- WebSocket 发送可能等待锁或网络超时,阻塞其他系统事件; +- 以后 Start 协议字段增加后仍可能再次逼近栈上限; +- 事件分发任务承担了不属于它的会话编排职责。 + +因此最终方案是迁移执行上下文,而不是只增加系统事件任务的栈。 + +## 4. 解决方式 + +### 4.1 按键任务只登记状态并通知 worker + +最终实现不再从 `control_plane_request_cloud_chouqian()` 直接执行 Start 或 Prompt: + +```c +esp_err_t control_plane_request_cloud_chouqian(void) +{ + uint32_t requested_ms = control_plane_now_ms(); + + portENTER_CRITICAL(&s_multimodal_runtime_lock); + if (s_cloud_chouqian_pending) { + portEXIT_CRITICAL(&s_multimodal_runtime_lock); + return ESP_ERR_INVALID_STATE; + } + s_cloud_chouqian_pending = true; + s_cloud_chouqian_action_in_progress = false; + s_cloud_chouqian_prompt_sent = false; + s_cloud_chouqian_requested_ms = requested_ms; + s_model_command_dispatched = false; + portEXIT_CRITICAL(&s_multimodal_runtime_lock); + + ESP_LOGI(TAG, "Cloud chouqian request queued"); + control_plane_wake_multimodal_watchdog(); + return ESP_OK; +} +``` + +此时 `cp_system_event` 只执行: + +```text +写入少量受锁保护的状态 + -> 打印 queued 日志 + -> 发送 FreeRTOS task notification + -> 返回事件循环 +``` + +### 4.2 使用 FreeRTOS task notification 唤醒 watchdog + +```c +static void control_plane_wake_multimodal_watchdog(void) +{ + TaskHandle_t task = + get_multimodal_task_handle(&s_multimodal_watchdog_task); + if (task != NULL) { + xTaskNotifyGive(task); + } +} +``` + +任务通知不携带大对象,也不需要新建队列。抽签请求的真实状态保存在受 `s_multimodal_runtime_lock` 保护的静态状态中,通知只表示“需要立即检查一次”。 + +### 4.3 watchdog 同时支持通知唤醒和周期检查 + +原 watchdog 每秒固定延时: + +```c +vTaskDelay(tick_interval); +``` + +修改后使用带超时的通知等待: + +```c +while (!get_multimodal_stop_flag(&s_multimodal_watchdog_stop_requested)) { + (void)ulTaskNotifyTake(pdTRUE, tick_interval); + + control_plane_multimodal_runtime_state_t state = {0}; + if (control_plane_multimodal_get_runtime_state(&state) != ESP_OK) { + idle_transport_started_ms = 0U; + continue; + } + + uint32_t now_ms = control_plane_now_ms(); + control_plane_try_cloud_chouqian_request(); + control_plane_try_server_success_prompt(&state, now_ms); + /* 原有 heartbeat、stall 和 idle transport 检查继续执行。 */ +} +``` + +其语义为: + +- 收到抽签或会话事件通知时立即运行; +- 没有通知时最多等待 1 秒,继续执行原有周期 watchdog 工作; +- 通知在 worker 忙碌时会被记录,下一轮循环不会丢失唤醒; +- `pending` 状态是业务事实来源,不依赖通知次数表达请求数量。 + +### 4.4 会话回调也不直接发送网络请求 + +`TASK_STARTED` 和 `SERVER_LISTENING` 可能发生在 WebSocket 消息处理上下文。最终实现让它们同样只唤醒 watchdog: + +```c +case CONTROL_PLANE_MULTIMODAL_EVENT_TASK_STARTED: + control_plane_wake_multimodal_watchdog(); + break; + +case CONTROL_PLANE_MULTIMODAL_EVENT_SERVER_LISTENING: + control_plane_wake_multimodal_watchdog(); + break; +``` + +这样 `Start` 和 `RequestToRespond` 始终由同一个 control-plane worker 执行,不会把同步 WebSocket 发送压力转移到另一个事件回调任务。 + +### 4.5 增加 watchdog 栈余量 + +watchdog 现在负责构造和发送 Start、RequestToRespond,因此栈从 6144 字节增加到 8192 字节: + +```c +#define CONTROL_PLANE_WATCHDOG_TASK_STACK_SIZE 8192 +``` + +增加的 2048 字节用于覆盖多模态协议 JSON 构造、日志格式化和 WebSocket 发送调用链。系统事件任务仍保持原来的 4096 字节,不为网络工作长期扩容。 + +## 5. 修复后的执行上下文 + +```text +cp_system_event(4096 字节) + -> 识别 SW2 SHORT_PRESS + -> 设置抽签 pending + -> xTaskNotifyGive(mm_watchdog) + -> 立即返回 + +mm_watchdog(8192 字节) + -> 被通知唤醒 + -> 查询多模态会话状态 + -> 必要时构造并发送 Start + -> 等待服务端 Listening 通知 + -> 构造并发送 RequestToRespond +``` + +系统事件任务与网络发送调用栈已经完全分离。 + +## 6. 解决成果 + +- `cp_system_event` 不再执行 Start JSON 构造和 WebSocket 发送; +- SW2 按键分发能够快速返回,不会阻塞其他系统事件; +- task-started 和 Listening 回调不再直接执行抽签网络请求; +- watchdog 通过任务通知即时响应,不引入固定 1 秒操作延迟; +- watchdog 栈增加到 8192 字节,为新增职责提供明确余量; +- 原有 heartbeat、下行 stall 恢复和空闲 transport 管理仍按 1 秒周期运行; +- ESP-IDF v5.5.4 完整构建通过; +- 生成的应用固件大小为 `0x20f450` 字节,最小应用分区剩余约 41%。 + +## 7. 后续影响与边界 + +### 7.1 内存影响 + +watchdog 栈增加 2048 字节,通常来自 FreeRTOS 可用内部内存。这是本修复最明确的常驻内存成本。相比扩大通用系统事件任务栈,额外内存被分配给实际执行网络协议工作的 owner task,职责更匹配。 + +### 7.2 watchdog 唤醒频率 + +每次抽签排队、task started 和 Listening 事件都会通知 watchdog。通知只增加一次轻量调度,不创建新任务,不分配业务消息内存,也不会改变原有任务优先级。 + +### 7.3 原有周期任务行为 + +`ulTaskNotifyTake(pdTRUE, tick_interval)` 在无通知时仍以 1 秒超时返回,所以 heartbeat、无响应保护、stall 恢复和 idle transport 关闭逻辑的最大检查间隔没有变长。有通知时这些检查可能提前执行,这是安全的。 + +### 7.4 请求可靠性 + +task notification 只负责唤醒,不负责保存业务数据。即使多次通知合并,`s_cloud_chouqian_pending` 仍保持为 true,watchdog 的周期检查也会继续推进请求,因此不会因为通知合并而丢失抽签状态。 + +### 7.5 仍需关注的验证项 + +- 冷启动后首次按 SW2,确认不再出现 `cp_system_event` 栈溢出; +- 通过运行时 stack high-water mark 检查 `mm_watchdog` 的实际余量; +- 弱网和 WebSocket 发送超时场景下,确认系统事件队列仍能持续处理其他按键; +- 高频 SW2 和 PTT 交错操作下,确认不会发生重复 Prompt 或异常会话抢占。 + diff --git a/docs/knowledge vault/bugfix-disable-tls-renegotiation-crash.zh-CN.md b/docs/knowledge vault/bugfix-disable-tls-renegotiation-crash.zh-CN.md new file mode 100644 index 0000000..c9b5425 --- /dev/null +++ b/docs/knowledge vault/bugfix-disable-tls-renegotiation-crash.zh-CN.md @@ -0,0 +1,363 @@ +# 关闭 TLS Renegotiation:语音上行崩溃原因、证据与修改说明 + +## 1. 文档目的 + +本文说明设备在本地唤醒后进行语音上行时发生的一次 `LoadProhibited` 崩溃,包括: + +- 崩溃发生在哪条调用链; +- 日志能够直接证明什么; +- 为什么判断为 TLS 动态 RX buffer 与写操作之间的时序问题; +- 为什么选择关闭 TLS renegotiation; +- 修改方法; +- 对 HTTPS、WSS、鉴权、重连、内存和兼容性的影响; +- 后续真机验证方法。 + +对应现场日志: + +```text +monitor-20260812-170239 +``` + +对应固件版本: + +```text +1c32d9f feat(runtime): add diagnostic and network recovery controls +ESP-IDF v5.5.4 +``` + +## 2. 崩溃现象 + +设备已经完成以下步骤: + +1. 本地唤醒词“喵掌柜”检测成功; +2. WebSocket 已连接; +3. 服务端已经进入 Listening; +4. 本地唤醒提示音播放完成; +5. ASR 开始; +6. 麦克风上行启动; +7. 第一包 Opus 音频成功发送; +8. 约 2.5 秒后 Core 1 发生 `LoadProhibited` 并重启。 + +崩溃前关键日志: + +```text +I (29077) cp_mm_session: ASR started +I (29077) cp_rt_workers: PTT state WAIT_SPEECH_STARTED -> STREAMING +I (29137) multimodal_audio: Mic uplink activated: mode=network +I (29167) multimodal_audio: Mic uplink first packet sent: activation_to_send_ms=60 bytes=180 + +Guru Meditation Error: Core 1 panic'ed (LoadProhibited). +``` + +这说明崩溃发生在设备已经进入正常语音上行以后,不是在启动、鉴权、WebSocket 握手或本地提示音解码阶段。 + +## 3. 直接证据 + +### 3.1 异常类型是空地址读取 + +寄存器信息: + +```text +PC : 0x40056ece memcmp +A2 : 0x00000000 +EXCVADDR: 0x00000000 +EXCCAUSE: 0x0000001c +``` + +ESP32-S3 Xtensa 调用约定中,`A2` 是函数的第一个参数。崩溃 PC 位于 `memcmp`,且 `A2` 与 `EXCVADDR` 都是零,因此可以直接确定: + +> 某次 `memcmp()` 的第一个输入地址为 NULL,CPU 尝试从地址 0 读取数据,从而触发 `LoadProhibited`。 + +### 3.2 完整回溯位于音频上行 TLS write + +日志解析出的回溯: + +```text +uplink_send_task + -> control_plane_audio_uplink_send_cb + -> control_plane_multimodal_send_audio_realtime + -> domain_runtime_multimodal_transport_send_binary_realtime + -> platform_multimodal_transport_send_binary_realtime + -> multimodal_ws_send_binary_realtime + -> websocket_send_binary_internal + -> esp_websocket_client_send_bin + -> esp_transport_ws_send_raw + -> esp_transport_write + -> esp_tls_conn_write + -> mbedtls_ssl_write + -> ssl_check_ctr_renegotiate + -> memcmp(NULL, ...) +``` + +回溯没有经过 Opus Decoder、LCD、WakeNet、MultiNet 或下行播放器。因此这些模块不是本次崩溃的直接位置。 + +### 3.3 崩溃行访问的是 TLS 输入计数器 + +ESP-IDF v5.5.4 所用 mbedTLS 的 `ssl_check_ctr_renegotiate()` 中存在: + +```c +in_ctr_cmp = memcmp(ssl->in_ctr + ep_len, + &ssl->conf->renego_period[ep_len], + MBEDTLS_SSL_SEQUENCE_NUMBER_LEN - ep_len); +``` + +结合 `A2=0`,发生异常时 `ssl->in_ctr` 没有指向有效的 TLS RX record counter。 + +### 3.4 不是堆内存申请失败 + +崩溃前最近一次内存上下文: + +```text +internal free=21059 +internal largest=7680 +dma free=13271 +dma largest=6400 +alloc_failures=0 +``` + +内部内存已经比较紧张,但仍有可用空间,且 allocation failure hook 没有记录失败。本次异常也不是 `ESP_ERR_NO_MEM`、heap assertion 或 heap corruption。 + +因此不能把这次崩溃归类为“内存耗尽”。 + +## 4. 触发条件 + +当前固件同时启用了: + +```text +CONFIG_MBEDTLS_DYNAMIC_BUFFER=y +CONFIG_MBEDTLS_SSL_RENEGOTIATION=y +CONFIG_ESP_WS_CLIENT_SEPARATE_TX_LOCK=y +``` + +三项配置分别代表: + +| 配置 | 作用 | +| --- | --- | +| `CONFIG_MBEDTLS_DYNAMIC_BUFFER` | TLS 收发缓冲按需分配和释放,降低常驻内存。 | +| `CONFIG_MBEDTLS_SSL_RENEGOTIATION` | 编译并启用 TLS 1.2 连接内重新协商的支持。 | +| `CONFIG_ESP_WS_CLIENT_SEPARATE_TX_LOCK` | WebSocket 发送使用独立 TX 锁,允许接收循环和音频发送并行。 | + +动态 TLS buffer 的 RX 路径会释放、缩小或重新建立输入 buffer,同时更新 `ssl->in_buf`、`ssl->in_ctr` 等内部指针。 + +WebSocket 独立 TX 锁则允许出现: + +```text +WebSocket RX 任务 + └─ TLS read + └─ 动态释放或重建 RX buffer / in_ctr + +音频上行任务 + └─ TLS write + └─ renegotiation counter check 读取 in_ctr +``` + +如果两个动作刚好落入同一个很小的时序窗口,write 侧的 renegotiation 检查可能看到暂时为空的 `ssl->in_ctr`,随后在 `memcmp` 中访问空地址。 + +## 5. 根因结论及置信度 + +日志可以直接证明: + +- 崩溃位于音频上行 TLS write; +- 崩溃函数是 renegotiation counter check; +- 被传给 `memcmp` 的 TLS 输入计数器地址为 NULL; +- 当时没有堆申请失败; +- 当时没有 WebSocket stop、destroy 或 Wi-Fi 重关联。 + +结合动态 buffer 会改动 RX 内部指针、独立 TX 锁允许 RX/TX 并行,可以高置信度判断: + +> 本次崩溃是 mbedTLS renegotiation 写侧检查访问了无效的动态 RX counter 指针;最可能的触发方式是 WebSocket 并行收发期间的短暂时序竞态。 + +由于现场只保存了 panic 日志,没有保存完整 coredump 和两个任务在崩溃瞬间的同步状态,所以“具体是哪一次 RX buffer 释放与哪一包 TX 完全重叠”属于基于源码和回溯的推断,而不是日志能够逐指令直接证明的部分。 + +## 6. 为什么采用关闭 TLS renegotiation + +设备当前使用 TLS 的场景主要是: + +- HTTPS 请求设备 token; +- WSS 连接 DashScope 多模态服务; +- 其他普通 HTTPS API 请求。 + +这些连接依赖的是首次 TLS handshake、证书校验和连接断开后的重新建连,不依赖在同一条 TLS 1.2 连接中进行 renegotiation。 + +关闭 renegotiation 后,`mbedtls_ssl_write()` 不再编译或执行: + +```text +ssl_check_ctr_renegotiate() + -> memcmp(ssl->in_ctr, ...) +``` + +因此直接移除了本次崩溃所在的访问路径。 + +相比其他方案,这个修改更小: + +| 方案 | 效果与代价 | +| --- | --- | +| 关闭 TLS renegotiation | 直接移除崩溃路径;不增加常驻内存;不改变正常首次握手。当前采用。 | +| 关闭动态 TLS buffer | 也能减少 RX 指针动态变化,但会明显增加内部 RAM/PSRAM 常驻占用。暂不采用。 | +| 关闭 WebSocket 独立 TX 锁 | 强制收发争用同一把锁,可能增加实时音频发送延迟,并改变 WebSocket 调度行为。暂不采用。 | +| 修改 ESP-IDF/mbedTLS 内部源码 | 能增加空指针保护或同步,但会形成需要长期维护的 SDK 补丁。暂不采用。 | + +## 7. 修改方式 + +### 7.1 项目默认配置 + +在 `sdkconfig.defaults` 中加入: + +```text +# CONFIG_MBEDTLS_SSL_RENEGOTIATION is not set +``` + +这样新建 build 目录、清理配置或其他开发者重新生成 `sdkconfig` 时,默认都关闭 renegotiation。 + +### 7.2 当前本地构建配置 + +当前 `sdkconfig` 同步改为: + +```text +# CONFIG_MBEDTLS_SSL_RENEGOTIATION is not set +``` + +因此当前工作区下一次直接运行 `idf.py build` 就会使用关闭后的配置,不需要先删除 build 目录。 + +也可以在菜单中搜索配置符号: + +```text +idf.py menuconfig +按 / 搜索 MBEDTLS_SSL_RENEGOTIATION +``` + +搜索结果会显示该选项所在菜单位置。 + +## 8. 修改后的完整运行链 + +修改前: + +```text +麦克风 PCM + -> Opus 编码 + -> uplink_send_task + -> WebSocket send_bin + -> TLS write + -> 检查 renegotiation record counter + -> 读取动态 RX 的 in_ctr + -> 极端时序下 in_ctr=NULL + -> memcmp(NULL) + -> LoadProhibited + -> 设备重启 +``` + +修改后: + +```text +麦克风 PCM + -> Opus 编码 + -> uplink_send_task + -> WebSocket send_bin + -> TLS write + -> 不编译/不执行 renegotiation counter check + -> 正常写入 TLS record + -> 服务端接收音频 +``` + +连接真的失效时,仍走项目已有的恢复链: + +```text +TLS/WebSocket 发送失败 + -> transport error + -> 停止音频上行 + -> 回收旧 WebSocket + -> control-plane 重新检查鉴权 + -> 创建新 WebSocket +``` + +关闭 renegotiation 不会关闭这套断线重连能力。 + +## 9. 可能造成的影响 + +### 9.1 正常 HTTPS 和 WSS 首次握手不受影响 + +以下能力仍然保留: + +- TLS 1.2/1.3 首次握手; +- 服务端证书校验; +- ALPN; +- HTTPS 请求; +- WSS 建连; +- TLS session ticket; +- WebSocket 心跳; +- WebSocket 断开后的新连接; +- token 获取和刷新。 + +### 9.2 不再支持 TLS 1.2 同连接 renegotiation + +如果某个服务端要求在一条已经建立的 TLS 1.2 连接内重新协商密钥或客户端证书,设备将无法完成该动作。服务端可能关闭连接,设备随后按现有逻辑重新建立一条完整的新连接。 + +当前使用的 token HTTPS 接口和 DashScope WSS 流程没有表现出必须依赖 renegotiation 的行为,因此该兼容性风险较低。 + +TLS 1.3 本身不使用 TLS 1.2 风格的 renegotiation,因此关闭该选项不会移除 TLS 1.3 的正常密钥更新机制。 + +### 9.3 可能略微减少固件和运行时开销 + +关闭后,renegotiation 相关代码和状态不再参与构建或运行,理论上会略微减少固件体积和 TLS 状态开销。实际差值需要以构建产物为准,预计不会很大。 + +### 9.4 不解决其他内存或网络问题 + +本修改只针对这次 `ssl_check_ctr_renegotiate -> memcmp(NULL)` 崩溃,不会自动解决: + +- DMA/internal heap 水位下降; +- DNS `EAI_FAIL`; +- HTTPS `ESP_ERR_HTTP_CONNECT`; +- 服务端 TTS stall; +- Wi-Fi 关联异常; +- Opus 编解码资源问题。 + +## 10. 与近期修复的关系 + +### 与 fresh-auth WebSocket 重连的关系 + +没有冲突。关闭 renegotiation 处理的是一条已建立 TLS 连接内部的 renegotiation 检查;fresh-auth 修复处理的是 WebSocket 已经失败后,如何回收旧连接并创建新连接。 + +### 与下行 stall 恢复的关系 + +没有直接关系。下行 stall 恢复处理云端 TTS 长时间没有播放进展;本次崩溃发生在麦克风音频上行的 TLS write。 + +### 与 Wi-Fi 主动重关联的关系 + +没有直接关系。崩溃日志中没有主动 Wi-Fi reassociation,且该功能默认关闭。 + +## 11. 建议验证方法 + +### 11.1 基础回归 + +1. 烧录关闭 renegotiation 的固件; +2. 确认设备能正常获取 token; +3. 确认 WSS 能连接; +4. 分别测试本地唤醒和按键对话; +5. 确认 ASR、TTS 和多轮对话正常。 + +### 11.2 并行收发压力测试 + +1. 连续进行至少 100 轮语音对话; +2. 每轮保持 5~15 秒语音上行; +3. 在上行期间让服务端持续发送 Listening、ASR 或其他控制事件; +4. 观察是否再次出现 `ssl_check_ctr_renegotiate`、`memcmp` 或 `LoadProhibited`; +5. 同时记录 `alloc_failures` 和内存水位,区分本修复与其他资源问题。 + +### 11.3 网络异常回归 + +1. 在语音上行期间短暂断网; +2. 确认设备报告 WebSocket/TLS 发送失败,而不是 panic; +3. 恢复网络; +4. 确认旧 transport 被回收; +5. 确认重新鉴权建连后可以继续对话。 + +## 12. 验收标准 + +满足以下条件后,可以认为本修复通过真机验证: + +- 构建配置中 `CONFIG_MBEDTLS_SSL_RENEGOTIATION` 为关闭状态; +- token HTTPS 请求正常; +- DashScope WSS 正常连接; +- 本地唤醒与按键对话均正常; +- 连续高频语音上行不再出现相同回溯; +- 网络中断时进入受控错误与重连流程,而不是设备崩溃重启。 diff --git a/docs/knowledge vault/bugfix-downstream-stall-recovery.zh-CN.md b/docs/knowledge vault/bugfix-downstream-stall-recovery.zh-CN.md new file mode 100644 index 0000000..a4a833b --- /dev/null +++ b/docs/knowledge vault/bugfix-downstream-stall-recovery.zh-CN.md @@ -0,0 +1,375 @@ +# 下行音频卡死后的会话恢复:问题原因与修复方案 + +## 1. 对应提交 + +```text +a94df95 fix(multimodal): recover stalled downstream sessions +``` + +本文说明云端 TTS 下行音频在播放过程中长时间不再推进时,设备可能一直停留在“云端正在响应”的状态,导致后续无法唤醒或开始下一轮对话的问题。 + +## 2. 用户可见现象 + +典型过程如下: + +1. 设备已经成功唤醒并发送用户语音; +2. 云端开始返回 TTS,设备也开始播放; +3. 播放中途音频包停止到达,或播放器长时间没有进展; +4. 音频管线的保护逻辑终止本地播放; +5. 设备表面上不再播放,但云端会话仍可能保持 responding 或 active; +6. 后续再次说唤醒词或按键,无法顺利开始新会话; +7. 断开连接或重启设备后才恢复。 + +这个问题和“偶尔停顿后继续播放”不同。短暂网络抖动可以通过预缓冲或重缓冲吸收;这里处理的是已经超过保护阈值、被判定为不可继续等待的长时间 stall。 + +## 3. 原有逻辑的问题 + +### 3.1 stall 被当作普通 stopped + +修复前,音频管线检测到下行播放长期无进展后,会推进 playback generation、停止播放任务并通知: + +```text +MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_STOPPED +``` + +但 `STOPPED` 本身可能代表多种正常情况: + +- 用户主动打断; +- 上层主动停止; +- 会话正常切换; +- transport 关闭导致播放结束; +- 播放器因长时间无数据被保护逻辑强制终止。 + +把 stall 和正常 stopped 混在一起后,上层无法判断是否需要恢复云端会话。 + +### 3.2 本地播放器停止不等于云端任务结束 + +播放器只负责消费并播放已经收到的 Opus 数据。播放器停止后,服务端并不会自动知道设备已经放弃本次响应。 + +因此可能形成状态分裂: + +```text +设备音频管线:已经停止播放 +control-plane:仍认为任务存在 +云端:仍认为本轮响应或任务未结束 +WebSocket:物理连接可能仍显示 connected +``` + +此时“WebSocket 还连着”并不代表会话可复用。旧任务没有收尾,新一轮 RequestToSpeak、Start 或唤醒流程可能被旧状态阻塞。 + +### 3.3 直接重连会造成不必要的恢复开销 + +一旦 stall 就立刻关闭 WebSocket 虽然能清空状态,但会带来: + +- 重新鉴权和建连延迟; +- 额外网络请求; +- 正常可用的 WebSocket 被浪费; +- 偶发的服务端响应异常被放大为完整断线。 + +因此需要先尝试只结束当前任务,失败后再退休连接。 + +## 4. 根本原因 + +问题的根本原因是下行播放状态和云端会话状态之间缺少针对 stall 的闭环: + +```text +下行长时间没有新数据或播放进展 + ↓ +音频保护逻辑停止本地播放器 + ↓ +仅上报普通 STOPPED + ↓ +上层无法识别这是异常卡死 + ↓ +没有向云端结束当前任务,也没有退休 transport + ↓ +旧会话长期保持 active,后续唤醒被阻塞 +``` + +## 5. 修复方案 + +修复采用“两级恢复”,优先保留 WebSocket,无法收敛时再重连。 + +### 5.1 增加独立的 STALLED 事件 + +音频管线在保护 watchdog 判定响应卡死时,不再发送普通 `STOPPED`,而是发送: + +```text +MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_STALLED +``` + +该事件逐层传递: + +```text +audio pipeline + ↓ +platform callback + ↓ +domain event + ↓ +control-plane session service + ↓ +CONTROL_PLANE_MULTIMODAL_EVENT_DOWNSTREAM_STALLED +``` + +这样上层可以准确区分: + +| 事件 | 含义 | 是否启动异常恢复 | +| --- | --- | --- | +| `STOPPED` | 主动停止、打断或普通停止 | 否 | +| `COMPLETED` | 正常播放完成 | 否 | +| `STALLED` | 下行响应长期无进展,被保护机制终止 | 是 | + +### 5.2 只对云端响应启动会话恢复 + +session service 收到 `STALLED` 后,会检查当前是否确实存在云端响应: + +- 当前有有效 task id; +- 并且云端仍处于 responding,或者本轮已经收到过云端音频。 + +如果只是本地提示音卡住,不会修改云端 WebSocket 状态,避免本地素材问题误伤云端连接。 + +### 5.3 第一级恢复:请求服务端 Stop + +确认是云端下行响应 stall 后: + +1. 停止音频上行; +2. 设置 `s_downstream_stall_stop_pending`; +3. 停止继续接收和播放本轮迟到音频; +4. 发送 `finish-task / Stop` 指令; +5. 最多等待 2000 ms 的服务端确认。 + +如果收到服务端 `Stopped`: + +- 重置当前 session state; +- 停止残留的本地下行播放; +- 通知上层服务端已经不再 listening; +- 清除 stall recovery 状态; +- 保留当前 WebSocket,供下一轮对话复用。 + +这是成本最低的恢复路径。 + +### 5.4 第二级恢复:退休 transport 并重新连接 + +以下情况会进入第二级恢复: + +- Stop 指令发送失败; +- 发送 Stop 后 2000 ms 内没有收到服务端确认; +- 当前连接已经断开。 + +对于前两种情况,旧 transport 会被标记为: + +```text +CONTROL_PLANE_TRANSPORT_LOSS_DOWNSTREAM_STALL +``` + +随后执行: + +```text +清除 stall recovery 状态 + ↓ +将旧 transport 标记为不可用 + ↓ +停止旧 WebSocket + ↓ +调度新的 WebSocket 启动 + ↓ +重新进入鉴权和建连流程 +``` + +这样即使服务端任务已经卡死、无法响应 Stop,设备也不会无限等待。 + +### 5.5 watchdog 负责有界等待 + +恢复流程由已有多模态 watchdog 推进,而不是在音频回调里阻塞等待。 + +内部记录三个状态: + +```text +s_downstream_stall_recovery_pending +s_downstream_stall_stop_sent +s_downstream_stall_recovery_started_ms +``` + +它们分别表示: + +- 是否存在待处理的 stall 恢复; +- Stop 是否已经发出; +- 等待 Stop 确认的起始时间。 + +这样音频线程只负责报告事实,控制层异步完成网络操作和超时处理,避免阻塞播放任务或 WebSocket 回调。 + +### 5.6 启停和连接异常时清理恢复状态 + +以下场景会主动清除 stall recovery 状态: + +- transport disconnected; +- transport error; +- task failed; +- 多模态客户端重新启动; +- 多模态客户端停止。 + +目的是避免旧会话的 stall 标记污染下一条连接或下一轮任务。 + +## 6. 修复后的状态流转 + +### 6.1 服务端正常确认 Stop + +```text +检测到下行 stall + ↓ +上报 STALLED + ↓ +确认是云端响应 + ↓ +发送 Stop + ↓ +服务端返回 Stopped(不超过 2 秒) + ↓ +清理当前任务状态 + ↓ +保留 WebSocket + ↓ +下一轮可以继续使用 +``` + +### 6.2 Stop 失败或确认超时 + +```text +检测到下行 stall + ↓ +发送 Stop 失败,或等待超过 2 秒 + ↓ +将旧 transport 标记为 downstream_stall + ↓ +关闭旧 WebSocket + ↓ +调度重新鉴权和建连 + ↓ +恢复到可接受新会话的状态 +``` + +### 6.3 本地提示音 stall + +```text +检测到本地播放 stall + ↓ +没有有效云端响应 + ↓ +仅结束本地播放 + ↓ +不发送云端 Stop + ↓ +不关闭 WebSocket +``` + +## 7. 为什么等待时间是 2 秒 + +`CONTROL_PLANE_DOWNSTREAM_STALL_STOP_GRACE_MS` 当前为 2000 ms。 + +它不是播放提示音或采集音频的固定延迟,而只是 stall 已经发生后,等待服务端确认 Stop 的最长保护时间。 + +选择有界等待的原因是: + +- 完全不等待会导致每次 stall 都立刻重连; +- 无限等待会让设备再次卡死; +- 2 秒给正常的服务端控制消息留出响应时间; +- 超时后仍能保证设备最终自愈。 + +在这 2 秒内,当前异常会话不会被当作正常会话继续使用。 + +## 8. 修复的影响范围 + +### 会改变的行为 + +- 云端 TTS 被确认 stall 后,会主动结束当前任务; +- Stop 无法确认时会关闭并重建 WebSocket; +- stall 恢复期间会停止音频上行并丢弃当前响应的迟到音频; +- 最坏情况下,下一轮对话需要等待旧连接退休和新连接建立。 + +### 不会改变的行为 + +- 正常 TTS 播放完成不会触发恢复; +- 用户主动打断产生的普通 stopped 不会误触发 stall 重连; +- 本地提示音 stall 不会主动关闭云端连接; +- 该提交不负责修复 Opus Decoder 内存泄漏; +- 该提交不负责 Wi-Fi STA 重新关联路由器; +- 该提交不解决短时抖动导致的普通卡顿,短时抖动由缓冲策略处理。 + +## 9. 关键日志 + +正常进入恢复时: + +```text +Cloud downstream playback stalled; request bounded session recovery +Downstream stall recovery queued for watchdog worker +Downstream stall recovery started: request Stop and wait up to 2000 ms +Request Stop for stalled downstream response +``` + +服务端及时确认时: + +```text +Downstream stall Stop acknowledged; transport remains reusable +Downstream stall recovered by server Stop acknowledgement; keep WebSocket +``` + +Stop 发送失败时: + +```text +Downstream stall Stop request failed: ...; retire transport now +``` + +等待超时时: + +```text +Downstream stall Stop acknowledgement timed out after 2000 ms; retire transport +``` + +如果进入 transport 退休,还应继续看到重新调度 WebSocket 的日志。 + +## 10. 建议验证方法 + +### 10.1 正常流程回归 + +1. 连续完成至少 20 轮正常对话; +2. 验证正常 TTS 完成不会出现 `DOWNSTREAM_STALLED`; +3. 验证用户按键打断和唤醒打断不会触发不必要的 WebSocket 重连。 + +### 10.2 stall 后服务端能够确认 Stop + +1. 人为阻断下行音频数据,但保持 WebSocket 控制消息可用; +2. 等待本地 watchdog 判断 stall; +3. 确认设备发送 Stop; +4. 确认收到 `Stopped` 后保留原 WebSocket; +5. 立即开始下一轮唤醒,确认可以正常对话。 + +### 10.3 stall 后服务端不确认 Stop + +1. 人为阻断服务端响应或所有 WebSocket 数据; +2. 确认设备等待不超过 2000 ms; +3. 确认旧 transport 被退休; +4. 恢复网络后确认设备自动建立新连接; +5. 确认无需重启设备即可再次唤醒。 + +### 10.4 本地提示音回归 + +1. 播放本地唤醒提示音或系统提示音; +2. 人为制造本地播放异常; +3. 确认日志显示本地 stall; +4. 确认没有发送云端 Stop,也没有关闭健康的 WebSocket。 + +## 11. 涉及文件 + +```text +components/control_plane/internal/control_plane_internal.h +components/control_plane/src/multimodal/control_plane_multimodal_runtime.c +components/control_plane/src/multimodal/control_plane_multimodal_session_service.c +components/domain/include/domain.h +components/domain/src/runtime/domain_runtime_adapters.c +components/platform/include/platform.h +components/platform/internal/multimodal/audio_pipeline.h +components/platform/src/multimodal/audio_pipeline/audio_pipeline_runtime_impl.c +components/platform/src/runtime/platform_multimodal_runtime.c +``` + diff --git a/docs/knowledge vault/bugfix-persistent-local-responding-worker.zh-CN.md b/docs/knowledge vault/bugfix-persistent-local-responding-worker.zh-CN.md new file mode 100644 index 0000000..d686cea --- /dev/null +++ b/docs/knowledge vault/bugfix-persistent-local-responding-worker.zh-CN.md @@ -0,0 +1,577 @@ +# Local Responding Worker 持久化:内存下降问题原因、证据与修复说明 + +## 1. 文档目的 + +本文说明以下提交解决的问题、源码根因、现场证据、修改方式和剩余边界: + +```text +4e7fadd fix(multimodal): keep local responding worker persistent +``` + +该提交针对的是多轮语音对话过程中 internal heap 和 DMA-capable heap 持续下降的问题。修改后,负责发送 `LocalRespondingStarted` 和 `LocalRespondingEnded` 的任务不再每次空闲 5 秒后退出,而是在当前多模态客户端生命周期内保持常驻。 + +## 2. Local Responding Worker 是什么 + +服务端下发 TTS 音频后,设备需要向服务端报告两个本地播放事件: + +```text +LocalRespondingStarted +LocalRespondingEnded +``` + +它们分别表示: + +- 设备已经开始播放当前云端响应; +- 设备已经播放完成或停止当前云端响应。 + +这些事件不能直接在音频播放回调中完成所有网络发送,原因包括: + +- WebSocket send 可能等待锁或发生网络阻塞; +- 音频回调需要尽快返回; +- 在音频任务上下文中直接进行复杂网络操作会放大卡顿风险; +- 回调、会话状态锁和 WebSocket 锁之间可能形成不安全的锁顺序。 + +因此项目使用一个独立 worker: + +```text +音频播放事件 + ↓ +写入 local responding event queue + ↓ +mm_local_evt worker 读取事件 + ↓ +核对 task_id 和会话状态 + ↓ +发送 LocalRespondingStarted / LocalRespondingEnded +``` + +该 worker 的主要参数: + +```text +任务名:mm_local_evt +栈大小:4096 bytes +优先级:5 +队列长度:4 +运行核心:CONFIG_TQ_LOCAL_TASK_CORE +``` + +## 3. 修复前的生命周期 + +修复前,worker 不是在多模态客户端启动时创建,而是在第一次需要发送 responding 事件时按需创建: + +```text +TTS 开始或结束 + ↓ +queue_local_responding_event() + ↓ +ensure_local_responding_event_worker() + ↓ +如果任务不存在,则创建 mm_local_evt +``` + +worker 等待队列时设置了 5 秒超时: + +```c +#define CONTROL_PLANE_LOCAL_RESPONDING_EVENT_IDLE_EXIT_MS 5000U + +if (xQueueReceive(queue, &msg, idle_ticks) != pdTRUE) { + s_local_responding_event_task = NULL; + vTaskDelete(NULL); + return; +} +``` + +因此每轮对话之间只要超过 5 秒没有新 responding 事件,就会发生: + +```text +worker 空闲 5 秒 + ↓ +将任务句柄清空 + ↓ +任务调用 vTaskDelete(NULL) 自删除 + ↓ +下一轮 TTS 再次创建 worker +``` + +实际运行模式变成了频繁的: + +```text +创建 → 工作 → 空闲 → 删除 → 创建 → 工作 → 空闲 → 删除 +``` + +## 4. 真正的内存泄漏原因 + +### 4.1 worker 使用 WithCaps API 创建 + +当前配置允许把任务栈放到外部 PSRAM: + +```text +CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM=y +``` + +因此 `CONTROL_PLANE_LOCAL_RESPONDING_TASK_WITH_CAPS=1`,worker 实际通过以下 API 创建: + +```c +xTaskCreatePinnedToCoreWithCaps( + local_responding_event_worker, + "mm_local_evt", + 4096, + ..., + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +``` + +其内存分布大致为: + +| 对象 | 主要内存区域 | +| --- | --- | +| 4096-byte 任务栈 | PSRAM;失败时回退到 internal RAM | +| FreeRTOS TCB | internal RAM | +| 调度和任务管理数据 | internal/DMA-capable heap 所在内存池 | +| responding event queue | 优先放到 PSRAM | + +### 4.2 WithCaps 任务不能用普通 vTaskDelete 删除 + +ESP-IDF v5.5.4 对 `xTaskCreatePinnedToCoreWithCaps()` 有明确要求: + +```text +A task created using this function must only be deleted using +vTaskDeleteWithCaps(). +``` + +原因是 WithCaps API 自己分配 stack 和 TCB,再以静态任务的形式交给 FreeRTOS。普通 `vTaskDelete()` 只完成 FreeRTOS 任务删除,Idle task 不知道如何释放 WithCaps API 额外管理的 stack 和 TCB。 + +ESP-IDF 自己的实现注释也直接说明: + +```text +the idle task will not free the task TCB and stack memories we created +statically during xTaskCreateWithCaps() or xTaskCreatePinnedToCoreWithCaps(). +Therefore, it will leak memory. +``` + +修复前,worker 恰好使用了错误组合: + +```text +xTaskCreatePinnedToCoreWithCaps(...) + ↓ +vTaskDelete(NULL) +``` + +这不是单纯的 heap 碎片,而是 ESP-IDF API 生命周期不匹配导致的真实任务内存泄漏。 + +## 5. 为什么主要表现为 internal/DMA 水位下降 + +虽然 worker 的 4096-byte stack 优先放到 PSRAM,但 TCB 必须位于 internal RAM。ESP-IDF 文档明确指出,WithCaps 的 capability 只作用于 stack,不作用于 TCB。 + +ESP32-S3 的许多 internal RAM 块同时带有 DMA capability,因此同一块内存没有释放时,可能同时表现为: + +```text +free_internal 下降 +free_dma 下降 +largest_dma 下降或无法恢复 +``` + +这也是为什么当时看起来像“DMA 内存持续泄漏”,但真正泄漏对象不一定是 I2S DMA descriptor 或音频 DMA buffer,而可能是任务 TCB 和相关管理内存。 + +## 6. 现场日志证据 + +### 6.1 修复前:每轮对话 internal heap 继续下降 + +`monitor-20260812-153303` 中,多轮对话开始前的内存值为: + +| 时间 | free_internal | largest_dma | +| ---: | ---: | ---: | +| 26947 ms | 54775 | 31744 | +| 47037 ms | 53807 | 31744 | +| 61507 ms | 53323 | 31744 | +| 75057 ms | 52959 | 31744 | +| 87847 ms | 52475 | 31744 | + +`free_internal` 的变化: + +```text +54775 + ↓ -968 +53807 + ↓ -484 +53323 + ↓ -364 +52959 + ↓ -484 +52475 +``` + +它不是只在第一次初始化时下降,而是随着多轮对话继续降低。这与 worker 每隔 5 秒退出、下一轮重新创建的节奏一致。 + +`largest_dma` 在这段日志中保持 31744,说明当时最大的连续块暂时没有变小,但总 internal/DMA 可用量正在减少。持续足够多轮后,最大连续块仍可能受到影响。 + +### 6.2 修复后:worker 只启动一次 + +`monitor-20260812-154459` 中出现: + +```text +I (19727) cp_mm_session: Local responding event worker started (persistent) +``` + +在随后大量对话中没有重复出现该启动日志,说明 worker 没有因为 5 秒空闲而反复退出和重建。 + +### 6.3 修复后:水位进入稳定平台 + +同一份日志中的多轮上行前内存值: + +| 时间 | free_internal | largest_dma | +| ---: | ---: | ---: | +| 26997 ms | 54399 | 31744 | +| 44127 ms | 54131 | 31744 | +| 62017 ms | 54131 | 31744 | +| 78857 ms | 53995 | 31744 | +| 96607 ms | 53995 | 31744 | +| 116377 ms | 53995 | 31744 | +| 134727 ms | 53995 | 31744 | +| 152147 ms | 53867 | 31744 | +| 171657 ms | 53867 | 31744 | +| 213507 ms | 53867 | 31744 | +| 237227 ms | 53867 | 31744 | +| 258507 ms | 53867 | 31744 | +| 279227 ms | 53867 | 31744 | + +初始化和前几轮存在少量正常波动,之后长期稳定在约 53867~53995 bytes,`largest_dma` 一直为 31744 bytes。 + +这证明持久化 worker 对“每轮对话后继续下降”的现象具有直接效果。 + +### 6.4 另一份长时间日志也进入稳定平台 + +`monitor-20260812-155603` 中,系统因为整体配置和当次启动状态不同,internal/DMA 基线更低,但稳定后多轮数据基本保持: + +```text +free_internal ≈ 21083 +largest_dma = 10240 +``` + +即使继续进行大量对话,也没有恢复到修复前那种每轮固定下降的趋势。 + +这进一步说明判断重点应是“是否持续下降”,而不是不同固件启动后的绝对水位是否完全相同。 + +## 7. 本次提交的修改方案 + +### 7.1 删除 5 秒空闲退出机制 + +移除了: + +```text +CONTROL_PLANE_LOCAL_RESPONDING_EVENT_IDLE_EXIT_MS +``` + +worker 改为永久等待队列: + +```c +xQueueReceive(queue, &msg, portMAX_DELAY) +``` + +没有事件时任务处于 Blocked 状态,不占用 CPU 时间,也不会周期性唤醒轮询。 + +### 7.2 在多模态客户端启动时创建 worker + +修复前是在每个 responding 事件到来时才调用 `ensure_local_responding_event_worker()`。 + +修复后,在 `control_plane_multimodal_start()` 中完成鉴权准备后、注册播放回调和启动 transport 前创建 worker: + +```text +多模态 start + ↓ +准备鉴权 + ↓ +创建 persistent local responding worker + ↓ +注册下行播放 callback + ↓ +启动 WebSocket transport +``` + +好处包括: + +- 资源不足会在启动阶段明确失败; +- 音频播放回调不再临时创建任务; +- 事件到来时 worker 已经就绪; +- responding 事件发送延迟更加确定。 + +### 7.3 事件入队路径不再负责创建任务 + +`queue_local_responding_event()` 现在只获取已存在的 queue 和 task: + +```c +queue = s_local_responding_event_task != NULL ? + s_local_responding_event_queue : NULL; +``` + +如果 worker 不可用,会清除 pending 标志并记录警告,而不会在音频事件路径中分配新任务资源。 + +### 7.4 只在明确停止多模态客户端时退出 + +worker 仍支持 `LOCAL_RESPONDING_EVENT_QUIT`,但只在以下情况主动停止: + +- 多模态启动后注册下行回调失败; +- transport 启动失败; +- 调用 `control_plane_multimodal_stop()` 正式停止多模态客户端。 + +因此“persistent”表示: + +> worker 在一次多模态客户端生命周期内持续存在,而不是从设备启动到关机永远不退出。 + +## 8. 修改前后的完整链条 + +### 8.1 修改前 + +```text +TTS 开始 + ↓ +临时创建 WithCaps worker + ↓ +发送 LocalRespondingStarted + ↓ +TTS 结束 + ↓ +发送 LocalRespondingEnded + ↓ +空闲 5 秒 + ↓ +worker 使用普通 vTaskDelete 自删除 + ↓ +WithCaps stack/TCB 没有被正确释放 + ↓ +下一轮重新创建 worker + ↓ +internal/DMA/PSRAM 再损失一部分 + ↓ +多轮对话后可用内存持续下降 +``` + +### 8.2 修改后 + +```text +多模态客户端启动 + ↓ +创建一次 WithCaps worker + ↓ +worker 阻塞等待事件 + ↓ +TTS 开始 → 发送 LocalRespondingStarted + ↓ +继续阻塞等待 + ↓ +TTS 结束 → 发送 LocalRespondingEnded + ↓ +继续阻塞等待 + ↓ +后续多轮复用同一个任务和队列 + ↓ +不再产生每轮 create/delete 泄漏 +``` + +## 9. 为什么常驻任务不会持续消耗 CPU + +worker 使用: + +```c +xQueueReceive(queue, &msg, portMAX_DELAY) +``` + +队列为空时,FreeRTOS 会将任务置为 Blocked: + +- 不参与可运行任务竞争; +- 不会不断轮询; +- 不会每隔固定时间唤醒; +- CPU 可以运行其他任务或进入 idle。 + +因此持久化增加的是固定内存占用,不是持续 CPU 占用。 + +## 10. 资源影响 + +### 10.1 固定占用增加 + +多模态客户端存活期间固定保留: + +- 一个 4096-byte worker stack,优先位于 PSRAM; +- 一个 internal RAM 中的 FreeRTOS TCB; +- 一个长度为 4 的事件队列,优先位于 PSRAM; +- 少量同步和队列管理数据。 + +与旧设计相比,这些资源不会在空闲 5 秒后尝试释放。 + +### 10.2 总体长期占用反而下降 + +旧设计表面上会删除空闲任务,但因为删除 API 与创建 API不匹配,实际没有完整回收任务内存。每轮重新创建都会继续累积。 + +新设计只保留一份固定资源: + +```text +旧设计:固定资源 × 反复泄漏次数 +新设计:固定资源 × 1 +``` + +因此设备运行时间越长、对话轮数越多,持久化方案的资源优势越明显。 + +### 10.3 响应延迟略有改善 + +事件产生时不再创建任务,`LocalRespondingStarted/Ended` 可以直接入队。虽然差异通常很小,但减少了任务分配和调度的不确定性。 + +## 11. 对业务行为的影响 + +### 不变的行为 + +- `LocalRespondingStarted` 和 `LocalRespondingEnded` 的协议内容不变; +- 仍通过独立 worker 异步发送; +- 仍使用 task_id 过滤迟到或旧会话事件; +- 仍使用 pending 标志防止重复入队; +- 队列满时仍会记录警告并清除 pending 状态; +- WebSocket、鉴权、ASR、TTS 和音频编解码流程不变。 + +### 改变的行为 + +- worker 不再因空闲 5 秒自动退出; +- worker 在多模态 start 阶段创建; +- 如果 worker 创建失败,多模态启动会明确失败; +- responding 回调路径不再临时分配任务资源; +- worker 只在完整停止多模态客户端时退出。 + +## 12. 原提交的边界与后续补充修复 + +`4e7fadd` 解决了最频繁的泄漏来源,但该提交完成时仍有一个生命周期边界: + +worker 收到 `LOCAL_RESPONDING_EVENT_QUIT` 后仍调用: + +```c +vTaskDelete(NULL); +``` + +而该任务在当前配置下由 `xTaskCreatePinnedToCoreWithCaps()` 创建。按照 ESP-IDF API 要求,理论上应该使用: + +```c +vTaskDeleteWithCaps(NULL); +``` + +因此仅看 `4e7fadd` 时,实际效果是: + +```text +修复前:每次空闲超过 5 秒都可能泄漏一次 +修复后:正常多轮对话不再泄漏;完整停止/重建多模态客户端时仍可能泄漏一次 +``` + +这解释了为什么现场长时间正常对话已经趋于稳定,但如果设备频繁发生 WebSocket 退休、multimodal stop/start,仍应继续关注 internal/DMA 水位。 + +当前分支已经追加补齐删除路径,新增统一封装: + +```c +static void delete_local_responding_event_task(TaskHandle_t task) +{ +#if CONTROL_PLANE_LOCAL_RESPONDING_TASK_WITH_CAPS + vTaskDeleteWithCaps(task); +#else + vTaskDelete(task); +#endif +} +``` + +worker 收到 `LOCAL_RESPONDING_EVENT_QUIT` 后改为调用: + +```c +delete_local_responding_event_task(NULL); +``` + +这样创建和删除方式保持严格配对: + +| 创建方式 | 删除方式 | +| --- | --- | +| `xTaskCreatePinnedToCoreWithCaps()` | `vTaskDeleteWithCaps()` | +| `xTaskCreatePinnedToCore()` | `vTaskDelete()` | + +补充修复后,正常多轮复用和完整 stop/start 两种生命周期都不再依赖错误的普通任务删除路径。需要注意,这部分是 `4e7fadd` 之后的补充修复,不应被描述为原提交本身已经包含的代码。 + +## 13. 与其他内存修改的关系 + +### 与 Opus Decoder reset/reuse 无关 + +现场关闭 MultiNet、关闭唤醒提示音、使用按键对话后,内存仍会下降;而持久化 responding worker 后趋势明显稳定。这说明该问题与 Opus Decoder 是否复用不是同一个原因。 + +### 与内存水位监测的关系 + +内存水位监测没有修复泄漏,它提供了定位证据: + +- `free_internal` 是否逐轮下降; +- `largest_dma` 是否缩小; +- 是否发生 allocation failure; +- 当时处于 PTT、listening、responding 还是 uplink 状态。 + +### 与 local wake 无关 + +按键对话也会触发云端 TTS 和 local responding 事件,因此旧 worker 生命周期问题在按键流程中同样存在。它不是 WakeNet 或 MultiNet 专属问题。 + +## 14. 建议验证方法 + +### 14.1 正常多轮对话测试 + +1. 重启设备并保存完整 monitor 日志; +2. 等待 WebSocket 连接成功; +3. 确认只出现一次: + +```text +Local responding event worker started (persistent) +``` + +4. 连续完成至少 50 轮对话; +5. 每轮之间等待超过 5 秒,覆盖旧逻辑的 idle exit 条件; +6. 比较每轮 `Heap before_uplink_start`; +7. 确认 `free_internal` 和 `largest_dma` 在初始化后进入稳定区间。 + +### 14.2 本地唤醒和按键分别测试 + +- 使用“喵掌柜”完成至少 20 轮; +- 使用 PTT 按键完成至少 20 轮; +- 确认两种入口都不会引起 worker 重建或逐轮下降。 + +### 14.3 transport 重建压力测试 + +删除路径补齐后,仍建议通过 transport 重建验证完整生命周期: + +1. 人为制造 WebSocket error/disconnected; +2. 等待旧 transport 回收和 fresh-auth 重连; +3. 重复至少 20 次; +4. 统计每次 `Local responding event worker started (persistent)`; +5. 对比每次重连前后的 `free_internal`、`free_spiram` 和 `largest_dma`。 + +如果每次完整重建仍产生固定下降,则说明还有其他 stop/start 生命周期资源没有释放,应根据水位变化继续定位;此时 local responding worker 已经使用正确的 WithCaps 删除路径。 + +## 15. 验收标准 + +该提交本身的验收标准: + +- 一次多模态客户端生命周期内 worker 只创建一次; +- 对话间隔超过 5 秒后 worker 不退出; +- 多轮 TTS 的 Started/Ended 事件正常发送; +- 正常多轮对话不再出现 internal/DMA 逐轮下降; +- worker 阻塞等待期间没有额外 CPU 负载; +- multimodal start 失败时 worker 能进入受控清理路径。 + +完整生命周期进一步完善后的标准: + +- 正常多轮对话水位稳定; +- 反复 stop/start 和 transport 重建后水位也稳定; +- 所有 WithCaps 任务都通过 `vTaskDeleteWithCaps()` 或匹配的统一封装删除。 + +## 16. 涉及文件 + +该提交只修改: + +```text +components/control_plane/src/multimodal/control_plane_multimodal_session_service.c +``` + +主要涉及函数: + +```text +local_responding_event_worker() +ensure_local_responding_event_worker() +queue_local_responding_event() +control_plane_multimodal_start() +stop_local_responding_event_worker() +control_plane_multimodal_stop() +``` diff --git a/docs/knowledge vault/bugfix-wall-clock-timezone-before-lcd.zh-CN.md b/docs/knowledge vault/bugfix-wall-clock-timezone-before-lcd.zh-CN.md new file mode 100644 index 0000000..1227cc0 --- /dev/null +++ b/docs/knowledge vault/bugfix-wall-clock-timezone-before-lcd.zh-CN.md @@ -0,0 +1,289 @@ +# 开机时钟小时偏差问题修复说明 + +## 1. 提交信息 + +- 提交:`f33ad70 fix(time): apply timezone before lcd startup` +- 现象:设备每次开机后,LCD 显示的分钟正确,但小时错误;网络连接并完成时间服务初始化后,小时恢复正确。 +- 复现场景:普通重启、Deep Sleep 唤醒、开机后暂时没有网络。 + +## 2. 问题原因 + +ESP32 的系统时间和 RTC 时间使用 UTC 时间戳保存绝对时间。LCD 将时间戳转换成年、月、日、小时和分钟时,会通过 `localtime_r()` 使用当前进程的 `TZ` 环境变量进行本地时区换算。 + +旧实现只在 Wi-Fi 连接成功、准备启动 SNTP 时执行: + +```c +setenv("TZ", posix_tz, 1); +tzset(); +``` + +但 LCD 的初始化和渲染发生在 Wi-Fi 连接及 SNTP 启动之前。因此,设备刚启动时虽然 RTC 中保留的 UTC 时间戳仍然有效,但当前进程尚未设置中国标准时间时区,`localtime_r()` 会按 UTC 解释时间。这会产生以下特征: + +- 分钟不受 UTC 与中国标准时间时差影响,所以分钟显示正确。 +- 小时少 8 小时,因此小时显示错误。 +- Wi-Fi 连接成功后,SNTP 启动流程设置 `TZ=CST-8`,小时随即恢复正确。 + +Deep Sleep 也会复现该问题,是因为 RTC 可以继续保留绝对时间,而应用进程重新启动后,进程级的 `TZ` 环境状态需要重新设置。也就是说,保留 RTC 时间并不等于保留本地时区配置。 + +修复前的启动顺序为: + +```text +存储准备 +-> 音频初始化 +-> LCD 初始化及时间渲染 +-> Wi-Fi 连接 +-> 运行时任务启动 +-> SNTP 启动并设置时区 +``` + +问题本质不是 RTC 或 SNTP 保存了错误的小时,而是本地时区设置得太晚。 + +## 3. 解决方式 + +将“应用本地时区”和“通过网络同步绝对时间”拆分为两个可以独立执行的操作: + +1. 新增不依赖网络的时区设置接口,只负责设置 `TZ` 并调用 `tzset()`。 +2. 按照现有分层,通过 `platform -> domain -> control_plane -> app_composition` 逐层暴露该能力。 +3. 在存储准备完成后、LCD 初始化之前增加 `wall_clock` 启动阶段,立即应用配置项 `CONFIG_TQ_TIME_POSIX_TZ`。 +4. SNTP 仍在网络连接成功后启动,用于校准 UTC 绝对时间;启动 SNTP 时再次应用时区,作为后续保护。 +5. 如果开机阶段设置时区失败,记录警告并继续启动,避免时区异常阻断整个设备启动流程。 + +修复后的启动顺序为: + +```text +存储准备 +-> 本地应用 CST-8 时区 +-> 电池启动保护及音频初始化 +-> LCD 初始化及时间渲染 +-> Wi-Fi 连接 +-> SNTP 校准绝对时间 +``` + +其中,`CST-8` 是 POSIX 时区字符串。POSIX 的符号约定与通常书写习惯相反,`CST-8` 表示 UTC+8。 + +## 4. 具体代码 + +### 4.1 Platform 层:新增独立时区设置接口 + +公开接口声明: + +```c +/** + * @brief Wall-clock time synchronization (SNTP) primitives. + * + * - `platform_time_set_timezone()` applies the process-local POSIX time zone + * without starting SNTP. Call it during boot before local time is rendered. + * - `platform_time_sync_start()` is idempotent: it sets the process time zone + * (`posix_tz`, e.g. "CST-8"; ignored when NULL/empty) on every call and + * starts the SNTP client once. Must be called after the network is up. + * - `platform_time_is_synced()` reports whether the system clock has advanced + * past a sane epoch lower bound (i.e. SNTP has set the time at least once). + */ +esp_err_t platform_time_set_timezone(const char *posix_tz); +esp_err_t platform_time_sync_start(const char *sntp_server, const char *posix_tz); +bool platform_time_is_synced(void); +``` + +接口实现: + +```c +esp_err_t platform_time_set_timezone(const char *posix_tz) +{ + if (posix_tz == NULL || posix_tz[0] == '\0') { + return ESP_ERR_INVALID_ARG; + } + if (setenv("TZ", posix_tz, 1) != 0) { + ESP_LOGE(TAG, "Set time zone failed (tz=%s)", posix_tz); + return ESP_FAIL; + } + tzset(); + ESP_LOGI(TAG, "Local time zone applied (tz=%s)", posix_tz); + return ESP_OK; +} +``` + +该实现先校验参数,再更新进程级 `TZ` 环境变量并刷新 C 运行库的时区换算规则。此过程不访问网络,也不修改 RTC 中的 UTC 时间戳。 + +### 4.2 Platform 层:SNTP 启动复用时区接口 + +```c +esp_err_t platform_time_sync_start(const char *sntp_server, const char *posix_tz) +{ + if (posix_tz != NULL && posix_tz[0] != '\0') { + esp_err_t timezone_err = platform_time_set_timezone(posix_tz); + if (timezone_err != ESP_OK) { + return timezone_err; + } + } + + portENTER_CRITICAL(&s_time_lock); + if (s_sntp_started) { + portEXIT_CRITICAL(&s_time_lock); + return ESP_OK; + } + s_sntp_started = true; + portEXIT_CRITICAL(&s_time_lock); + + const char *server = (sntp_server != NULL && sntp_server[0] != '\0') + ? sntp_server + : "pool.ntp.org"; + + esp_sntp_config_t config = ESP_NETIF_SNTP_DEFAULT_CONFIG(server); + config.start = true; + config.server_from_dhcp = false; + + esp_err_t err = esp_netif_sntp_init(&config); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + portENTER_CRITICAL(&s_time_lock); + s_sntp_started = false; + portEXIT_CRITICAL(&s_time_lock); + ESP_LOGE(TAG, "SNTP init failed: %s", esp_err_to_name(err)); + return err; + } + + ESP_LOGI(TAG, "SNTP started (server=%s, tz=%s)", + server, (posix_tz != NULL && posix_tz[0] != '\0') ? posix_tz : ""); + return ESP_OK; +} +``` + +SNTP 启动仍保持幂等。每次调用都会先应用有效的时区配置,但 SNTP 客户端只初始化一次。 + +### 4.3 Domain 层:桥接时区能力 + +公开接口声明: + +```c +/** + * @brief Wall-clock time synchronization, bridged to the platform SNTP + * primitive. `set_timezone` applies local-time conversion without + * network access. `start` is idempotent and also applies the process + * time zone (`posix_tz`, e.g. "CST-8"); `is_synced` reports whether the + * clock has been set. Call `start` after the network is up. + */ +esp_err_t domain_runtime_time_set_timezone(const char *posix_tz); +esp_err_t domain_runtime_time_sync_start(const char *sntp_server, const char *posix_tz); +bool domain_runtime_time_is_synced(void); +``` + +桥接实现: + +```c +esp_err_t domain_runtime_time_set_timezone(const char *posix_tz) +{ + return platform_time_set_timezone(posix_tz); +} +``` + +### 4.4 Control Plane 层:使用项目配置准备本地时钟 + +时区配置缺省值: + +```c +#ifndef CONFIG_TQ_TIME_POSIX_TZ +#define CONFIG_TQ_TIME_POSIX_TZ "CST-8" +#endif +``` + +公开接口声明: + +```c +/** Apply the configured local time zone before any local time is rendered. */ +esp_err_t control_plane_prepare_wall_clock(void); +``` + +接口实现: + +```c +esp_err_t control_plane_prepare_wall_clock(void) +{ + return domain_runtime_time_set_timezone(CONFIG_TQ_TIME_POSIX_TZ); +} +``` + +### 4.5 App Composition 层:增加开机阶段 + +启动阶段枚举中增加 `APP_COMPOSITION_BOOT_STAGE_WALL_CLOCK`: + +```c +typedef enum { + APP_COMPOSITION_BOOT_STAGE_POWER_HOLD = 1, + APP_COMPOSITION_BOOT_STAGE_STORAGE_PREPARE, + APP_COMPOSITION_BOOT_STAGE_WALL_CLOCK, + APP_COMPOSITION_BOOT_STAGE_AUDIO_INIT, + APP_COMPOSITION_BOOT_STAGE_BOOT_SOUND, + APP_COMPOSITION_BOOT_STAGE_AUDIO_READY, + APP_COMPOSITION_BOOT_STAGE_LCD_DEVICE, + APP_COMPOSITION_BOOT_STAGE_LCD_RENDER_LOOP, + APP_COMPOSITION_BOOT_STAGE_PLATFORM_DOMAIN, + APP_COMPOSITION_BOOT_STAGE_TURNTABLE_CALIBRATION, + APP_COMPOSITION_BOOT_STAGE_WIFI_BOOTSTRAP, + APP_COMPOSITION_BOOT_STAGE_RUNTIME_WORKERS, + APP_COMPOSITION_BOOT_STAGE_NETWORK_SERVICES, +} app_composition_boot_stage_t; +``` + +在存储准备完成之后、LCD 和网络初始化之前调用: + +```c +app_composition_log_stage(APP_COMPOSITION_BOOT_STAGE_WALL_CLOCK, + "wall_clock", + "control_plane_prepare_wall_clock"); +esp_err_t wall_clock_err = control_plane_prepare_wall_clock(); +app_composition_log_stage_result(APP_COMPOSITION_BOOT_STAGE_WALL_CLOCK, + "wall_clock", + wall_clock_err); +if (wall_clock_err != ESP_OK) { + ESP_LOGW(TAG, + "Local time zone setup failed, continue boot: %s", + esp_err_to_name(wall_clock_err)); +} +``` + +## 5. 解决成果 + +- 代码路径已确保在 LCD 首次渲染时间前应用 `CST-8`;RTC 时间有效时,开机阶段即可显示正确的中国标准时间。 +- 从启动顺序上消除了普通重启和 Deep Sleep 唤醒后等待 Wi-Fi 才能修正小时的问题。 +- 从实现上支持离线启动时根据 RTC 中保留的 UTC 时间换算本地小时。 +- 分钟显示逻辑和原有 LCD 更新时间隔保持不变。 +- SNTP 职责保持不变,网络连接后仍负责校准绝对时间。 +- 已使用 ESP-IDF v5.5.4 完成编译验证,构建成功;普通重启、Deep Sleep 和断网启动仍建议按第 6.6 节进行实机回归。 +- 固件大小为 `0x217f20`,应用分区剩余 `0x1680e0`,约 40%。 + +## 6. 后续是否有其他影响 + +### 6.1 内存和性能影响 + +本次修改没有新增任务、定时器、帧缓冲、动态内存分配或 PSRAM 分配。新增内容主要是函数调用、少量代码和日志字符串,对内部 RAM 的持续占用基本无可感知变化,也不需要为了该修复将内部 RAM 改为 PSRAM。 + +开机时只增加一次 `setenv()`、`tzset()` 和日志输出,执行耗时相对于完整启动流程可以忽略。SNTP 启动时会再次调用时区设置接口,但不会重复启动 SNTP 客户端。 + +### 6.2 全局时区影响 + +`TZ` 是进程级全局配置。设置为 `CST-8` 后,项目内所有使用 `localtime()` 或 `localtime_r()` 的模块都会采用同一时区。这符合设备当前统一使用中国标准时间的预期;如果未来需要同时显示多个地区的时间,不能依赖反复修改全局 `TZ`,需要增加独立的时区换算方案。 + +### 6.3 启动日志和阶段编号 + +启动日志会新增一个 `wall_clock` 阶段以及一条时区应用成功日志。由于枚举中插入了新的阶段,后续启动阶段的数字编号会顺延一位;阶段名称和启动行为不受影响。如果外部日志分析程序依赖数字编号,需要同步调整。 + +### 6.4 异常降级行为 + +若时区字符串为空或设置失败,启动流程不会被中断,而是输出警告后继续。此时 LCD 可能暂时按 UTC 显示;网络连接后 SNTP 启动流程会再次尝试应用时区。 + +### 6.5 RTC 无有效时间的情况 + +本次修复只解决“UTC 时间有效,但本地时区尚未应用”的问题,不负责在完全断电且没有可靠 RTC 时间来源时生成正确绝对时间。如果冷启动时系统时间无效,LCD 应继续显示 `--:--`,直到网络连接并由 SNTP 完成校时。时区设置不能替代 SNTP 校时。 + +### 6.6 建议的硬件回归验证 + +建议在实机上继续覆盖以下场景: + +- RTC 时间有效时进行普通软件重启,确认首屏小时和分钟均正确。 +- 进入 Deep Sleep 后唤醒,确认联网前的小时正确。 +- 断开网络启动,确认 RTC 有效时仍能按 UTC+8 显示。 +- 网络恢复并完成 SNTP 同步,确认时间连续且没有异常跳变。 +- 完全断电导致 RTC 时间无效时,确认同步前显示 `--:--`,同步后恢复正确时间。 + +## 7. 结论 + +该问题由启动阶段未及时恢复进程级时区引起,与分钟计算、LCD 渲染或 RTC 走时精度无关。修复通过在 LCD 启动前独立应用 `CST-8`,使设备不依赖网络也能将有效的 UTC 时间换算为正确本地时间;网络连接后的 SNTP 继续负责绝对时间校准。改动不新增常驻内存资源,主要影响仅为新增启动日志、启动阶段编号顺延和全局本地时区提前生效。 diff --git a/docs/knowledge vault/bugfix-websocket-fresh-auth-reconnect.zh-CN.md b/docs/knowledge vault/bugfix-websocket-fresh-auth-reconnect.zh-CN.md new file mode 100644 index 0000000..adc1293 --- /dev/null +++ b/docs/knowledge vault/bugfix-websocket-fresh-auth-reconnect.zh-CN.md @@ -0,0 +1,273 @@ +# WebSocket 断线后使用新鉴权上下文重连:问题原因与修复方案 + +## 1. 对应提交 + +```text +273550b fix(multimodal): reconnect websocket with fresh auth +``` + +本文说明多模态 WebSocket 在运行过程中断开后,设备可能持续重连失败、无法继续唤醒对话,而重启设备后又可以恢复的问题。 + +## 2. 用户可见现象 + +典型表现如下: + +1. 设备启动后能够正常连接并完成若干轮对话; +2. 运行过程中 WebSocket 出现 error 或 disconnected; +3. 后续日志反复出现连接失败,设备无法重新进入可用的语音会话; +4. Wi-Fi 本身可能仍显示已连接; +5. 重启设备后,连接通常能够立即恢复。 + +“重启后恢复”说明问题不一定来自远端服务长期不可用。如果远端服务或公网一直不可达,重启设备一般也不会立刻解决。这个现象更符合设备内部旧 WebSocket、旧鉴权 Header 或重连状态没有被完整清理。 + +## 3. 原有设计的问题 + +### 3.1 WebSocket 重连存在两个控制者 + +修复前,底层 WebSocket 客户端配置为: + +```c +.disable_auto_reconnect = false, +.enable_close_reconnect = true, +``` + +这意味着底层库可以自行重连。同时,项目的 control-plane 也负责鉴权、创建连接、处理断线和调度下一次启动。 + +因此一次断线后可能同时存在两条恢复路径: + +```text +底层 WebSocket 库 + └─ 使用原客户端和原 Header 自动重连 + +control-plane + └─ 将会话标记为不可用,准备重新鉴权和创建连接 +``` + +两套状态机并不共享完整状态,容易产生以下不一致: + +- control-plane 已认为连接不可用,但底层仍在自动重连; +- control-plane 已清空当前会话鉴权信息,但旧客户端仍持有创建时的 Header; +- 新一轮启动与旧客户端的关闭、回调或重连交错; +- 上层看到“未连接”,底层却可能仍处于 connecting、closing 或 reconnecting; +- 旧连接的异步回调可能影响新一轮连接。 + +### 3.2 WebSocket error/disconnected 没有强制回收旧 transport + +修复前,`control_plane_multimodal_mark_transport_unavailable()` 会清理上层状态,但只有部分原因会触发底层 transport stop: + +- 协议错误; +- 文本发送失败; +- listening 超时; +- no-response 超时。 + +以下两个最直接代表 WebSocket 已失效的原因却没有进入强制关闭条件: + +```text +CONTROL_PLANE_TRANSPORT_LOSS_WS_ERROR +CONTROL_PLANE_TRANSPORT_LOSS_WS_DISCONNECTED +``` + +结果是上层状态已经退休,底层旧客户端却可能继续存在并自行重连。 + +### 3.3 断线事件没有稳定地调度新一轮连接 + +修复前,收到 transport error/disconnected 后主要执行: + +- 清理待处理的云端业务状态; +- 停止音频上行。 + +但没有在这两个事件中明确调度一次新的 WebSocket 启动。因此恢复可能依赖底层自动重连,或者依赖后续其他状态变化碰巧触发连接。 + +### 3.4 一次启动失败后,重试请求可能被消费掉 + +Wi-Fi 状态任务会消费 `ws_start_requested`。修复前,如果本次 `control_plane_try_start_multimodal_from_state_machine()` 返回失败,请求已经被消费,但没有立即重新挂起。 + +这样会出现: + +```text +收到重连请求 + ↓ +本次启动失败 + ↓ +请求被消费 + ↓ +没有新的触发条件 + ↓ +设备长期停留在未连接状态 +``` + +## 4. 根本原因 + +根本原因不是单次网络错误,而是 WebSocket 恢复流程的所有权不唯一: + +```text +底层库自动重连旧客户端 + + +control-plane 清理并重建会话 + + +WS 错误时旧 transport 未被强制回收 + + +启动失败后重试请求可能丢失 + ↓ +旧连接、旧 Header 和新会话状态互相干扰 + ↓ +运行中持续连接失败,重启后因全部状态重建而恢复 +``` + +## 5. 修复方案 + +### 5.1 禁止底层 WebSocket 自动重连 + +配置改为: + +```c +.disable_auto_reconnect = true, +.enable_close_reconnect = false, +``` + +底层 transport 只负责一条连接的建立、收发和关闭,不再私自决定重连。 + +重连所有权统一交给 control-plane: + +```text +WebSocket 异常 + ↓ +通知 control-plane + ↓ +退休旧 transport + ↓ +重新进入多模态启动流程 + ↓ +重新取得当前有效鉴权信息并生成连接 Header + ↓ +创建新的 WebSocket 客户端 +``` + +### 5.2 WS error/disconnected 必须关闭旧 transport + +以下原因被加入 `close_transport` 判断: + +```text +CONTROL_PLANE_TRANSPORT_LOSS_WS_ERROR +CONTROL_PLANE_TRANSPORT_LOSS_WS_DISCONNECTED +``` + +这保证上层判定 WebSocket 不可用时,底层旧客户端也会被停止,而不是留在后台继续使用旧状态重连。 + +### 5.3 断线和错误事件主动调度重连 + +处理以下事件时,明确调用 `control_plane_schedule_websocket_wait_task()`: + +```text +CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_DISCONNECTED +CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_ERROR +``` + +因此恢复不再依赖底层自动重连或其他偶发事件。 + +### 5.4 启动失败后重新挂起重试请求 + +Wi-Fi 状态任务尝试启动 WebSocket 后会检查结果: + +```c +if (!ws_started) { + (void)control_plane_schedule_websocket_wait_task(); +} +``` + +本次失败不会永久吃掉重试机会。后续仍由既有状态机和退避节奏继续尝试。 + +### 5.5 增加架构守卫 + +架构检查脚本增加规则,防止后续修改重新引入以下行为: + +- 再次开启底层 WebSocket 自动重连; +- WS error/disconnected 不再回收旧 transport; +- transport error/disconnected 不再调度 control-plane 重连; +- 启动失败后不重新安排重试。 + +这条守卫的目的不是运行时恢复,而是在构建或代码检查阶段阻止同类设计回退。 + +## 6. “fresh auth”的准确含义 + +这里的 `fresh auth` 更准确地说是: + +> 不复用旧 WebSocket 客户端内部固化的鉴权 Header,而是销毁旧 transport,重新通过 control-plane 的鉴权与建连流程构造连接。 + +它不必然表示每次断线都强制向服务端申请一个全新的 JWT。设备鉴权模块仍可能复用尚未过期、权限范围正确的有效 JWT。 + +两者的区别是: + +| 概念 | 含义 | +| --- | --- | +| 重新走鉴权建连流程 | 重新检查当前鉴权状态,并为新客户端生成当前有效的连接参数和 Header。此次修复保证做到这一点。 | +| 强制刷新 JWT | 无论现有 JWT 是否有效,都向鉴权服务申请新 JWT。此次提交并没有要求每次都这样做。 | + +## 7. 修复后的影响 + +### 正面影响 + +- WebSocket 重连只有一个控制者; +- 每轮重连前会回收旧客户端; +- 避免旧连接回调污染新会话; +- 重连会重新经过项目自己的鉴权和状态检查; +- 单次启动失败后仍能继续重试; +- 运行中断线后的行为更接近重启设备后的干净启动流程。 + +### 代价 + +- 每次 WebSocket error/disconnected 都需要销毁并新建 transport; +- 相比底层直接原地自动重连,会多经过一次清理和 control-plane 调度; +- 如果需要刷新鉴权,还会增加鉴权请求时间; +- 但换来的是状态一致性和可恢复性,避免设备陷入长期不可用状态。 + +该提交只重建多模态 WebSocket,不会主动让 Wi-Fi STA 断开并重新关联路由器。Wi-Fi 重关联属于另一层恢复策略。 + +## 8. 关键日志 + +修复后重点关注: + +```text +Schedule fresh-auth reconnect after transport disconnect +Schedule fresh-auth reconnect after transport error +Retire unavailable transport: reason=...; next start requires fresh auth +``` + +预期顺序大致为: + +```text +transport error/disconnected + ↓ +Retire unavailable transport + ↓ +Schedule fresh-auth reconnect + ↓ +旧 transport 完成清理 + ↓ +鉴权准备 + ↓ +新 WebSocket 建立成功 +``` + +如果只看到反复的 schedule,但没有旧 transport 清理完成或新鉴权开始,应继续检查 transport stop 是否卡住。如果鉴权成功但新 WebSocket 仍连接失败,应继续区分 DNS、TCP/TLS 和远端拒绝问题。 + +## 9. 建议验证方法 + +1. 正常完成多轮语音对话,确认基础流程不受影响; +2. 对话空闲时短暂关闭路由器或屏蔽网络,制造 WebSocket disconnect; +3. 恢复网络,确认设备会退休旧 transport,并自动创建新连接; +4. 在 TTS 播放或音频上行期间制造断网,确认上行停止且不会留下假连接; +5. 连续重复断网、恢复至少 10 次,确认不需要重启设备; +6. 检查每次新连接前是否都有明确的旧 transport 清理过程; +7. 检查没有出现底层 WebSocket 自行无限重连的日志循环。 + +## 10. 涉及文件 + +```text +components/control_plane/src/multimodal/control_plane_multimodal_runtime.c +components/control_plane/src/multimodal/control_plane_multimodal_session_service.c +components/control_plane/src/runtime/control_plane_runtime_workers.c +components/platform/src/multimodal/transport_ws/transport_ws_workers_impl.c +tools/check_architecture_guards.sh +``` + diff --git a/docs/knowledge vault/button-cloud-chouqian-flow.zh-CN.md b/docs/knowledge vault/button-cloud-chouqian-flow.zh-CN.md new file mode 100644 index 0000000..9e679bf --- /dev/null +++ b/docs/knowledge vault/button-cloud-chouqian-flow.zh-CN.md @@ -0,0 +1,478 @@ +# 按键触发云端抽签上下行流程 + +## 1. 目的与原则 + +本文说明用户短按 SW2 后,设备如何通过 FreeRTOS 队列传递按键事件,如何使用 `RequestToRespond` 向云端发送“我要抽签”,以及云端返回的设备指令和 TTS 音频如何分别驱动 LCD UI 与扬声器。 + +当前实现遵循以下原则: + +- SW2 单击不再在设备本地随机抽签; +- 抽签结果由云端生成; +- UI 与语音来自同一轮云端响应; +- 云端会话不可用时不执行本地抽签,只记录日志; +- 一轮按键抽签处理期间忽略重复请求。 + +## 2. 总体流程 + +```text +SW2 电平变化 +→ GPIO ISR 投递原始中断事件 +→ Key Input Task 消抖并识别 SHORT_PRESS +→ 投递 CONTROL_PLANE_EVENT_FAMILY_KEY 系统事件 +→ System Event Task 取出事件 +→ control_plane_request_cloud_chouqian() +→ RequestToRespond(prompt, "我要抽签") +→ WebSocket 上行到云端 +→ 云端生成抽签结果、回复文本和 TTS +→ WebSocket 下行 + ├→ RespondingContent 文本回调 + │ → 解析抽签设备指令 + │ → 投递模型命令队列 + │ → Model Command Task 请求抽签 UI + │ → LCD Render Task 绘制动画和结果 + └→ Binary Audio 下行回调 + → 音频播放管线 + → 扬声器播放 TTS +→ RespondingEnded +→ 清除按键抽签处理中状态 +``` + +## 3. 模块职责 + +| 模块 | 主要职责 | +| --- | --- | +| `control_plane_key_input.c` | GPIO 中断接收、消抖、按下/释放和单双击识别 | +| `control_plane_system_events.c` | 系统事件队列和按键业务分发 | +| `control_plane_multimodal_runtime.c` | 云端抽签请求编排、回调桥接、抽签状态管理 | +| `control_plane_multimodal_session_service.c` | 会话状态、协议 JSON 组装和 WebSocket 消息解析 | +| `control_plane_model_command_extractor.c` | 从云端回复提取抽签设备指令和结果索引 | +| `control_plane_runtime_workers.c` | 模型命令队列消费和抽签 UI 请求 | +| `domain_runtime_adapters.c` | 抽签索引到 LCD 资源的映射 | +| LCD Platform 实现 | 串行读取资源并绘制 LCD | + +## 4. 上行:从按键到云端 + +### 4.1 GPIO ISR 投递原始事件 + +SW2 对应 GPIO 发生电平变化后,CPU 进入 GPIO ISR。中断函数不执行云端请求或 UI 绘制,只采集 GPIO 编号和电平,并使用 ISR 安全的队列接口投递到 `s_gpio_irq_queue`: + +```text +GPIO ISR +→ control_plane_gpio_irq_event_t +→ s_gpio_irq_queue +→ 立即退出 ISR +``` + +这样可以避免在中断上下文执行耗时、阻塞或不允许调用的操作。 + +### 4.2 Key Input Task 识别短按 + +`control_plane_key_input_task()` 从 `s_gpio_irq_queue` 接收原始电平事件,负责消抖、按下/释放判断、按压时长计算以及单双击识别。 + +当 SW2 被识别为单击时,得到的语义事件主要字段为: + +```text +key_id = CONTROL_PLANE_KEY_ID_SW2 +type = CONTROL_PLANE_KEY_EVENT_SHORT_PRESS +gpio_num = 13 +click_count = 1 +``` + +### 4.3 投递系统事件 + +按键模块调用 `control_plane_emit_key_event()` 构造 `control_plane_system_event_t`: + +```c +event.family = CONTROL_PLANE_EVENT_FAMILY_KEY; +event.data.key.key_id = CONTROL_PLANE_KEY_ID_SW2; +event.data.key.type = CONTROL_PLANE_KEY_EVENT_SHORT_PRESS; +``` + +然后调用: + +```c +control_plane_post_system_event(&event, 0); +``` + +内部使用 FreeRTOS `xQueueSend()` 将整个事件结构体复制到 `s_system_event_queue`。超时参数为 `0`,因此队列有空位时立即成功,队列已满时不等待并返回错误。 + +Queue 保存的是结构体副本,而不是局部变量地址,所以投递函数返回后,原始局部变量失效不会影响队列中的事件。 + +### 4.4 System Event Task 分发事件 + +`control_plane_system_event_task()` 使用: + +```c +xQueueReceive(s_system_event_queue, &event, pdMS_TO_TICKS(200)); +``` + +等待系统事件。队列为空时任务进入 FreeRTOS Blocked 状态,不会持续占用 CPU。收到事件后依次调用: + +```text +control_plane_dispatch_system_event() +→ 根据 family 识别为 KEY +→ control_plane_dispatch_key_event() +→ 根据 key_id 识别为 SW2 +→ 根据 type 识别为 SHORT_PRESS +→ control_plane_request_cloud_chouqian() +``` + +SW2 双击仍走原有 `DOMAIN_EXPRESSION_WUXING` 流程,不会触发云端抽签。 + +### 4.5 防止重复请求 + +`control_plane_request_cloud_chouqian()` 在 `s_multimodal_runtime_lock` 保护下检查 `s_cloud_chouqian_pending`: + +```text +false:当前没有 SW2 发起的云端抽签请求 +true:请求已经发出,本轮云端响应尚未结束 +``` + +状态变化为: + +```text +IDLE(false) +→ SW2 单击并开始发送 +→ PENDING(true) +→ RespondingEnded 或异常 +→ IDLE(false) +``` + +如果已经是 `true`,新的 SW2 单击不会再次发送,避免多轮 TTS 和 UI 重叠。 + +### 4.6 重置模型指令去重状态 + +发送按键抽签请求前还会执行: + +```c +s_model_command_dispatched = false; +``` + +`RespondingContent` 是流式全量文本,同一设备指令可能随文本更新重复出现。该状态保证一轮响应只派发一次设备指令: + +```text +false:本轮还没有派发设备指令 +true:已经派发,后续重复文本忽略 +``` + +语音上行通常在 `SpeechStarted` 时复位该状态。按键 Prompt 没有 `SpeechStarted`,所以必须在发送前主动复位。 + +### 4.7 构造 Prompt + +按键抽签请求为: + +```c +const control_plane_multimodal_respond_request_t request = { + .type = CONTROL_PLANE_MULTIMODAL_RESPOND_TYPE_PROMPT, + .text = "我要抽签", + .parameters_json = NULL, +}; +``` + +`PROMPT` 表示把文本交给大模型理解并生成回答,不是直接朗读“我要抽签”。 + +调用链如下: + +```text +control_plane_request_cloud_chouqian() +→ control_plane_multimodal_send_request_to_respond() +→ send_request_to_respond_directive() +→ domain_runtime_multimodal_transport_send_text() +→ WebSocket +``` + +### 4.8 会话状态检查 + +发送接口要求: + +- WebSocket transport 可用; +- `task_id` 非空; +- `dialog_id` 非空; +- `s_server_listening == true`。 + +任何条件不满足都会返回 `ESP_ERR_INVALID_STATE`。按键入口只记录日志,不执行本地抽签。 + +### 4.9 上行协议 + +设备发送的核心 JSON 为: + +```json +{ + "header": { + "action": "continue-task", + "task_id": "<当前任务ID>", + "streaming": "duplex" + }, + "payload": { + "input": { + "directive": "RequestToRespond", + "dialog_id": "<当前对话ID>", + "type": "prompt", + "text": "我要抽签" + } + } +} +``` + +发送成功后记录: + +```text +Cloud chouqian prompt sent +``` + +## 5. 下行:从云端回复到语音和 UI + +### 5.1 回调注册 + +多模态客户端启动时注册生命周期事件回调: + +```c +control_plane_multimodal_register_event_callback( + control_plane_multimodal_event_bridge, + NULL +); +``` + +同时注册下行音频回调: + +```c +control_plane_multimodal_register_downstream_audio_callback( + control_plane_downstream_audio_bridge, + NULL +); +``` + +模型命令工作任务启动时注册模型命令回调: + +```c +control_plane_register_model_command_callback( + control_plane_on_model_command, + NULL +); +``` + +该注册过程又把 `control_plane_responding_text_bridge()` 注册为 RespondingText 回调。 + +| 回调 | 职责 | +| --- | --- | +| `control_plane_multimodal_event_bridge()` | 处理连接、任务、Listening、Responding 等生命周期事件 | +| `control_plane_downstream_audio_bridge()` | 接收并播放云端 TTS 音频 | +| `control_plane_responding_text_bridge()` | 从回复文本提取设备指令 | +| `control_plane_on_model_command()` | 把已解析的设备指令投递到模型命令队列 | + +### 5.2 RespondingStarted + +云端开始回答后,Session Service 更新远端响应状态并通知 `CONTROL_PLANE_MULTIMODAL_EVENT_RESPONDING_STARTED`。事件桥接函数会停止仍在运行的麦克风上行,避免 TTS 播放期间继续上传语音。 + +### 5.3 TTS 音频链路 + +云端 TTS 通过 WebSocket 二进制消息流式下发: + +```text +WebSocket binary +→ control_plane_downstream_audio_bridge(data, len) +→ control_plane_multimodal_play_downstream_audio(data, len) +→ 音频解码与播放管线 +→ 扬声器 +``` + +按键抽签完全复用原有云端 TTS 播放链路。 + +### 5.4 文本与设备指令链路 + +云端流式回复 `RespondingContent` 时,Session Service 读取 `output.text` 和 `output.spoken`,然后调用 `notify_responding_text()`,最终进入: + +```c +control_plane_responding_text_bridge(); +``` + +假设云端回复包含: + +```text +/*{"抽签":"上上签"}*/ +``` + +文本桥接函数调用 `control_plane_extract_model_command()`,得到: + +```text +command_type = CONTROL_PLANE_MODEL_COMMAND_CHOUQIAN +value = "上上签" +value_index = 2 +value_index_valid = true +``` + +解析成功后再次在锁内检查 `s_model_command_dispatched`,避免两个流式回调同时派发相同指令。 + +### 5.5 模型命令回调和队列 + +文本桥接函数调用已注册的: + +```c +control_plane_on_model_command(...); +``` + +该回调不直接操作 LCD,而是构造 `control_plane_model_command_t` 并通过 `xQueueSend()` 投递到模型命令队列。这样 WebSocket/Session 回调可以快速返回,不会被 UI 绘制阻塞。 + +### 5.6 Model Command Task 请求 UI + +`control_plane_model_command_task()` 从队列收到 `CONTROL_PLANE_MODEL_COMMAND_CHOUQIAN` 后调用: + +```c +control_plane_show_chouqian_expression( + cmd.value_index, + cmd.value_index_valid +); +``` + +随后构造 Domain 请求: + +```c +domain_expression_request_t request = { + .id = DOMAIN_EXPRESSION_CHOUQIAN, + .chouqian_result_index = result_index, + .chouqian_result_index_valid = result_index_valid, +}; +``` + +### 5.7 结果到 LCD 资源的映射 + +| 索引 | 签型 | LCD 资源目录 | +| ---: | --- | --- | +| 0 | 卜运签 | `chouqian_buyun` | +| 1 | 和平签 | `chouqian_heping` | +| 2 | 上上签 | `chouqian_shangshang` | +| 3 | 小吉签 | `chouqian_xiaoji` | +| 4 | 中上签 | `chouqian_zhongshang` | + +当 `chouqian_result_index_valid == true` 时,Domain 使用云端结果,不进行本地随机。 + +LCD 播放顺序为: + +```text +chouqian_qianzhi frame 0 +→ chouqian_qianzhi frame 1 +→ chouqian_qianzhi frame 2 +→ 结果资源 frame 0 +→ 结果资源 frame 1 +→ 结果资源 frame 2 +``` + +## 6. 响应结束与状态清理 + +云端回答结束时发送 `RespondingEnded`。Session Service 结束下行播放并通知 `CONTROL_PLANE_MULTIMODAL_EVENT_RESPONDING_ENDED`。事件桥接函数随后调用: + +```c +control_plane_clear_cloud_chouqian_pending("response ended"); +``` + +将 `s_cloud_chouqian_pending` 恢复为 `false`,允许下一次 SW2 抽签。 + +以下异常事件也会清除该状态: + +- `TRANSPORT_DISCONNECTED`; +- `TRANSPORT_ERROR`; +- `TASK_FAILED`; +- `OUTPUT_ERROR`; +- 请求发送失败; +- 多模态客户端停止。 + +## 7. 任务、队列和回调关系 + +```text +GPIO ISR + │ xQueueSendFromISR + ▼ +GPIO IRQ Queue + │ xQueueReceive + ▼ +Key Input Task + │ control_plane_post_system_event / xQueueSend + ▼ +System Event Queue + │ xQueueReceive + ▼ +System Event Task + │ RequestToRespond / WebSocket send + ▼ +Cloud + │ + ├─ RespondingContent ─→ RespondingText Callback + │ │ Model Command Callback + │ ▼ + │ Model Command Queue + │ │ xQueueReceive + │ ▼ + │ Model Command Task + │ │ Domain expression request + │ ▼ + │ LCD Request / Render Task + │ + └─ Binary Audio ─────→ Downstream Audio Callback + │ + ▼ + Audio Playback Pipeline +``` + +Queue 用于跨任务复制和传递数据;回调在事件产生者的当前任务上下文中执行,所以回调只做快速解析或桥接,较重的 UI 操作继续通过队列交给专用任务。 + +## 8. 主要状态变量 + +### `s_cloud_chouqian_pending` + +本次功能新增的请求状态: + +```text +false:允许 SW2 发起云端抽签 +true:已有 SW2 云端抽签尚未结束,忽略重复请求 +``` + +由 `s_multimodal_runtime_lock` 保护。 + +### `s_model_command_dispatched` + +原有的模型设备指令去重状态,本次按键抽签复用: + +```text +false:当前一轮回复尚未派发设备指令 +true:已经派发,忽略流式重复文本中的同一指令 +``` + +语音请求在 `SpeechStarted` 时复位;按键 Prompt 在发送前主动复位。 + +### Session Service 原有状态 + +| 状态 | 含义 | +| --- | --- | +| `s_task_id` | 当前云端多模态任务 ID | +| `s_dialog_id` | 当前对话 ID | +| `s_server_listening` | 云端是否接受新输入 | +| `s_remote_responding` | 云端是否正在回答 | +| `s_request_to_speak_pending` | 是否存在语音打断请求 | + +这些状态由 Session Service 统一维护,上层不额外复制会话状态。 + +## 9. 关键日志 + +```text +cp_system_event: key event key=SW2 type=SHORT_PRESS ... +control_plane_mm_rt: Cloud chouqian prompt sent +control_plane_mm_rt: Cloud chouqian result received: 上上签 +control_plane_mm_rt: Cloud chouqian request completed: response ended +``` + +会话不可用或请求仍在处理中: + +```text +cp_system_event: Cloud chouqian request skipped: multimodal session unavailable or request pending +``` + +## 10. 当前注意事项 + +为了严格保证 UI 与语音一致,云端应返回带具体签型的设备指令,例如: + +```text +/*{"抽签":"上上签"}*/ +``` + +如果只返回包含“签文:”的普通文本而没有具体设备指令,现有兼容逻辑可能生成无有效索引的抽签命令,Domain 随后会本地随机 UI。因此云端输出格式应保持稳定,或在后续版本中限制按键云端抽签只接受带有效结果索引的设备指令。 + diff --git a/docs/knowledge vault/device-auth-v2-flow.zh-CN.md b/docs/knowledge vault/device-auth-v2-flow.zh-CN.md new file mode 100644 index 0000000..f37a4b3 --- /dev/null +++ b/docs/knowledge vault/device-auth-v2-flow.zh-CN.md @@ -0,0 +1,1006 @@ +# 设备 Auth V2 与 AI 语音鉴权流程 + +> 本文只讲清楚一件事:一台新 ESP32 设备如何从工厂写入身份开始,完成公钥绑定、Challenge/Response 认证,取得设备 JWT,再换取语音 Token 并建立 WebSocket。 +> +> 当前实际配置:`CONFIG_AUTH_V2_ENABLE=y`、`CONFIG_TQ_FACTORY_CLI_ENABLE=y`。 + +## 1. 先看完整流程 + +整个鉴权过程分为两个阶段: + +1. **工厂注册阶段**:每台新设备原则上只做一次,目的是让服务器建立可信关系:`device_id -> 设备公钥`。 +2. **设备日常运行阶段**:设备启动后自主执行,目的是证明自己仍持有对应私钥,并逐级取得语音服务 Token。 + +### 1.1 工厂注册阶段:建立设备身份和公钥绑定 + +```text +工厂后台创建一条设备记录 + │ + │ 得到 device_id、serial_number + ▼ +工厂工具通过串口连接 ESP32 的 factory_cli 任务 + │ + │ set_device_id + │ set_serial_number + │ save_config + ▼ +ESP32 保存 device_id、serial_number + │ + │ get_auth_material + ▼ +ESP32 本地生成 P-256 密钥对 + │ + ├── 私钥:只保存在 ESP32,不上传 + └── 公钥、JKT、key_version:通过串口返回工厂工具 + │ + ▼ +工厂工具携带“工位身份和工位签名”请求 Register 服务 + │ + │ 把 device_id 与设备公钥可信绑定 + ▼ +服务器保存:device_id -> pub_key、JKT、key_version + │ + ▼ +工厂工具取得服务器 Challenge + │ + │ auth_sign_challenge + ▼ +ESP32 用设备私钥签名 + │ + ▼ +工厂工具把设备签名提交给服务器 + │ + ▼ +服务器用已绑定的设备公钥验签,设备完成激活 +``` + +这里必须区分两种工具: + +| 参与者 | 所在位置 | 职责 | +| --- | --- | --- | +| `factory_cli` | ESP32 固件中的串口监听任务 | 写入设备身份、生成/读取设备公钥、使用设备私钥签名 | +| 工厂工具 | 生产电脑上的程序 | 发送串口命令、持有工位凭证、请求 Register 服务、组织首次绑定流程 | + +`factory_cli` 本身不会代替工厂工具完成所有 HTTP 注册操作。它主要负责访问设备内部数据和调用设备私钥。 + +### 1.2 日常运行阶段:设备自主取得语音 Token + +首次绑定完成以后,不需要每次语音聊天都连接工厂串口。设备可以自主执行: + +```text +ESP32 读取 device_id 和本地设备密钥 + │ + │ POST /register/api/auth/challenge + ▼ +服务器查询 device_id 对应的已绑定公钥 + │ + │ 返回 challenge_id、challenge、pub_key_jkt、key_version + ▼ +ESP32 检查 Challenge 字段并构造待签名消息 + │ + │ 使用设备私钥做 P-256 ECDSA/SHA-256 签名 + ▼ +ESP32 得到 signature = base64url(raw r || raw s) + │ + │ POST /register/api/auth/response + ▼ +服务器使用已绑定的设备公钥验签 + │ + │ 验签成功 + ▼ +服务器返回短期设备 JWT + │ + │ Authorization: Bearer + │ POST /token/api/device/token + ▼ +Token 服务验证 JWT、设备身份和 voice:session 权限 + │ + │ 返回临时语音 Token、workspace_id、app_id、voice_id + ▼ +ESP32 建立 DashScope WSS + │ Authorization: Bearer <临时语音Token> + │ X-DashScope-WorkSpace: + ▼ +开始 AI 语音会话 +``` + +### 1.3 三种凭证不要混淆 + +| 凭证 | 谁产生 | 谁验证/使用 | 作用 | +| --- | --- | --- | --- | +| 设备签名 `signature` | ESP32 用设备私钥产生 | Register 服务用设备公钥验证 | 证明设备持有私钥 | +| 设备 JWT | Register 服务签发 | Token 服务验证 | 授权设备调用 TalkingQ 业务接口 | +| 临时语音 Token | Token 服务返回 | DashScope WebSocket 使用 | 授权实际 AI 语音会话 | + +设备 JWT 不能直接替代语音 Token连接 DashScope;语音 Token 也不是设备长期身份证明。 + +--- + +## 2. 工厂串口任务如何启动 + +当配置: + +```text +CONFIG_TQ_FACTORY_CLI_ENABLE=y +``` + +运行时启动代码会调用: + +```c +control_plane_factory_cli_start(); +``` + +它创建名为 `factory_cli` 的 FreeRTOS 任务,通过 Console UART 读取以 `\r` 或 `\n` 结束的命令。 + +关键参数: + +| 参数 | 当前值 | +| --- | ---: | +| 最大命令行长度 | 512 B | +| 最大参数数量 | 8 | +| 串口读取超时 | 100 ms | +| 任务栈 | 12 KiB | +| 任务优先级 | 4 | +| 运行核心 | `CONFIG_TQ_LOCAL_TASK_CORE` | + +命令响应格式: + +```text +OK +ERROR +<一行 JSON> +``` + +鉴权相关命令: + +| 命令 | 作用 | +| --- | --- | +| `factory_mode` | 标记当前 CLI 进入工厂模式 | +| `get_device_info` | 读取已保存的设备 ID 和序列号 | +| `set_device_id ` | 临时设置设备 ID | +| `set_serial_number ` | 临时设置序列号 | +| `save_config` | 提交并保存两个身份字段 | +| `get_auth_material` | 生成或加载设备密钥,返回公开材料 | +| `auth_sign_challenge ` | 使用设备私钥签署工厂提供的 Challenge | +| `auth_prepare_rotate ` | 生成新密钥并准备轮换授权签名 | + +## 3. 第一步:写入设备身份 + +### 3.1 工厂工具发送什么 + +```text +factory_mode +set_device_id 550e8400-e29b-41d4-a716-446655440000 +set_serial_number 0508-0001 +save_config +get_device_info +``` + +### 3.2 `set` 和 `save_config` 的区别 + +`set_device_id` 和 `set_serial_number` 只将值暂存在 RAM: + +```c +s_pending_device_id +s_pending_serial_number +``` + +只有执行 `save_config` 后,才会调用: + +```c +domain_runtime_write_device_identity(device_id, serial_number); +``` + +并保存: + +```text +device_id +serial_number +``` + +`save_config` 要求两个字段都不为空,否则返回: + +```text +ERROR identity_not_ready +``` + +保存完成后,代码会调用: + +```c +control_plane_multimodal_device_auth_invalidate(0); +``` + +使旧设备身份对应的 JWT 缓存失效。下次鉴权必须使用新身份重新执行 Challenge/Response。 + +### 3.3 身份字段的使用位置 + +| 字段 | 后续用途 | +| --- | --- | +| `device_id` | Challenge 请求、签名消息、服务器公钥查询、JWT 主体 | +| `serial_number` | 业务 Token 请求中的辅助身份校验 | + +当前 Auth V2 的签名逻辑要求 `device_id` 能解析成16字节 UUID。 + +## 4. 第二步:生成或读取设备密钥 + +### 4.1 工厂工具请求 + +```text +get_auth_material +``` + +设备调用链: + +```text +handle_get_auth_material() + -> control_plane_multimodal_device_auth_get_key_material() + -> key_store_load_or_create_locked() +``` + +### 4.2 第一次执行 + +如果设备中没有密钥记录: + +```text +key_store_load_or_create_locked() + -> generate_key_material() + -> 生成私钥 + -> 推导公钥 + -> 计算公钥 JKT + -> key_store_save_locked() +``` + +实际生成函数: + +```c +static esp_err_t generate_key_material( + uint8_t out_priv[AUTH_PRIVKEY_LEN], + uint8_t out_pub[AUTH_PUBKEY_LEN], + uint8_t out_jkt[AUTH_PUBKEY_JKT_LEN]); +``` + +三个参数都是输出缓冲区,不需要外部提供密码或随机种子。 + +内部逻辑: + +```text +esp_fill_random(out_priv, 32) + -> derive_public_from_private(out_priv, out_pub) + -> build_pubkey_jkt(out_pub, out_jkt) +``` + +固定长度: + +```c +#define AUTH_PRIVKEY_LEN 32 +#define AUTH_PUBKEY_LEN 65 +#define AUTH_PUBKEY_JKT_LEN 32 +``` + +65字节公钥是未压缩 P-256 点: + +```text +0x04 || X坐标32字节 || Y坐标32字节 +``` + +### 4.3 后续执行 + +如果设备中已经存在密钥,则加载原密钥,不会重新生成: + +```text +读取 priv_key、pub_key、pub_key_jkt、key_version + -> 根据私钥重新推导公钥和JKT + -> 检查/修复公钥材料是否匹配 + -> 返回原公钥材料 +``` + +### 4.4 串口返回什么 + +```json +{ + "pub_key": "<65字节公钥的base64url>", + "pub_key_jkt": "<32字节JKT的base64url>", + "key_version": 1 +} +``` + +工厂工具只能拿到公钥、JKT 和版本。设备私钥不会通过 `get_auth_material` 返回。 + +### 4.5 JKT在流程中起什么作用 + +设备先将 P-256 公钥转换成稳定的 JWK 表示: + +```json +{ + "crv": "P-256", + "kty": "EC", + "x": "", + "y": "" +} +``` + +再计算 SHA-256,得到32字节 JKT: + +```text +公钥 JWK -> SHA-256 -> pub_key_jkt +``` + +JKT 是公钥指纹,用于确认 Challenge 指向的公钥与设备本地公钥一致。JKT 不能签名,也不能还原私钥。 + +## 5. 第三步:工厂首次绑定公钥 + +### 5.1 为什么必须经过工厂授权 + +服务器不能因为收到下面的请求,就无条件相信公钥: + +```json +{ + "device_id": "某台设备", + "pub_key": "某个公钥" +} +``` + +否则攻击者可以给别人的 `device_id` 绑定自己的公钥。 + +首次绑定必须同时证明: + +1. `device_id` 是工厂后台正式分配的设备; +2. 此次操作来自合法工位; +3. 公钥确实由目标 ESP32 生成; +4. ESP32 持有该公钥对应的私钥。 + +### 5.2 工厂首次Challenge请求 + +接口: + +```text +POST /register/api/auth/challenge +``` + +首次绑定请求包含: + +```json +{ + "device_id": "550e8400-e29b-41d4-a716-446655440000", + "key_version": 1, + "pub_key": "<设备返回的公钥>", + "force_rebind": false, + "station_id": "station-001", + "timestamp": 1778192400, + "nonce": "<工位生成的随机串>", + "sig_b64": "<工位签名>" +} +``` + +其中: + +| 字段 | 来源 | 作用 | +| --- | --- | --- | +| `device_id` | 工厂后台/设备 | 指定要注册的设备 | +| `key_version` | ESP32 | 首次通常为1 | +| `pub_key` | `get_auth_material` | 要绑定的设备公钥 | +| `station_id` | 工厂工具 | 表明是哪一个合法工位 | +| `timestamp` | 工厂工具 | 限制请求有效时间 | +| `nonce` | 工厂工具 | 防止工位请求重放 | +| `sig_b64` | 工厂工具 | 工位私钥对请求内容的签名 | + +`sig_b64` 是工位签名,不是 ESP32 设备签名。 + +### 5.3 服务器返回Challenge + +```json +{ + "challenge_id": "", + "challenge": "", + "pub_key_jkt": "", + "key_version": 1, + "expires_in": 60, + "server_time": 1778192400 +} +``` + +服务端同时保存挑战上下文: + +```text +challenge_id +device_id +challenge +pub_key_jkt +key_version +expires_at +used=false +``` + +### 5.4 工厂工具让ESP32签名 + +工厂工具通过串口发送: + +```text +auth_sign_challenge +``` + +设备调用: + +```text +handle_sign_challenge() + -> control_plane_multimodal_device_auth_sign_challenge() + -> sign_message_raw_rs_b64u() +``` + +设备返回: + +```json +{ + "signature": "" +} +``` + +### 5.5 工厂工具提交签名 + +接口: + +```text +POST /register/api/auth/response +``` + +首次/pending阶段的请求概念上是: + +```json +{ + "device_id": "550e8400-e29b-41d4-a716-446655440000", + "challenge_id": "<服务器返回值>", + "signature": "", + "station_id": "station-001", + "timestamp": 1778192400, + "nonce": "<新的工位随机串>", + "sig_b64": "<工位签名>" +} +``` + +服务器分别验证: + +```text +工位签名是否有效 + -> 首次绑定操作是否可信 + +设备签名是否有效 + -> ESP32 是否持有待绑定公钥对应的私钥 +``` + +两个检查都通过后,服务器才能安全地激活: + +```text +device_id -> pub_key、pub_key_jkt、key_version +``` + +## 6. 第四步:日常Challenge/Response + +设备激活后,工厂工位不再参与普通认证。 + +### 6.1 确保设备JWT可用 + +语音 Token 流程调用: + +```c +control_plane_multimodal_device_auth_ensure( + CONTROL_PLANE_DEVICE_AUTH_SCOPE_VOICE_SESSION, + force_refresh); +``` + +它先检查: + +```text +是否有缓存JWT +JWT是否即将过期 +JWT是否包含voice:session权限 +调用方是否要求强制刷新 +``` + +满足条件则复用 JWT;否则执行新的 Challenge/Response。 + +### 6.2 普通Challenge请求 + +调用: + +```c +request_challenge(device_id, key_version, pub_key_b64u, &result); +``` + +请求: + +```json +{ + "device_id": "550e8400-e29b-41d4-a716-446655440000", + "key_version": 1, + "pub_key": "" +} +``` + +因为服务器已经保存可信绑定,这次不再需要工位字段。 + +内部接收结构: + +```c +typedef struct { + char challenge_id_b64u[128]; + char challenge_b64u[128]; + char pub_key_jkt_b64u[128]; + uint32_t key_version; + bool rotate_required; + bool factory_required; +} challenge_result_t; +``` + +如果服务器认为设备尚未完成首次绑定,返回 `factory_required`,设备进入 `ST_WAIT_FACTORY`,不会绕过工厂流程。 + +## 7. 设备具体签名的内容 + +设备不是只签名 `challenge` 字符串。 + +它先解码服务器返回的 Base64URL 字段,然后按精确顺序拼接: + +```text +UTF8("robot-auth-v1") ++ UUID_TO_16_BYTES(device_id) ++ base64url_decode(challenge_id) ++ base64url_decode(challenge) ++ base64url_decode(pub_key_jkt) ++ int32_be(key_version) +``` + +各字段的解码长度: + +| 字段 | 长度 | +| --- | ---: | +| `robot-auth-v1` | 13 B | +| `device_id` UUID | 16 B | +| `challenge_id` | 16 B | +| `challenge` | 32 B | +| `pub_key_jkt` | 32 B | +| `key_version` | 4 B,大端序 | + +为什么要签这么多字段: + +| 字段 | 防止的问题 | +| --- | --- | +| `robot-auth-v1` | 防止同一签名被挪作其他协议用途 | +| `device_id` | 防止签名被转移给另一台设备 | +| `challenge_id` | 绑定服务器保存的具体挑战记录 | +| `challenge` | 保证每次认证的新鲜性 | +| `pub_key_jkt` | 绑定服务器预期的公钥 | +| `key_version` | 防止不同密钥版本混用 | + +签名函数: + +```c +sign_message_raw_rs_b64u(message, message_len, output, output_len); +``` + +算法: + +```text +SHA-256(完整组合消息) + -> 使用P-256设备私钥做ECDSA签名 + -> 得到(r, s) +``` + +P-256 ECDSA 的 `r` 和 `s` 各占32字节。当前协议按下面的格式上传: + +```text +raw_signature = r_32_bytes || s_32_bytes +signature = base64url_no_padding(raw_signature) +``` + +它不是 DER 编码;服务端必须按64字节 `raw r||s` 解码。 + +## 8. 服务器如何验签并签发JWT + +设备请求: + +```text +POST /register/api/auth/response +``` + +普通请求体: + +```json +{ + "device_id": "550e8400-e29b-41d4-a716-446655440000", + "challenge_id": "<服务器返回的challenge_id>", + "signature": "" +} +``` + +服务器处理顺序应当是: + +```text +根据challenge_id读取挑战记录 + -> 检查挑战属于device_id + -> 检查未过期 + -> 检查used=false + -> 读取device_id绑定的公钥、JKT和版本 + -> 重建与ESP32完全相同的签名字节 + -> 解码signature得到r和s + -> 使用设备公钥执行P-256 ECDSA/SHA-256验签 + -> 原子地将挑战标记为used + -> 签发短期设备JWT +``` + +成功响应可能是: + +```json +{ + "access_token": "", + "token_ttl": 600, + "key_version": 1, + "scope": "voice:session" +} +``` + +设备兼容解析多个字段名,如 `jwt`、`device_jwt`、`access_token` 等。 + +内部结构: + +```c +typedef struct { + char jwt[AUTH_JWT_MAX_LEN]; + uint64_t exp_epoch_s; + uint32_t scope_mask; + uint32_t key_version; + bool rotate_required; + bool factory_required; +} response_result_t; +``` + +设备缓存: + +```c +typedef struct { + char jwt[AUTH_JWT_MAX_LEN]; + uint64_t exp_epoch_s; + uint32_t scope_mask; + uint32_t key_version; + bool valid; +} jwt_cache_t; +``` + +设备 JWT 是 Register 服务签发的 **JSON Web Token**,典型 Claim: + +| Claim | 含义 | +| --- | --- | +| `sub` | 设备 ID | +| `exp` | 到期时间 | +| `scope` | 权限,如 `voice:session` | +| `kv` | 设备密钥版本 | + +设备用私钥签 Challenge;JWT 则由认证服务器自己的签名密钥签发。这是两次不同的签名。 + +## 9. 使用设备JWT换取语音Token + +入口: + +```c +control_plane_multimodal_auth_fetch(); +``` + +设备先准备请求体: + +```json +{ + "deviceId": "550e8400-e29b-41d4-a716-446655440000", + "serialNumber": "0508-0001" +} +``` + +再请求: + +```text +POST https://gz.api.talkingq.com/token/api/device/token +``` + +请求头: + +```http +Authorization: Bearer +Content-Type: application/json +``` + +Token 服务应验证: + +```text +JWT签名是否可信 +JWT是否过期 +JWT的sub是否与请求设备匹配 +JWT是否包含voice:session +deviceId与serialNumber是否属于同一设备 +设备是否启用 +设备是否配置了可用的app/workspace/voice +``` + +代表性响应: + +```json +{ + "code": 200, + "data": { + "appId": "mm_xxx", + "workspaceId": "llm_xxx", + "voiceId": "kenny", + "token": "", + "deviceId": "550e8400-e29b-41d4-a716-446655440000", + "serialNumber": "0508-0001", + "expires_at": 1778194200 + } +} +``` + +设备保存: + +```c +control_plane_multimodal_auth_info_t { + token; + workspace_id; + app_id; + voice_id; + device_id; + serial_number; + valid; +} +``` + +如果第一次请求返回401或403: + +```text +使缓存设备JWT失效 + -> 强制重新执行Challenge/Response + -> 得到新设备JWT + -> 再请求一次语音Token +``` + +最多额外重试一次,避免无效凭证造成无限循环。 + +## 10. 使用语音Token建立WebSocket + +设备使用上一步得到的临时语音 Token 建立: + +```text +wss://dashscope.aliyuncs.com/api-ws/v1/inference +``` + +握手头: + +```http +Authorization: Bearer <临时语音Token> +X-DashScope-WorkSpace: +``` + +`workspace_id` 必须与 Token 所属工作空间以及后续 Start 指令中的配置一致,否则可能收到: + +```text +Invalid workspace_id +``` + +到这里,鉴权链路才算完成: + +```text +设备私钥证明身份 + -> 设备JWT授权TalkingQ业务接口 + -> 临时语音Token授权DashScope + -> WSS连接成功 +``` + +## 11. JWT缓存与刷新 + +设备在使用 JWT 前检查: + +```text +jwt.valid +exp_epoch_s +scope_mask +key_version +``` + +如果 JWT 仍有效、权限满足,并且未进入提前刷新窗口,就直接复用。 + +当前提前刷新配置: + +```text +CONFIG_AUTH_JWT_REFRESH_MARGIN_SEC=90 +``` + +也就是距到期不足90秒时按需要刷新处理,减少请求进行到一半 JWT 过期的概率。 + +设备会解析 JWT Payload 中的 `exp` 和 `scope`,但当前并不在本地验证 JWT 签名。本地解析只用于缓存决策;真正的安全授权必须由 Token 服务验签完成。 + +## 12. 密钥轮换 + +当服务器要求 `rotate_required`,设备需要从旧密钥 V1 切换到新密钥 V2: + +```text +设备生成新私钥V2和新公钥V2 + -> 暂时保留旧私钥V1为活动密钥 + -> 使用旧私钥V1签署“切换到公钥V2”的消息 + -> POST /register/api/auth/rotate + -> 服务器用旧公钥V1验签 + -> 服务器接受公钥V2并升级key_version + -> 设备保存私钥V2、公钥V2和新版本 + -> 重新执行Challenge/Response获取新JWT +``` + +轮换签名的精确字节: + +```text +UTF8("robot-rotate-v1") ++ UUID_TO_16_BYTES(device_id) ++ int32_be(current_key_version) ++ base64url_decode(new_pub_key_jkt) ++ base64url_decode(new_pub_key) +``` + +请求体: + +```json +{ + "device_id": "550e8400-e29b-41d4-a716-446655440000", + "key_version": 1, + "new_pub_key": "<新公钥>", + "new_pub_key_jkt": "<新公钥JKT>", + "signature": "<旧私钥产生的签名>" +} +``` + +旧私钥签名的含义是: + +> 当前仍被服务器信任的设备密钥,授权将本设备切换到这个指定的新公钥。 + +新旧私钥都不会通过网络上传。 + +当前自动轮换存在一个需要关注的异常窗口:服务器先接受 V2,设备再保存 V2。如果中间断电或保存失败,可能出现服务器信任 V2、设备仍只有 V1 的不一致状态。量产实现应考虑 pending 双槽记录和轮换恢复协议。 + +## 13. Auth V2状态 + +```c +typedef enum { + ST_READY = 0, + ST_NEED_JWT, + ST_WAIT_FACTORY, + ST_BACKOFF, + ST_ROTATE, +} auth_state_t; +``` + +| 状态 | 含义 | +| --- | --- | +| `ST_READY` | 已有可用设备JWT | +| `ST_NEED_JWT` | 需要执行Challenge/Response | +| `ST_WAIT_FACTORY` | 设备尚需首次绑定或工厂授权 | +| `ST_BACKOFF` | 临时失败后等待重试 | +| `ST_ROTATE` | 需要执行密钥轮换 | + +## 14. 关键参数汇总 + +### 14.1 设备本地身份 + +```text +device_id 最大64字符;Auth签名路径要求可解析为UUID +serial_number 最大64字符 +``` + +### 14.2 设备密钥 + +```text +priv_key 32字节P-256私钥 +pub_key 65字节未压缩P-256公钥 +pub_key_jkt 32字节SHA-256公钥指纹 +key_version 32位正整数 +``` + +### 14.3 Challenge + +```text +challenge_id Base64URL传输,解码后16字节 +challenge Base64URL传输,解码后32字节 +pub_key_jkt Base64URL传输,解码后32字节 +key_version 32位正整数 +expires_in 服务端挑战有效期 +``` + +### 14.4 设备签名 + +```text +算法 P-256 ECDSA + SHA-256 +原始格式 r的32字节 || s的32字节 +传输格式 无填充Base64URL +原始总长度 64字节 +``` + +### 14.5 设备JWT + +```text +sub 设备ID +exp 到期时间 +scope 至少需要voice:session +kv 设备密钥版本 +``` + +### 14.6 临时语音Token结果 + +```text +token DashScope临时Token +workspace_id 工作空间ID +app_id 多模态应用ID +voice_id TTS音色ID +``` + +## 15. 代码调用链汇总 + +### 15.1 工厂身份写入 + +```text +factory_cli_worker() + -> handle_command_line() + -> handle_save_config() + -> domain_runtime_write_device_identity() + -> platform_write_device_identity() +``` + +### 15.2 设备密钥生成/读取 + +```text +handle_get_auth_material() + -> control_plane_multimodal_device_auth_get_key_material() + -> key_store_load_or_create_locked() + -> generate_key_material() // 仅本地没有密钥时 + -> key_store_save_locked() +``` + +### 15.3 日常设备JWT + +```text +control_plane_multimodal_device_auth_ensure() + -> refresh_jwt_internal() + -> request_challenge() + -> sign_message_raw_rs_b64u() + -> request_response() + -> save_state_to_store_locked() +``` + +### 15.4 语音Token + +```text +control_plane_multimodal_auth_fetch() + -> control_plane_multimodal_device_auth_ensure() + -> control_plane_multimodal_device_auth_copy_jwt() + -> http_post_json() + -> domain_runtime_post_json() + -> parse_token_response() +``` + +### 15.5 WebSocket + +```text +control_plane_multimodal_start() + -> domain_runtime_multimodal_transport_start() + -> platform_multimodal_transport_start() + -> multimodal_ws_start() +``` + +## 16. 当前实现需要重点确认的安全事项 + +1. 首次公钥绑定必须要求合法工位认证,不能只相信设备上报的 `pub_key`。 +2. 服务端 Challenge 必须短期有效、一次性使用,并与设备、公钥和版本绑定。 +3. Token 服务必须验证设备 JWT 签名、到期时间、设备主体和 `voice:session` 权限。 +4. 私钥、设备 JWT 和临时语音 Token 不得打印到日志。 +5. 当前配置未启用 Secure Boot、Flash Encryption 和 NVS Encryption,量产前需要评估私钥的物理读取风险。 +6. 工厂 CLI 在量产出厂后应关闭或限制访问,否则物理串口会扩大攻击面。 +7. 密钥轮换需要补充断电后的事务恢复方案。 +8. 缺少或无法解析 `scope` 时应拒绝使用,不应默认拥有全部权限。 + +## 17. 一句话总结 + +```text +工厂先把设备ID写入ESP32,并将ESP32本地生成的公钥可信绑定到服务器; +日常运行时,服务器发随机Challenge,ESP32用永不上传的私钥签名; +服务器用绑定公钥验签后发设备JWT,ESP32再用设备JWT换取临时语音Token, +最后携带临时语音Token和workspace_id建立AI语音WebSocket。 +``` diff --git a/docs/knowledge vault/downstream-audio-stutter-fix-analysis.zh-CN.md b/docs/knowledge vault/downstream-audio-stutter-fix-analysis.zh-CN.md new file mode 100644 index 0000000..39d78fd --- /dev/null +++ b/docs/knowledge vault/downstream-audio-stutter-fix-analysis.zh-CN.md @@ -0,0 +1,1117 @@ +# 下行音频高频卡顿问题分析、修复实现与诊断说明 + +## 1. 文档范围 + +本文说明下行 TTS 音频播放过程中出现短暂停顿、高频卡顿以及类似死机噪声的问题,包括: + +- 问题产生原因和现场日志证据; +- 基于上次提交之后的具体修改; +- 修改后的实际效果; +- 预缓冲、自适应重缓冲、静音保护、超时保护和 generation 隔离的实现方法; +- 新增诊断日志、保护证明日志及其解读方法; +- 现场验证结果、资源影响和当前仍需验证的边界。 + +本次修改以当前分支上次提交为基线: + +```text +b60a037 fix(control_plane): serialize wake prompt after barge-in +``` + +相对该提交,当前工作区修改如下: + +```text +5 files changed, 692 insertions(+), 10 deletions(-) +``` + +涉及文件: + +```text +components/platform/src/multimodal/audio_pipeline.c +components/platform/src/multimodal/audio_pipeline/audio_pipeline_codec_impl.c +components/platform/src/multimodal/audio_pipeline/audio_pipeline_runtime_impl.c +main/Kconfig.projbuild +sdkconfig.defaults +``` + +## 2. 问题现象 + +现场出现过两类相关现象。 + +### 2.1 播放中途短暂停顿 + +典型表现是 TTS 已经开始,例如播放到“哟”之后停住一小会儿,然后继续播放。 + +对应日志: + +```text +W multimodal_audio: Downstream diagnostics active_wait: +response_age_ms=740 +first_packet=1 +last_packet_age_ms=520 +first_playback=1 +last_playback_age_ms=250 +queued=7 +played=7 +pending_estimate=0 +enqueue_failures=0 +playback_failures=0 + +W multimodal_audio: Downstream diagnostics: +queue recovered after 430 ms empty +``` + +这里可以直接看出: + +- 已经收到首包并开始播放; +- 总共入队 7 包,也已经播放 7 包; +- `pending_estimate=0`,说明播放队列已经完全耗空; +- 520 ms 没有收到新包; +- 队列实际保持空状态约 430 ms; +- 没有入队失败; +- 没有 Opus 解码或 codec 写入失败。 + +结合两条日志的时间差,连续音频包之间存在约 720 ms 的到包空洞。已有库存吸收其中一部分,最终仍产生约 430 ms 的可听暂停。 + +### 2.2 长时间高频卡顿或类似死机噪声 + +另一类现象是响应已经开始,但首包或后续音频长时间不再到达。设备其他任务和 WebSocket 心跳仍然正常,但扬声器输出可能持续异常。 + +这说明问题不是整机死机,而是: + +```text +下行响应仍处于 active + ↓ +没有新的音频包或播放进展 + ↓ +播放队列耗空 + ↓ +输出路径没有及时进入安全静音 + ↓ +I2S/DMA 可能保持残留输出 + ↓ +出现持续高频声或类似死机的卡顿感 +``` + +## 3. 问题产生原因 + +问题不是单一故障,而是“上游供包空洞”和“设备端缺少抗抖动及失效保护”共同形成的。 + +### 3.1 直接触发原因:下行音频供包不连续 + +现场已经明确观察到: + +```text +last_packet_age_ms=520 +queued=7 +played=7 +pending_estimate=0 +queue recovered after 430 ms empty +``` + +因此直接触发原因是下行音频包没有按正常节奏持续进入播放器。 + +空洞可能来自: + +- 服务端 TTS 流式生成节奏不连续; +- 服务端或代理层批量发送; +- Wi-Fi 或公网传输抖动; +- WebSocket 接收任务调度延迟。 + +现有 WebSocket 采样日志显示,在被采样的时刻,event、worker 和会话回调通常只相差 0~10 ms,队列深度通常为 0~1,暂时没有发现本地 WebSocket worker 长时间积压。因此目前更倾向于服务端供包节奏或网络传输抖动。 + +但现有 WebSocket 日志是定期采样,不是逐异常包链路跟踪,所以还不能仅凭它严格区分服务端和网络。 + +### 3.2 原有播放器收到首包就开始播放 + +更新前的播放逻辑基本等价于: + +```c +if (queue != NULL) { + received = xQueueReceive(queue, &packet, pdMS_TO_TICKS(20)); +} + +if (received == pdTRUE) { + handle_downstream_opus_sync(packet.data, packet.len); +} +``` + +首包到达后立即开始播放,没有固定的初始 jitter buffer。 + +这种策略首字延迟低,但只要后续包出现较大间隔,队列就很容易耗空。 + +### 3.3 原有逻辑没有耗空后的重缓冲状态 + +更新前队列耗空后,一旦下一包到达就立即恢复播放。 + +如果网络持续不稳定,播放会变成: + +```text +收到 1 包 → 播放 1 包 → 再次耗空 → 等待 → 收到 1 包 → 再播放 +``` + +这会造成反复启停。听感上可能不只是一次干净的停顿,而是连续的高频卡顿或抖动。 + +### 3.4 active 响应会抑制原有 idle mute + +原有定时器在响应 active 时主要进行诊断并返回: + +```c +if (snapshot_downstream_response_active()) { + log_downstream_active_diagnostics(); + arm_downstream_active_diagnostics(ctx); + return; +} +``` + +如果响应状态一直没有结束,但音频已经停止,输出路径可能长期没有进入正常的 idle mute。 + +### 3.5 I2S 原来没有显式启用 DMA 自动清零 + +原 I2S 初始化只设置 DMA descriptor 和 frame 数量: + +```c +i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG( + (i2s_port_t)CONFIG_MULTIMODAL_AUDIO_I2S_PORT, + I2S_ROLE_MASTER); +chan_cfg.dma_desc_num = CONFIG_MULTIMODAL_AUDIO_I2S_DMA_DESC_NUM; +chan_cfg.dma_frame_num = CONFIG_MULTIMODAL_AUDIO_I2S_DMA_FRAME_NUM; +``` + +在上层断流或写入停止时,缺少显式的 DMA 自动清零保护,会增加旧采样残留或重复输出的风险。 + +### 3.6 旧响应包缺少 generation 标识 + +原来的下行包只包含长度和数据指针: + +```c +typedef struct { + size_t len; + uint8_t *data; +} downstream_packet_t; +``` + +当响应被打断、超时或切换时,队列中迟到的旧包缺少明确的响应归属信息,存在被下一次播放路径消费的风险。 + +## 4. 本次更新后的具体效果 + +### 4.1 正常播放开始前保留至少 180 ms 音频库存 + +配置值为 160 ms: + +```text +CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PREBUFFER_MS=160 +``` + +当前 Opus 帧长为 60 ms,代码向上取整: + +```c +static UBaseType_t get_downstream_prebuffer_packet_count( + const multimodal_audio_ctx_t *ctx) +{ + uint32_t frame_ms = ctx != NULL && ctx->opus_frame_duration_ms != 0U ? + ctx->opus_frame_duration_ms : 20U; + uint32_t packet_count = + ((uint32_t)CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PREBUFFER_MS + + frame_ms - 1U) / + frame_ms; + if (packet_count == 0U) { + packet_count = 1U; + } + UBaseType_t queue_len = get_downstream_playback_queue_len(); + return packet_count > (uint32_t)queue_len ? + queue_len : (UBaseType_t)packet_count; +} +``` + +计算结果: + +```text +ceil(160 / 60) = 3 包 +3 × 60 ms = 180 ms 音频库存 +``` + +这不是固定增加 180 ms 的墙钟延迟。服务端可能批量到包,现场等待到第 3 包通常只需要 10~40 ms。 + +### 4.2 较大到包抖动可以由库存直接吸收 + +更新后的现场日志出现过: + +```text +Downstream diagnostics playback_complete: +response_age_ms=8560 +queued=142 +played=142 +depth_max=30 +arrival_gap_ms(min/avg/max)=0/47/560 +empty_events=0 +enqueue_failures=0 +playback_failures=0 +``` + +说明: + +- 音频包之间出现过最大 560 ms 间隔; +- 队列最高累计 30 包,即约 1.8 秒音频; +- 队列没有耗空; +- 142 个包全部正常播放; +- 用户没有听到高频卡顿。 + +这是已经由现场日志验证的改善。 + +### 4.3 队列真正耗空后转为安全静音和重缓冲 + +空队列检测阈值: + +```text +CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_REBUFFER_GAP_MS=120 +``` + +队列连续为空 120 ms 后: + +1. codec 输出音量设置为 0; +2. 不再收到一包就立即播放; +3. 播放状态切换为等待缓存; +4. 达到恢复目标后再统一恢复。 + +关键实现: + +```c +if (received != pdTRUE || packet.data == NULL || packet.len == 0U) { + if (snapshot_downstream_response_end_received()) { + maybe_notify_downstream_playback_completed_after_drain("queue_empty"); + } else if (playback_buffer_ready && + generation_started && + snapshot_downstream_response_active()) { + TickType_t now = xTaskGetTickCount(); + if (queue_empty_since == 0U) { + queue_empty_since = now; + note_downstream_queue_empty(); + } + + uint32_t empty_ms = + (uint32_t)pdTICKS_TO_MS(now - queue_empty_since); + if (empty_ms >= get_downstream_rebuffer_gap_ms(ctx)) { + if (recovery_underrun_count < UINT32_MAX) { + recovery_underrun_count++; + } + + UBaseType_t recovery_target = + get_downstream_recovery_packet_count( + ctx, + recovery_underrun_count); + + (void)set_downstream_output_mute_if_needed( + ctx, + true, + "Playback underrun"); + + playback_buffer_ready = false; + underrun_since = queue_empty_since; + buffer_wait_since = now; + first_recovery_packet_tick = 0U; + queue_empty_since = 0U; + + ESP_LOGW(TAG, + "Downstream protection engaged: " + "empty_ms=%u underrun=%u " + "recovery_target=%u packets " + "target_ms=%u output_muted=1", + (unsigned)empty_ms, + (unsigned)recovery_underrun_count, + (unsigned)recovery_target, + (unsigned)((uint32_t)recovery_target * + ctx->opus_frame_duration_ms)); + } + } + + vTaskDelay(pdMS_TO_TICKS(10)); + continue; +} +``` + +### 4.4 重复断流时自适应提高恢复库存 + +最大恢复缓存配置: + +```text +CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_MAX_REBUFFER_MS=640 +``` + +实现: + +```c +static UBaseType_t get_downstream_recovery_packet_count( + const multimodal_audio_ctx_t *ctx, + uint32_t underrun_count) +{ + uint32_t frame_ms = + ctx != NULL && ctx->opus_frame_duration_ms != 0U ? + ctx->opus_frame_duration_ms : 20U; + + uint32_t base_ms = + (uint32_t)CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PREBUFFER_MS; + if (base_ms < frame_ms) { + base_ms = frame_ms; + } + + uint32_t max_ms = + (uint32_t)CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_MAX_REBUFFER_MS; + if (max_ms < base_ms) { + max_ms = base_ms; + } + + uint64_t scaled_ms = + (uint64_t)base_ms * ((uint64_t)underrun_count + 1ULL); + uint32_t recovery_ms = + scaled_ms > max_ms ? max_ms : (uint32_t)scaled_ms; + + uint32_t packet_count = + (recovery_ms + frame_ms - 1U) / frame_ms; + UBaseType_t queue_len = get_downstream_playback_queue_len(); + return packet_count > (uint32_t)queue_len ? + queue_len : (UBaseType_t)packet_count; +} +``` + +当前 60 ms 帧对应: + +| 本次响应内耗空次数 | 理论目标 | 实际包数 | 实际库存 | +| --- | ---: | ---: | ---: | +| 第 1 次 | 320 ms | 6 包 | 360 ms | +| 第 2 次 | 480 ms | 8 包 | 480 ms | +| 第 3 次及以后 | 640 ms | 11 包 | 660 ms | + +网络越不稳定,同一次响应后续恢复时越保守,避免反复出现“恢复一下又耗空”。 + +### 4.5 I2S DMA 无新数据时自动清零 + +更新后的 I2S 初始化: + +```c +static esp_err_t init_i2s_bus(multimodal_audio_ctx_t *ctx) +{ + if (!ctx) { + return ESP_ERR_INVALID_ARG; + } + + i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG( + (i2s_port_t)CONFIG_MULTIMODAL_AUDIO_I2S_PORT, + I2S_ROLE_MASTER); + chan_cfg.dma_desc_num = CONFIG_MULTIMODAL_AUDIO_I2S_DMA_DESC_NUM; + chan_cfg.dma_frame_num = CONFIG_MULTIMODAL_AUDIO_I2S_DMA_FRAME_NUM; + chan_cfg.auto_clear_after_cb = true; + + esp_err_t err = i2s_new_channel( + &chan_cfg, + &ctx->tx_handle, + &ctx->rx_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, + "Create I2S channels failed: %s", + esp_err_to_name(err)); + return err; + } + + /* 后续标准 I2S 配置保持不变。 */ + return ESP_OK; +} +``` + +作用: + +- 上层停止写入时清理 DMA 输出; +- 降低重复旧采样或残留波形的风险; +- 配合 codec 音量门控,将异常断流表现控制为安静暂停。 + +本次没有新增直接切换 PA GPIO 的逻辑。PA 原有控制方式保持不变,断流保护使用 codec 输出音量归零和 I2S DMA 自动清零。 + +### 4.6 3 秒无音频且无新包时终止异常响应 + +配置: + +```text +CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_ACTIVE_STALL_MUTE_MS=3000 +``` + +上下文增加: + +```c +typedef struct { + /* 其他字段省略。 */ + bool downstream_response_active; + bool downstream_response_end_received; + TickType_t downstream_last_audio_tick; + TickType_t downstream_last_response_activity_tick; + esp_timer_handle_t downstream_idle_mute_timer; + /* 其他字段省略。 */ +} multimodal_audio_ctx_t; +``` + +定时器回调: + +```c +static void downstream_idle_mute_timer_cb(void *arg) +{ + multimodal_audio_ctx_t *ctx = + (multimodal_audio_ctx_t *)arg; + if (ctx == NULL) { + return; + } + + const bool response_active = + snapshot_downstream_response_active(); + + if (response_active) { + log_downstream_active_diagnostics(); + + TickType_t now = xTaskGetTickCount(); + TickType_t last_audio_tick = + snapshot_downstream_last_audio_tick(ctx); + uint32_t idle_ms = last_audio_tick != 0U ? + (uint32_t)pdTICKS_TO_MS(now - last_audio_tick) : 0U; + + uint32_t idle_timeout_ms = + get_downstream_idle_mute_timeout_ms(); + uint32_t stall_timeout_ms = + get_downstream_active_stall_mute_timeout_ms(); + + if (idle_ms < idle_timeout_ms) { + restart_downstream_idle_mute_timer( + ctx, + idle_timeout_ms - idle_ms); + return; + } + + if (ctx->initialized && ctx->codec_dev != NULL) { + (void)set_downstream_output_mute_if_needed( + ctx, + true, + "Active response gap"); + } + + if (idle_ms < stall_timeout_ms) { + uint32_t remaining_ms = stall_timeout_ms - idle_ms; +#if CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE + if (remaining_ms > + MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ACTIVE_INTERVAL_MS) { + remaining_ms = + MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ACTIVE_INTERVAL_MS; + } +#endif + restart_downstream_idle_mute_timer(ctx, remaining_ms); + return; + } + + TickType_t last_activity_tick = + snapshot_downstream_last_response_activity_tick(ctx); + uint32_t response_idle_ms = last_activity_tick != 0U ? + (uint32_t)pdTICKS_TO_MS(now - last_activity_tick) : + idle_ms; + + if (response_idle_ms < stall_timeout_ms) { + restart_downstream_idle_mute_timer( + ctx, + stall_timeout_ms - response_idle_ms); + return; + } + + abort_downstream_response_after_active_stall(ctx, idle_ms); + return; + } + + if (ctx->initialized && ctx->codec_dev != NULL) { + (void)set_downstream_output_mute_if_needed( + ctx, + true, + "Idle mute"); + } + + maybe_notify_downstream_playback_completed_after_drain("idle"); +} +``` + +终止异常响应: + +```c +static void abort_downstream_response_after_active_stall( + multimodal_audio_ctx_t *ctx, + uint32_t idle_ms) +{ + ESP_LOGW(TAG, + "Downstream protection watchdog: " + "abort stalled response idle_ms=%u", + (unsigned)idle_ms); + + (void)advance_downstream_playback_generation(); + notify_downstream_playback_stopped(); + update_downstream_response_state(false, false); + + if (audio_get_downstream_playback_task(ctx) != NULL || + audio_get_downstream_playback_starting(ctx)) { + audio_set_downstream_playback_stopping(ctx, true); + audio_set_downstream_playback_keep_stopping_on_exit(ctx, false); + audio_set_downstream_playback_stop_requested(ctx, true); + } + + drain_downstream_playback_queue_now(ctx); +} +``` + +判断逻辑不是单纯“3 秒没有播放就终止”,而是同时参考: + +- 最后一次成功音频输出时间; +- 最后一次下行音频包活动时间。 + +如果包仍在到达,只是暂时没有输出,会保持静音并继续等待;只有两者都停滞达到阈值才终止。 + +### 4.7 使用 generation 隔离新旧响应 + +下行包增加 generation: + +```c +typedef struct { + size_t len; + uint32_t playback_generation; + uint8_t *data; +} downstream_packet_t; +``` + +入队时标记: + +```c +downstream_packet_t packet = { + .len = len, + .playback_generation = + snapshot_downstream_playback_generation(), + .data = (uint8_t *)audio_psram_first_malloc(len), +}; +``` + +播放前验证: + +```c +if (packet.playback_generation != + snapshot_downstream_playback_generation()) { + free_downstream_packet(&packet); + continue; +} +``` + +响应被打断、超时或切换后,旧 generation 的迟到包会被释放,不会继续送入新的解码播放流程。 + +## 5. 播放状态机具体实现 + +播放任务新增以下状态: + +```c +downstream_packet_t packet = {0}; +uint32_t buffered_generation = UINT32_MAX; +TickType_t buffer_wait_since = 0U; +TickType_t queue_empty_since = 0U; +TickType_t underrun_since = 0U; +TickType_t first_recovery_packet_tick = 0U; +bool playback_buffer_ready = false; +bool generation_started = false; +uint32_t recovery_underrun_count = 0U; +``` + +含义: + +| 字段 | 含义 | +| --- | --- | +| `buffered_generation` | 当前状态机服务的响应 generation | +| `buffer_wait_since` | 本次初始或恢复缓存开始时间 | +| `queue_empty_since` | 连续空队列开始时间 | +| `underrun_since` | 本次真正 underrun 起始时间 | +| `first_recovery_packet_tick` | 耗空后第一包重新到达时间 | +| `playback_buffer_ready` | 是否已经达到允许播放的库存 | +| `generation_started` | 当前响应是否已经成功开始播放 | +| `recovery_underrun_count` | 当前响应内累计耗空次数 | + +generation 变化时全部重置: + +```c +uint32_t current_generation = + snapshot_downstream_playback_generation(); + +if (current_generation != buffered_generation) { + buffered_generation = current_generation; + buffer_wait_since = xTaskGetTickCount(); + queue_empty_since = 0U; + underrun_since = 0U; + first_recovery_packet_tick = 0U; + playback_buffer_ready = false; + generation_started = false; + recovery_underrun_count = 0U; +} +``` + +未达到缓存目标时不出队播放: + +```c +if (queue != NULL && !playback_buffer_ready) { + UBaseType_t queued_packets = + uxQueueMessagesWaiting(queue); + + UBaseType_t target_packets = generation_started ? + get_downstream_recovery_packet_count( + ctx, + recovery_underrun_count) : + get_downstream_prebuffer_packet_count(ctx); + + TickType_t now = xTaskGetTickCount(); + + if (generation_started && + queued_packets > 0U && + first_recovery_packet_tick == 0U) { + first_recovery_packet_tick = now; + } + + bool response_ended = + snapshot_downstream_response_end_received(); + + if (queued_packets >= target_packets || + (response_ended && queued_packets > 0U)) { + playback_buffer_ready = true; + } +} +``` + +只有 ready 后才消费队列: + +```c +if (queue != NULL && playback_buffer_ready) { + received = xQueueReceive( + queue, + &packet, + pdMS_TO_TICKS(20)); +} +``` + +这就是本次从“数据驱动立即播放”改为“带初始缓冲和恢复缓冲的状态机播放”的核心。 + +## 6. 日志更新 + +### 6.1 可配置诊断开关 + +新增 Kconfig: + +```kconfig +config MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE + bool "Enable downstream audio diagnostics" + default n + help + Collect and report low-rate downstream audio timing, queue + depth, queue starvation, enqueue failure, and playback + failure statistics. Active responses also report a + once-per-second wait snapshot when no audio advances. + Disabled by default to avoid diagnostic logging and + per-packet accounting in production builds. +``` + +默认配置: + +```text +# CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE is not set +``` + +当前现场测试使用的本地 `sdkconfig` 已开启该配置,因此能看到完整诊断日志。产品默认仍为关闭。 + +### 6.2 诊断统计结构 + +```c +#if CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE +typedef struct { + TickType_t response_begin_tick; + TickType_t arrival_last_tick; + TickType_t first_arrival_tick; + TickType_t first_playback_tick; + TickType_t last_playback_tick; + TickType_t queue_empty_since_tick; + uint32_t arrival_gap_min_ms; + uint32_t arrival_gap_max_ms; + uint64_t arrival_gap_total_ms; + uint32_t arrival_gap_samples; + UBaseType_t queue_depth_min; + UBaseType_t queue_depth_max; + uint32_t queue_empty_events; + uint32_t queue_empty_max_ms; + uint32_t enqueue_failures; + uint32_t playback_failures; + bool summary_logged; +} downstream_diagnostics_t; + +static downstream_diagnostics_t s_downstream_diagnostics; +#endif +``` + +关闭诊断后,对应函数使用空内联实现: + +```c +static inline void reset_downstream_diagnostics(void) {} +static inline void note_downstream_arrival(UBaseType_t queue_depth) +{ + (void)queue_depth; +} +static inline void note_downstream_playback(void) {} +static inline void note_downstream_queue_empty(void) {} +static inline void note_downstream_queue_recovered(void) {} +static inline void note_downstream_enqueue_failure(void) {} +static inline void note_downstream_playback_failure(void) {} +``` + +因此关闭时不会保留逐包诊断统计开销。 + +### 6.3 首包日志 + +```text +Downstream diagnostics first packet: +wait_ms=20 queue_depth=1 +``` + +含义: + +- `wait_ms`:响应开始到首包入队的时间; +- `queue_depth`:首包入队后的队列深度。 + +### 6.4 首次播放日志 + +```text +Downstream diagnostics first playback: +wait_ms=80 +``` + +表示响应开始到第一次成功写入 codec 的时间。 + +### 6.5 播放完成汇总日志 + +```text +Downstream diagnostics playback_complete: +response_age_ms=8560 +first_packet=1 +first_packet_ms=20 +last_packet_age_ms=1820 +first_playback=1 +first_playback_ms=80 +last_playback_age_ms=20 +queued=142 +played=142 +current_depth=0 +depth_min=1 +depth_max=30 +arrival_gap_ms(min/avg/max)=0/47/560 +empty_events=0 +empty_max_ms=0 +enqueue_failures=0 +playback_failures=0 +``` + +关键判断: + +| 字段 | 判断方法 | +| --- | --- | +| `first_packet=0` | 响应开始后首包始终没有到 | +| `first_packet=1 first_playback=0` | 包已到,但本地解码/播放没有推进 | +| `queued == played` | 所有成功入队的包都已消费 | +| `current_depth` | 汇总时队列剩余包数 | +| `depth_max` | 本次响应形成过的最大音频库存 | +| `arrival_gap_ms(.../max)` | 最大连续到包间隔 | +| `empty_events` | 播放期间队列实际耗空并恢复次数 | +| `enqueue_failures` | 包分配或入队失败次数 | +| `playback_failures` | 解码或 codec 写入失败次数 | + +### 6.6 active_wait 日志 + +响应仍然 active、但音频没有继续推进时输出: + +```text +Downstream diagnostics active_wait: +response_age_ms=... +first_packet=... +last_packet_age_ms=... +first_playback=... +last_playback_age_ms=... +queued=... +played=... +pending_estimate=... +enqueue_failures=... +playback_failures=... +``` + +判断方式: + +- `first_packet=0`:零首包停滞; +- `first_packet=1 first_playback=0`:包已到但播放未启动; +- `pending_estimate=0` 且 `last_packet_age_ms` 持续增长:下行供包停止; +- `pending_estimate>0` 且 `last_playback_age_ms` 持续增长:本地播放器或 I2S 路径阻塞。 + +### 6.7 初始缓存证明日志 + +诊断开启时输出: + +```text +Downstream protection evidence: +initial_wait_ms=30 +buffered_ms=180 +first_audio_jitter_budget_ms=180 +``` + +证明播放不是从首包立即开始,而是在开始前形成了指定音频库存。 + +现场还出现过: + +```text +Downstream protection evidence: +initial_wait_ms=20 +buffered_ms=1200 +first_audio_jitter_budget_ms=1200 +``` + +说明服务端当时批量到包,在播放任务开始消费前已经形成 20 包、约 1.2 秒库存。 + +### 6.8 耗空保护日志 + +队列连续空达到阈值时,无论诊断开关是否开启,都会保留关键运行告警: + +```text +Downstream protection engaged: +empty_ms=120 +underrun=1 +recovery_target=6 packets +target_ms=360 +output_muted=1 +``` + +这条日志说明: + +- 队列确实连续空了 120 ms; +- 输出已经静音; +- 播放器已经进入重缓冲; +- 第一次恢复需要 6 包,即约 360 ms 音频。 + +### 6.9 恢复证明日志 + +达到恢复目标时,普通运行日志输出: + +```text +Downstream recovery buffer ready: +depth=6 +target=6 +buffered_ms=360 +waited_ms=... +outage_ms=... +``` + +诊断开启时额外输出: + +```text +Downstream protection evidence: +underrun=1 +outage_ms=... +first_packet_wait_ms=... +held_after_first_packet_ms=... +buffered_ms=360 +legacy_would_resume_at_first_packet=1 +``` + +字段含义: + +- `first_packet_wait_ms`:耗空后重新等到第一包的时间; +- `held_after_first_packet_ms`:第一包到达后,新逻辑继续等待库存的时间; +- `buffered_ms`:真正恢复时的库存; +- `legacy_would_resume_at_first_packet=1`:旧逻辑此时会直接恢复。 + +如果: + +```text +held_after_first_packet_ms > 0 +buffered_ms >= target_ms +``` + +就能从日志证明新逻辑没有“来一包播一包”,而是等待形成连续播放能力后再恢复。 + +### 6.10 watchdog 日志 + +音频输出和下行包活动都停滞达到 3 秒时: + +```text +Downstream protection watchdog: +abort stalled response idle_ms=3000 +``` + +表示异常 generation 已终止、旧队列已清理,输出保持静音。 + +### 6.11 WebSocket 日志说明 + +以下日志不是本次新增,原来已经存在: + +```text +WS binary event queued +WS binary worker frame +Downstream binary frame +``` + +它们是定期采样日志,可观察: + +- WebSocket 原始 event 是否进入队列; +- worker 是否积压; +- 会话回调是否被调用。 + +现有现场日志中,这三层通常相差 0~10 ms,`pending` 通常为 0,说明采样时本地 WebSocket 路径没有明显拥塞。 + +因为它们不是逐异常包记录,所以目前不能用它们严格定位 560 ms 或 720 ms 空洞发生在服务端还是公网传输阶段。 + +## 7. 配置更新 + +Kconfig 新增: + +```kconfig +config MULTIMODAL_AUDIO_DOWNSTREAM_ACTIVE_STALL_MUTE_MS + int "Downstream active response stall timeout (ms)" + default 3000 + range 500 10000 + +config MULTIMODAL_AUDIO_DOWNSTREAM_PREBUFFER_MS + int "Downstream playback prebuffer (ms)" + default 160 + range 0 1000 + +config MULTIMODAL_AUDIO_DOWNSTREAM_REBUFFER_GAP_MS + int "Downstream underrun detection delay (ms)" + default 120 + range 20 500 + +config MULTIMODAL_AUDIO_DOWNSTREAM_MAX_REBUFFER_MS + int "Maximum adaptive downstream recovery buffer (ms)" + default 640 + range 160 2000 + +config MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE + bool "Enable downstream audio diagnostics" + default n +``` + +默认配置: + +```text +# CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_DIAGNOSTICS_ENABLE is not set +CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_ACTIVE_STALL_MUTE_MS=3000 +CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PREBUFFER_MS=160 +CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_REBUFFER_GAP_MS=120 +CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_MAX_REBUFFER_MS=640 +``` + +保护功能和诊断开关相互独立: + +- 关闭诊断不会关闭预缓冲、重缓冲、I2S 清零和 watchdog; +- 关闭诊断只移除逐包统计和详细 evidence 日志; +- 关键运行告警仍会保留。 + +## 8. 更新前后行为对比 + +| 场景 | 更新前 | 更新后 | +| --- | --- | --- | +| 开始播放 | 首包到达即播放 | 至少形成 3 包、180 ms 库存 | +| 普通小抖动 | 容易消耗掉全部余量 | 优先由初始库存吸收 | +| 较大到包间隔 | 可能出现短暂停顿 | 实际库存足够时完全无感 | +| 队列耗空 | 下一包到达即恢复 | 空 120 ms 后静音并进入重缓冲 | +| 连续耗空 | 反复来一包播一包 | 恢复目标逐步增加到约 660 ms | +| 长时间无包 | active 状态可能长期保持 | 音频和包都停滞 3 秒后终止 | +| I2S 无新数据 | 存在残留输出风险 | DMA callback 后自动清零 | +| 响应切换 | 旧包归属不明确 | generation 不匹配直接丢弃 | +| 问题定位 | 缺少完整队列时序 | 可区分供包、入队、播放和耗空 | + +## 9. 当前现场验证结论 + +最新日志中: + +```text +Downstream response begin 21 次 +Downstream diagnostics playback_complete 16 次 +Downstream protection engaged 0 次 +Downstream recovery buffer ready 0 次 +Downstream protection watchdog 0 次 +active_wait 0 次 +codec write failed 0 次 +opus decode failed 0 次 +``` + +16 次完整播放汇总全部满足: + +```text +empty_events=0 +enqueue_failures=0 +playback_failures=0 +``` + +最长的一次到包间隔: + +```text +arrival_gap_max=560 ms +depth_max=30 +queued=142 +played=142 +empty_events=0 +``` + +因此可以确认: + +1. 高频卡顿没有再次复现; +2. 上游较大的供包抖动实际出现过; +3. 这次 560 ms 抖动被队列库存完全吸收; +4. 初始预缓冲和更充裕的队列余量已经产生实际效果; +5. 尚未现场触发真正耗空后的 360~660 ms 自适应恢复路径; +6. watchdog 保护已实现并通过构建,但尚未由真实 3 秒停滞触发验证。 + +## 10. 资源影响 + +### 10.1 Flash + +构建结果: + +```text +诊断开启:0x1e1af0 +诊断关闭:0x1e1080 +``` + +大致影响: + +- 保护逻辑约增加 2 KB Flash; +- 开启诊断相对关闭再增加约 2.7 KB Flash。 + +### 10.2 RAM 和 PSRAM + +- `downstream_packet_t` 增加一个 `uint32_t playback_generation`; +- 当前队列长度为 1024,队列存储优先分配在 PSRAM; +- generation 字段约增加 4 KB PSRAM; +- 恢复缓存通常持有 6~11 个 Opus 包,约占 1~3 KB PSRAM; +- 诊断结构体只在诊断开启时存在。 + +### 10.3 CPU 和任务 + +- 没有新增任务; +- 复用现有下行播放任务; +- 复用现有 idle mute 定时器; +- 增加少量队列深度检查和时间戳计算; +- I2S 自动清零由驱动完成,不新增软件任务。 + +## 11. 构建验证 + +已经完成: + +```text +诊断开启配置构建通过 +诊断关闭配置构建通过 +架构边界检查通过 +git diff --check 通过 +``` + +两种配置均无新增编译告警。 + +## 12. 最终结论 + +本次问题的直接触发点是下行 TTS 音频供包出现数百毫秒空洞;设备端原有首包立即播放、缺少耗空重缓冲、active 响应抑制 idle mute、I2S 未启用自动清零等因素,使普通网络抖动可能进一步表现为明显停顿、反复启停或高频残留声。 + +本次更新将播放器改为带初始预缓冲和自适应恢复缓冲的状态机,并增加 codec 静音、I2S DMA 自动清零、3 秒双进度 watchdog、generation 隔离和可关闭诊断。 + +当前现场已经证明 560 ms 到包抖动被库存完整吸收,未出现队列耗空或高频卡顿。真正队列耗空后的自适应恢复和 watchdog 路径已经完成实现与构建验证,仍需等待一次更严重的真实下行断流完成现场验证。 diff --git a/docs/knowledge vault/flash-and-memory-capacity-analysis.md b/docs/knowledge vault/flash-and-memory-capacity-analysis.md new file mode 100644 index 0000000..2fb572a --- /dev/null +++ b/docs/knowledge vault/flash-and-memory-capacity-analysis.md @@ -0,0 +1,960 @@ +# Flash 与内存容量分析、风险及后续开发建议 + +本文记录当前固件的 Flash 分区使用情况、静态 RAM 使用情况、运行时内存风险、可优化内容和后续开发约束,用于功能评审、资源接入、OTA 设计和长期容量管理。 + +LittleFS 资源从源码到设备的详细流程见: + +- [`docs/littlefs-resource-build-flash-mount.md`](littlefs-resource-build-flash-mount.md) + +## 1. 分析基线与数据边界 + +本次分析基于: + +```text +分支:feature/st7789p3-new-board +提交:d5409ea +目标:ESP32-S3 +ESP-IDF:v5.5.4 +Flash 配置:16 MiB +分区表:16m.csv +应用镜像:build/TQ_Cat_Code.bin +链接映射:build/TQ_Cat_Code.map +资源镜像:build/gifpack.bin +``` + +当前 `build/` 目录中的应用产物生成于 2026-08-03,资源镜像生成于 2026-08-04。后续代码或资源变化后,应重新构建并更新本文数据。 + +需要明确区分两类数据: + +### 1.1 本地构建可以准确得到的数据 + +- Flash 分区的地址和容量; +- 应用程序二进制大小; +- `gifpack` 镜像大小; +- LittleFS 镜像内的文件数量、文件内容大小和已用块数; +- 静态 IRAM、D/IRAM、`.data`、`.bss` 使用量; +- 链接阶段放入 PSRAM 的静态 BSS 大小。 + +### 1.2 必须连接设备运行后才能得到的数据 + +- 芯片实际检测到的 PSRAM 总容量; +- 启动完成后的内部堆剩余量; +- PSRAM 剩余量; +- 最小历史空闲堆; +- 最大连续内部内存块; +- 最大连续 DMA 内存块; +- 每个任务的真实栈高水位; +- Wi-Fi、TLS、WebSocket、音频同时工作时的峰值占用; +- `logfs`、`model`、`coredump` 在具体设备上的真实内容和占用。 + +因此,本文的静态分析可以判断容量趋势和结构性风险,但不能替代真机峰值内存测试。 + +## 2. Flash 总体布局 + +设备使用 16 MiB Flash: + +```text +16 MiB = 16,777,216 bytes = 0x1000000 +``` + +`16m.csv` 已经把全部 16 MiB 空间分配完毕,没有未划分空间。 + +| 区域 | 地址范围 | 分配大小 | 占 Flash 比例 | 用途 | +| --- | ---: | ---: | ---: | --- | +| Bootloader、分区表、NVS、OTA data、PHY | `0x000000-0x010000` | 64 KiB | 0.39% | 系统启动和持久化基础数据 | +| `gifpack` | `0x010000-0xA90000` | 10.5 MiB | 65.63% | LCD 图片和提示音 LittleFS | +| `logfs` | `0xA90000-0xAD0000` | 256 KiB | 1.56% | 远程日志落盘队列 | +| `ota_0` | `0xAD0000-0xCA0000` | 1.8125 MiB | 11.33% | 应用 OTA 槽位 0 | +| `ota_1` | `0xCA0000-0xE70000` | 1.8125 MiB | 11.33% | 应用 OTA 槽位 1 | +| `model` | `0xE70000-0xFF0000` | 1.5 MiB | 9.38% | 模型数据分区 | +| `coredump` | `0xFF0000-0x1000000` | 64 KiB | 0.39% | 崩溃转储 | + +Flash 布局可以简化为: + +```text +0x000000 +├── 系统区域 64 KiB +├── gifpack 10.5 MiB +├── logfs 256 KiB +├── ota_0 1.8125 MiB +├── ota_1 1.8125 MiB +├── model 1.5 MiB +└── coredump 64 KiB +0x1000000 +``` + +## 3. 应用程序 Flash 使用情况 + +单个 OTA 应用分区大小: + +```text +0x1d0000 = 1,900,544 bytes = 1.8125 MiB +``` + +当前应用二进制: + +```text +build/TQ_Cat_Code.bin +实际文件大小:1,657,280 bytes +``` + +链接工具报告: + +```text +Flash .text: 1,134,436 bytes +Flash .rodata: 388,744 bytes +其他: 256 bytes +Flash 非 RAM 合计:1,523,436 bytes +总镜像大小: 1,657,135 bytes +最终 .bin: 1,657,280 bytes +``` + +单个 OTA 槽位占用: + +```text +使用率:87.20% +剩余:243,264 bytes + ≈ 237.56 KiB +``` + +两个 OTA 槽位必须具有相同容量。`ota_1` 即使当前没有运行,也不能简单视为空闲空间,因为 OTA 升级需要把新固件完整写入另一个槽位,并保留切换和回滚能力。 + +### 3.1 应用分区风险判断 + +风险等级:**高**。 + +原因: + +- 当前只剩约 238 KiB; +- 新增网络库、编解码库、证书、字体或大型只读表可能快速消耗空间; +- Debug 配置、日志字符串和完整证书包都会增加镜像; +- OTA 要求每个新版本都必须装入同样大小的备用槽; +- 链接成功不代表长期有充足增长空间。 + +建议设置容量门槛: + +| 使用率 | 建议处理 | +| ---: | --- | +| `< 80%` | 正常 | +| `80%-85%` | 开始跟踪增长趋势 | +| `85%-90%` | 构建警告;每个新增依赖必须解释体积影响 | +| `>= 90%` | 阻止普通功能合入,先做容量治理 | + +当前 87.20%,已经处于“必须持续跟踪”的区间。 + +## 4. `gifpack` 资源分区使用情况 + +`gifpack` 分区容量: + +```text +0xa80000 = 11,010,048 bytes = 10.5 MiB +``` + +当前临时资源目录中: + +| 资源类型 | 文件数 | 文件内容大小 | +| --- | ---: | ---: | +| LCD RLE | 41 | 9,379,380 bytes | +| Opus 提示音 | 30 | 378,918 bytes | +| 合计 | 71 | 9,758,298 bytes | + +读取 `gifpack.bin` 的 LittleFS 块信息后: + +```text +块大小:4,096 bytes +总块数:2,688 +已用块:2,426 +空闲块:262 + +LittleFS 已用:9,936,896 bytes ≈ 9.48 MiB +LittleFS 空闲:1,073,152 bytes ≈ 1.02 MiB +使用率:90.25% +``` + +资源文件内容为约 9.31 MiB,LittleFS 已用空间约 9.48 MiB,两者差值来自目录、索引、元数据、块对齐和文件系统管理开销。 + +`build/gifpack.bin` 的文件大小始终等于整个分区大小,即 10.5 MiB。不能通过 `ls -lh build/gifpack.bin` 判断资源真实占用,因为镜像会填充到分区全尺寸。 + +### 4.1 资源分区风险判断 + +风险等级:**高**。 + +原因: + +- 当前使用率已经超过 90%; +- 理论空闲约 1.02 MiB,实际新增文件还需要 LittleFS 管理开销; +- 动画通常一次增加多帧,每帧可能为 200-380 KiB; +- 新增 3-5 张全屏 RLE 图片就可能耗尽剩余空间; +- 资源分区没有 A/B 设计,未来直接在线更新存在断电损坏风险; +- 普通应用 OTA 不会更新 `gifpack`,容易出现应用代码与资源版本不匹配。 + +建议容量门槛: + +| 使用率 | 建议处理 | +| ---: | --- | +| `< 80%` | 正常 | +| `80%-90%` | 新增资源必须记录大小 | +| `90%-95%` | 高风险;新增动画前必须优化或清理 | +| `>= 95%` | 阻止资源合入 | + +当前 90.25%,已经进入高风险区间。 + +## 5. 当前完整烧录包含什么 + +当前 `build/flasher_args.json` 登记: + +```text +0x000000 bootloader/bootloader.bin +0x008000 partition_table/partition-table.bin +0x00D000 ota_data_initial.bin +0x010000 gifpack.bin +0xAD0000 TQ_Cat_Code.bin +``` + +常规完整烧录会写入: + +- Bootloader; +- 分区表; +- OTA 初始状态; +- `gifpack` 资源镜像; +- `ota_0` 应用程序。 + +以下分区没有预生成镜像加入当前完整烧录清单: + +- `logfs`:设备运行时挂载、格式化和写入; +- `ota_1`:应用 OTA 时写入; +- `model`:需要独立的模型写入流程; +- `coredump`:设备崩溃时写入。 + +因此,本地构建无法判断具体设备上这几个动态分区的真实使用量。 + +## 6. 静态 RAM 使用情况 + +链接映射报告: + +| RAM 区域 | 已使用 | 总容量 | 剩余 | 使用率 | +| --- | ---: | ---: | ---: | ---: | +| 独立 IRAM | 16,384 B | 16,384 B | 0 B | 100% | +| D/IRAM | 173,387 B | 341,760 B | 168,373 B | 50.73% | +| PSRAM 静态 BSS | 14,056 B | 取决于硬件 | 未知 | 未知 | + +D/IRAM 组成: + +```text +.data: 23,944 bytes +.bss: 56,072 bytes +.text: 93,371 bytes +合计: 173,387 bytes +剩余: 168,373 bytes ≈ 164.43 KiB +``` + +其中: + +- `.data`:有初始值的全局和静态变量; +- `.bss`:零初始化的全局和静态变量; +- `.text`:必须驻留 RAM 执行的函数; +- 剩余 D/IRAM 会参与形成运行时可用内部内存,但启动后还会被任务栈、DMA、驱动和堆分配继续使用。 + +### 6.1 IRAM 风险 + +独立 IRAM 当前为: + +```text +.text: 15,356 bytes +.vectors: 1,028 bytes +合计: 16,384 bytes +剩余: 0 bytes +``` + +风险等级:**高**。 + +这不表示设备运行时马上耗尽普通堆,但意味着独立 IRAM 链接区域没有静态增长余量。后续新增以下内容必须谨慎: + +- `IRAM_ATTR` 函数; +- ISR 中必须执行的代码; +- Flash cache 关闭期间仍需运行的函数; +- 驱动要求放入 IRAM 的回调; +- 性能优化时强制进入 IRAM 的热点代码。 + +不要为了普通性能优化随意添加 `IRAM_ATTR`。添加后必须重新运行 size 分析并检查链接结果。 + +### 6.2 D/IRAM 风险 + +静态使用率约 50.7%,从链接角度尚可,但运行时内部 RAM 压力预计较高,因为以下内容不能完全依赖 PSRAM: + +- Wi-Fi 和部分 lwIP 缓冲; +- SPI/I2S DMA 描述符和 DMA 缓冲; +- TLS 硬件 AES bounce buffer; +- 中断相关数据; +- 部分 FreeRTOS 和驱动对象; +- 显式创建在内部 RAM 的任务栈; +- 小于内部优先阈值的普通分配。 + +风险等级:**中高**,需要真机峰值数据确认。 + +## 7. PSRAM 配置与使用策略 + +当前配置启用: + +```text +Octal PSRAM +80 MHz +启动初始化 +加入 malloc 堆 +允许 BSS 放入外部内存 +允许任务栈放入外部内存 +Wi-Fi/lwIP 尝试使用 PSRAM +``` + +关键参数: + +```text +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096 +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768 +``` + +设计意图: + +- 大型普通内存申请可以进入 PSRAM; +- 较小申请倾向内部 RAM; +- 为 DMA 和硬件驱动保留 32 KiB 内部 RAM; +- 音频、WebSocket、HTTP 等大栈和队列优先放入 PSRAM。 + +链接阶段已有约 13.7 KiB BSS 放入 PSRAM。 + +### 7.1 当前未知项 + +配置使用自动 PSRAM 类型检测,没有在项目中写死物理容量。必须查看真机启动日志或调用 `esp_psram_get_size()` 才能确认实际是 2、4 还是 8 MiB。 + +风险等级:**中**。 + +后续量产前必须把实际模组型号、PSRAM 容量和启动检测结果纳入硬件验收,避免开发板和量产板容量不同。 + +## 8. 运行时主要内存消费者 + +### 8.1 项目任务栈 + +当前较大的显式任务栈: + +| 任务 | 配置栈 | 默认内存策略 | +| --- | ---: | --- | +| 音频采集 | 32 KiB | PSRAM 优先,失败回退内部 RAM | +| 音频发送 | 12 KiB | PSRAM 优先,失败回退内部 RAM | +| 音频播放 | 12 KiB | PSRAM 优先,失败回退内部 RAM | +| WebSocket ordered worker | 12 KiB | PSRAM 优先 | +| WebSocket client | 8-32 KiB 候选 | 第三方组件内部创建,主要消耗内部 RAM | +| LCD 渲染 | 12 KiB | 内部 RAM | +| Wi-Fi 状态 | 12 KiB | 内部 RAM | +| LAN HTTP | 8 KiB | PSRAM 优先 | +| 远程日志 | 8 KiB | 内部 RAM | +| 每日运势 | 6 KiB | 内部 RAM | +| 多模态 watchdog | 6 KiB | 内部 RAM | +| 系统事件 | 4 KiB | 内部 RAM | +| 按键 | 4 KiB | 内部 RAM | +| 模型命令 | 4 KiB | PSRAM 优先 | + +仅项目显式任务的配置栈总量已经超过 130 KiB。除此之外,ESP-IDF 还会创建: + +- main task; +- 两个 IDLE task; +- timer task; +- system event task; +- Wi-Fi task; +- TCP/IP task; +- HTTP/WebSocket 内部任务; +- 驱动相关任务。 + +不能把所有配置栈简单相加后与静态剩余 RAM 比较,因为: + +- 一部分任务不同时存在; +- 一部分栈优先放入 PSRAM; +- 任务控制块和队列还有额外开销; +- 第三方组件可能自行创建内部任务。 + +但这些数据说明,任务栈是运行时内存治理的重点。 + +### 8.2 音频队列 + +音频上行发送队列: + +```text +长度:24 +单项数据容量:约 1 KiB +队列数据:约 24 KiB +分配策略:PSRAM 优先 +``` + +音频下行播放队列: + +```text +长度:1,024 +队列项保存长度和数据指针 +队列描述符约 16 KiB +音频正文另外动态分配 +分配策略:PSRAM 优先 +``` + +下行正文占用取决于服务器下发速度、网络抖动和播放消费速度。极端积压时,真实内存占用会明显高于队列描述符本身。 + +### 8.3 WebSocket 队列 + +```text +消息队列上限:512 +单个描述符约 20 bytes +描述符队列约 10 KiB +消息正文动态分配,单条最大值可达数 KiB +``` + +描述符队列优先放入 PSRAM,但正文积压量仍可能成为峰值风险。 + +### 8.4 远程日志静态队列 + +远程日志启用: + +```text +CONFIG_TQ_REMOTE_LOG_ENABLE=y +队列长度:128 +单条文本容量:256 bytes +``` + +`remote_log_entry_t` 每项还包含时间戳、日志等级和状态字段,整个静态队列约占 33 KiB 内部 BSS。这是当前 `control_plane` 静态 BSS 约 37.8 KiB 的主要来源。 + +风险等级:**中高**。 + +这块内存在启用远程日志后始终常驻,即使没有积压日志也不会释放。 + +### 8.5 LCD 缓冲 + +LCD 采用分行读取,不加载整张 320×240 图片: + +```text +行缓冲:320 × 8 × 2 bytes = 5,120 bytes +RLE chunk:128 × 4 bytes = 512 bytes +``` + +这部分设计较合理。若整帧 RGB565 全部加载,将需要: + +```text +320 × 240 × 2 = 153,600 bytes +``` + +因此,后续不要轻易把当前流式绘制改成整帧内部 RAM 缓冲。确实需要双缓冲时,应评估 PSRAM、DMA 约束和帧率收益。 + +## 9. 运行时内存风险 + +### 9.1 内部连续内存不足 + +风险等级:**高**。 + +系统可能出现以下情况: + +```text +总 free_heap 看起来很多 +但 largest_internal 或 largest_dma 很小 +导致 TLS、AES、SPI、I2S 或 Wi-Fi 分配失败 +``` + +重点场景: + +- Wi-Fi 刚连接时进行 HTTPS Token 请求; +- TLS 握手和 WebSocket 建连重叠; +- 音频采集、编码、播放同时工作; +- LCD 正在 SPI DMA 传输; +- 远程日志同时上传 HTTPS; +- LAN HTTP 服务同时处理请求。 + +判断内存是否健康,不能只看 `esp_get_free_heap_size()`,还必须观察: + +```text +free_internal +largest_internal +largest_dma +free_spiram +largest_spiram +minimum_free_heap +``` + +### 9.2 内存碎片 + +风险等级:**中高**。 + +TLS、JSON、WebSocket 消息、音频包和 HTTP body 都会产生不同大小、不同生命周期的动态分配。长时间连接、重连、失败重试后,可能出现总空闲量尚可但最大连续块下降的情况。 + +判断方法: + +- 多轮重连后比较 `free_internal`; +- 比较 `largest_internal` 是否持续下降; +- 比较 `largest_dma` 是否持续下降; +- 比较连接前、握手中、交互中、断开后的数值能否恢复; +- 重复语音交互 100 次以上观察最低值和趋势。 + +### 9.3 PSRAM 回退导致内部 RAM 突增 + +很多任务和队列采用“PSRAM 优先,失败回退内部 RAM”。如果 PSRAM 初始化失败、容量不足或碎片严重,多个对象可能同时回退到内部 RAM,使内部堆瞬间恶化。 + +必须关注日志: + +```text +PSRAM allocation failed +fallback to internal +Task create with PSRAM stack failed +``` + +量产版本不应把这些日志当成无害告警。 + +### 9.4 任务栈过大或过小 + +风险等级:**中**。 + +- 栈过大:浪费内部 RAM 或 PSRAM; +- 栈过小:发生栈溢出、随机崩溃; +- 只在正常路径测量不足以覆盖 TLS 错误、JSON 大包、日志上传等深调用路径。 + +当前 `CONFIG_TQ_TASK_MONITOR_ENABLE` 关闭,缺少持续任务栈可观测性。建议在压力测试固件中启用,不一定默认用于量产版本。 + +## 10. 跨 Flash 与 RAM 的组合风险 + +### 10.1 资源与应用版本不一致 + +普通应用 OTA 只更新 `ota_0/ota_1`,不更新 `gifpack`。如果新代码引用新资源路径,而设备仍保留旧 `gifpack`,会出现: + +```text +open LCD asset failed +Open prompt failed +``` + +建议: + +- 应用中声明资源包版本; +- `gifpack` 中保存资源 manifest; +- 启动时比较应用要求版本和资源版本; +- 不匹配时进入明确降级路径,不要反复尝试打开不存在的文件。 + +### 10.2 单份资源分区在线更新风险 + +当前只有一个 `gifpack` 分区。若未来在线覆盖更新: + +- 更新中断电可能损坏整个资源文件系统; +- 新镜像校验失败时没有旧资源可回退; +- 应用和资源切换无法原子完成。 + +在没有完整方案前,不建议直接实现“边运行边覆盖单个 gifpack”。可选方向: + +1. 资源仅随工厂完整烧录更新; +2. 下载到备用区域,校验后再切换; +3. 设计 `gifpack_0/gifpack_1` 双资源分区; +4. 使用版本化资源容器和原子状态记录; +5. 将少量新增资源并入应用,但需评估 OTA 槽容量。 + +### 10.3 LittleFS 挂载依赖开机音流程 + +当前 `/littlefs` 主要由播放开机音前的 `voice_prompt_play_file()` 触发挂载。LCD 随后假定 `/littlefs` 已经可用。 + +如果开机音被关闭或音频初始化提前失败,LCD 可能在文件系统未挂载时读取资源。 + +建议把 `platform_mount_littlefs()` 提升为明确的启动阶段,由 `app_composition` 在音频和 LCD 之前编排,并保持调用幂等。 + +风险等级:**中高**。 + +## 11. 优化建议与优先级 + +### P0:立即建立容量门禁 + +在 CI 或构建检查中记录并比较: + +```text +应用 .bin 大小 +应用分区使用率 +gifpack LittleFS 已用块数 +gifpack 使用率 +IRAM 使用量 +D/IRAM 静态使用量 +``` + +建议规则: + +- 应用分区达到 85% 发出警告; +- 应用分区达到 90% 阻止合入; +- `gifpack` 达到 90% 发出高风险警告; +- `gifpack` 达到 95% 阻止合入; +- IRAM 增长必须在 PR 中说明; +- 新增库必须附带 size diff; +- 新增动画必须附带新增资源字节数和预计剩余空间。 + +### P0:采集真机峰值内存 + +至少在以下时间点采集: + +1. Bootloader 后、应用初始化前; +2. NVS 和 LittleFS 挂载后; +3. Wi-Fi 初始化后; +4. Wi-Fi 已连接; +5. TLS Token 请求前和握手中; +6. WebSocket 已连接; +7. 音频采集和播放同时进行; +8. LCD 动画播放中; +9. 远程日志上传中; +10. 所有业务进入稳态后; +11. 多次断线重连后; +12. 长时间运行 24 小时后。 + +每次至少记录: + +```text +esp_get_free_heap_size() +esp_get_minimum_free_heap_size() +heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) +heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) +heap_caps_get_largest_free_block(MALLOC_CAP_DMA) +heap_caps_get_free_size(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) +heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) +``` + +不要一开始就设定绝对通过阈值。先收集多台设备、多种网络条件的基线,再根据 TLS、DMA 和音频最大单次申请量设定安全阈值。 + +### P1:优化远程日志静态队列 + +当前约 33 KiB 内部 BSS 常驻,可考虑: + +1. 将队列动态分配到 PSRAM; +2. PSRAM 失败时回退到更短内部队列,而不是同等大小回退; +3. 将 `CONFIG_TQ_REMOTE_LOG_QUEUE_LEN` 从 128 调低; +4. 将单条文本上限从 256 调低; +5. 使用变长块池或分层队列; +6. 评估是否需要在所有构建类型中默认启用远程日志。 + +修改时要保持日志钩子并发安全,不能为了省内存破坏日志回调的锁和生命周期。 + +### P1:基于高水位调整任务栈 + +压力测试时开启任务监控,记录所有任务最小剩余栈。 + +调优原则: + +- 不以一次正常启动数据为依据; +- 覆盖错误路径、重连、超时和大 JSON; +- 保留安全余量; +- 优先优化长期存活且栈明显过大的任务; +- 对 ISR、DMA、Flash cache 关闭相关任务,不要盲目迁移到 PSRAM; +- 修改后执行长时间和异常路径测试。 + +### P1:治理资源体积 + +当前 LCD 资源占 `gifpack` 的绝大部分。建议: + +- 清理白名单中已不再播放的资源; +- 检查是否存在内容重复的帧; +- 对相邻帧考虑差分、局部刷新或共享背景; +- 检查 RLE 色彩和扫描方向是否影响压缩率; +- 避免把相同首帧复制到多个动画组; +- 新增资源时记录原图大小、RLE 大小、run 数和压缩率; +- 建立资源 manifest,自动检查 CMake 白名单和代码映射一致性。 + +当前 `tools/png_to_rle.py` 生成标准 TGA RLE,而 `lcd_direct` 读取原始 `uint16_t color + uint16_t count` 数据,两者不兼容。应统一并验证资源转换工具,避免开发者误用。 + +### P1:确认 `model` 分区是否仍需要 1.5 MiB + +当前 `model` 分区没有进入常规烧录清单。需要确认: + +- 是否由工厂工具单独烧录; +- 当前功能是否读取该分区; +- 量产设备中是否有有效模型数据; +- 1.5 MiB 是否为未来预留; +- 能否缩减并重新分配给应用或资源。 + +如果确认未使用,可以评估重新分区。但调整前必须同时考虑: + +- 两个 OTA 分区需要同步增大; +- 已出货设备变更分区表的迁移风险; +- OTA 是否能够安全改变分区布局; +- 模型恢复和工厂烧录流程; +- Flash 擦除和回滚策略。 + +不要只修改 CSV 就认为升级安全。 + +### P2:优化队列和积压策略 + +音频和 WebSocket 队列容量较大。建议根据真实峰值观测: + +- 最大队列深度; +- 平均排队时间; +- 丢包数量; +- 消费速率; +- 网络抖动时的峰值; +- 语音播放延迟。 + +如果队列长期只使用很小比例,可以缩短;如果积压很深,应优先解决背压和消费速率,不要单纯继续增大队列。 + +### P2:增加资源版本和完整性校验 + +在 `gifpack` 中增加小型 manifest,例如: + +```json +{ + "resource_version": "1.3.0", + "format_version": 1, + "target": "esp32s3-st7789p3", + "files": { + "lcd/jichubiaoqing0.rle": { + "size": 21644, + "run_count": 5411, + "sha256": "..." + } + } +} +``` + +启动时不必校验所有大文件,但至少应检查: + +- manifest 可读取; +- 资源版本兼容; +- 关键启动资源存在; +- 文件格式版本正确; +- 必要时抽样或按需校验哈希。 + +## 12. 后续开发准入要求 + +### 12.1 新增普通代码或第三方库 + +PR 中应记录: + +- 应用 `.bin` 增量; +- Flash `.text` 和 `.rodata` 增量; +- IRAM 增量; +- D/IRAM `.data/.bss/.text` 增量; +- 是否新增长期任务; +- 任务栈配置和内存区域; +- 是否新增大队列或大缓冲; +- 是否在 TLS、音频、LCD 并发路径中运行。 + +### 12.2 新增 LCD 或音频资源 + +PR 中应记录: + +- 新增文件数; +- 新增资源总字节数; +- 新 `gifpack` 使用率; +- 预计剩余空间; +- LCD `run_count` 是否更新; +- CMake 白名单是否更新; +- 平台资源映射是否更新; +- domain 播放顺序是否更新; +- 完整 `idf.py flash` 硬件验证结果。 + +### 12.3 新增长期任务 + +必须定义: + +- 明确 owner 层; +- 创建、启动、停止和销毁流程; +- 栈大小依据; +- 内部 RAM 或 PSRAM 选择依据; +- PSRAM 失败回退行为; +- 有界停止时间; +- 栈高水位观测方式; +- 队列和动态内存上限。 + +### 12.4 新增大型动态缓冲 + +必须回答: + +- 最大申请尺寸是多少; +- 是否要求 DMA 能力; +- 是否必须在内部 RAM; +- 生命周期多长; +- 是否会与 TLS、音频、LCD 同时存在; +- PSRAM 失败时如何降级; +- 是否存在无上限增长; +- 分配失败是否能安全恢复。 + +### 12.5 修改分区表 + +必须进行完整评审: + +- 总大小是否仍为 16 MiB; +- 两个 OTA 槽是否等大; +- 当前和历史固件是否兼容; +- 已部署设备如何迁移; +- 工厂烧录脚本是否更新; +- OTA、资源、模型和 coredump 流程是否更新; +- 地址变化是否影响任何硬编码工具; +- 断电和回滚是否安全。 + +## 13. 推荐测试矩阵 + +| 测试场景 | 重点观察 | +| --- | --- | +| 冷启动 | PSRAM 检测、LittleFS 挂载、初始内部堆 | +| 无网络启动 | 重试期间是否泄漏、LCD 是否仍能读资源 | +| Wi-Fi 弱网 | 队列积压、重连内存恢复 | +| HTTPS Token | `largest_dma`、`largest_internal` | +| WebSocket 重连 100 次 | 内存碎片、任务和队列是否释放 | +| 连续语音交互 | 音频队列峰值、PSRAM 趋势 | +| 上下行音频同时工作 | 内部 DMA 和 PSRAM 峰值 | +| LCD 动画和语音同时工作 | SPI/I2S DMA 竞争 | +| 远程日志上传 | TLS 与日志 body 峰值 | +| LAN HTTP 并发访问 | HTTP task 和响应缓冲 | +| 运行 24/72 小时 | 最小堆、碎片、任务泄漏 | +| 人为断电资源更新 | 资源完整性和恢复策略 | + +每轮测试都应保存: + +- 固件提交; +- `sdkconfig`; +- 应用和资源镜像大小; +- 完整串口日志; +- 各阶段堆快照; +- 任务栈高水位; +- 崩溃转储; +- 是否出现 PSRAM 回退。 + +## 14. 建议的持续观测指标 + +当前代码已经在认证、WebSocket、音频和远程日志路径中记录部分堆信息。建议统一成一个结构化内存快照接口,由 platform 层负责采集,control_plane 决定在何时上报。 + +推荐字段: + +```text +timestamp +phase +free_heap +minimum_free_heap +free_internal +largest_internal +largest_dma +free_spiram +largest_spiram +task_count +active_audio_state +websocket_state +lcd_state +``` + +避免多个模块重复实现不同格式的 heap 日志。长期应形成单一内存观测入口,并支持: + +- 启动阶段快照; +- 关键操作前后快照; +- 低内存阈值告警; +- 崩溃前最近快照保留; +- Debug 构建中的任务栈报表。 + +## 15. 当前风险汇总 + +| 风险 | 等级 | 当前证据 | 建议 | +| --- | --- | --- | --- | +| `gifpack` 空间不足 | 高 | 已用 90.25%,仅余约 1.02 MiB | 资源门禁、清理和压缩 | +| OTA 应用槽空间不足 | 高 | 已用 87.20%,仅余约 238 KiB | 代码 size 门禁、评估重新分区 | +| 独立 IRAM 无余量 | 高 | 16 KiB 已用 100% | 限制新增 `IRAM_ATTR` | +| 内部连续/DMA 内存不足 | 高 | Wi-Fi、TLS、音频、LCD 并发竞争 | 真机峰值监控和压力测试 | +| 应用和资源版本不一致 | 高 | 普通 OTA 不更新 `gifpack` | 资源 manifest 和兼容检查 | +| 单资源分区更新断电风险 | 高 | `gifpack` 无 A/B | 不直接在线覆盖;设计安全更新机制 | +| 远程日志常驻内部 BSS 较大 | 中高 | 静态队列约 33 KiB | PSRAM 化或缩小队列 | +| LittleFS 挂载依赖开机音 | 中高 | 挂载由提示音路径触发 | 独立启动阶段显式挂载 | +| 动态内存真实峰值未知 | 中高 | 尚无本次真机数据 | 建立基线和长稳测试 | +| PSRAM 实际容量未固化 | 中 | 自动检测,项目未声明容量 | 量产硬件验收和启动校验 | +| 任务栈缺少持续观测 | 中 | task monitor 默认关闭 | 压测构建启用并调优 | +| RLE 转换工具格式不一致 | 中 | TGA RLE 与 raw RLE 不兼容 | 统一转换和自动校验工具 | + +## 16. 推荐执行顺序 + +### 第一阶段:立即完成 + +1. 将应用和 `gifpack` 大小检查加入构建或 CI; +2. 在真机完成一次全流程内存快照; +3. 确认实际 PSRAM 容量; +4. 统计所有任务栈高水位; +5. 清点 `gifpack` 中是否存在未使用资源; +6. 确认 `model` 分区真实用途; +7. 将 LittleFS 挂载改为明确启动阶段。 + +### 第二阶段:容量治理 + +1. 优化或迁移远程日志静态队列; +2. 根据高水位缩减过大的任务栈; +3. 根据实测调整音频/WebSocket 队列; +4. 统一 RLE 转换工具和格式检查; +5. 增加资源 manifest; +6. 建立应用和资源版本兼容规则。 + +### 第三阶段:量产和长期升级 + +1. 决定资源是否需要 OTA; +2. 如需要,设计资源 A/B 或安全下载切换机制; +3. 评审并冻结量产分区表; +4. 建立 24/72 小时长稳测试; +5. 建立多轮 Wi-Fi/WebSocket 重连压力测试; +6. 保存每个发布版本的 Flash/RAM 基线报告。 + +## 17. 复现本分析的方法 + +### 17.1 应用镜像 + +完整构建后检查: + +```text +build/TQ_Cat_Code.bin +build/TQ_Cat_Code.map +``` + +使用当前 ESP-IDF v5.5.4 环境运行 size 工具: + +```bash +idf.py size +idf.py size-components +``` + +### 17.2 Flash 分区 + +检查: + +```text +16m.csv +build/partition_table/partition-table.bin +build/flasher_args.json +``` + +不要假定空 Offset 分区的地址永久不变,应以当前构建生成结果为准。 + +### 17.3 LittleFS 资源 + +检查临时目录文件总量: + +```text +build/esp-idf/platform/asset_image/ +``` + +使用构建环境中的 `littlefs-python` 查看: + +```bash +build/littlefs_py_venv/bin/littlefs-python list \ + build/gifpack.bin \ + --block-size 4096 \ + --name-max 64 +``` + +真实空闲块应通过 LittleFS API或设备挂载后的 `esp_littlefs_info()` 获取,不能只依据资源文件内容之和。 + +### 17.4 真机运行时内存 + +关注串口日志中的: + +```text +Heap before_ws_client_start +free_internal +largest_internal +largest_dma +free_spiram +largest_spiram +stack headroom +PSRAM allocation failed +fallback to internal +``` + +## 18. 当前结论 + +目前 16 MiB Flash 的规划已经非常紧凑:`gifpack` 占整个 Flash 的 65.63%,自身使用率达到 90.25%;两个 OTA 槽位合计占 22.66%,当前应用已占单槽 87.20%。Flash 没有未划分空间,任何扩容都必须以压缩或缩减其他分区为代价。 + +静态 RAM 方面,D/IRAM 使用率约 50.7%,表面仍有空间,但独立 IRAM 已经 100% 使用。真正的运行风险集中在内部连续内存和 DMA 内存:Wi-Fi、TLS、音频、LCD 和远程日志同时工作时,即使 PSRAM 仍有余量,也可能因为内部连续块不足而失败。 + +后续开发应把容量视为正式约束,而不是等链接或运行失败后再处理。最优先的工作是建立容量门禁、收集真机峰值内存、优化远程日志静态队列、清理资源、确认 `model` 分区用途,并为应用与资源版本建立明确兼容机制。 diff --git a/docs/knowledge vault/lcd-wall-clock-display.zh-CN.md b/docs/knowledge vault/lcd-wall-clock-display.zh-CN.md new file mode 100644 index 0000000..bc6ac1d --- /dev/null +++ b/docs/knowledge vault/lcd-wall-clock-display.zh-CN.md @@ -0,0 +1,530 @@ +# LCD 直绘时钟显示实现说明 + +## 1. 文档范围 + +本文整理提交 `96e975d feat(lcd): display synchronized wall clock` 的问题原因、解决方式、关键代码、验证结果和后续影响。 + +本次功能在不引入 LVGL 的前提下,为 320x240 LCD 增加左上角 `HH:MM` 时间显示。设备尚未完成网络校时的时候显示 `--:--`,二维码页面不显示时间。 + +## 2. 问题原因 + +### 2.1 项目没有文字渲染能力 + +项目当前直接通过 ESP-IDF `esp_lcd` 驱动 ST7789P3,LCD 渲染链路主要支持: + +- 从 LittleFS 读取 PNG; +- 将 PNG 解码为 RGB565; +- 按 8 行一组复制到 LCD DMA 行缓冲; +- 合成充电、低电量等状态图标; +- 通过 SPI 将行缓冲发送到屏幕。 + +项目没有 LVGL,也没有通用字体库,因此不能直接使用标签控件显示 `HH:MM`。如果为了五个字符引入完整 GUI 框架,会额外增加固件、RAM、任务和迁移成本。 + +### 2.2 校时启动依赖每日运势流程 + +项目原本已经具备 SNTP 校时接口,但校时启动位于每日运势任务中。每日运势任务会先读取出生资料;没有出生资料时,任务会提前等待,不会执行后面的 SNTP 启动逻辑。 + +因此原有逻辑不能保证设备只要联网就具备有效系统时间。LCD 时钟如果直接读取 `time()`,可能长期得到未校准的启动相对时间。 + +### 2.3 时间配置属于特定业务功能 + +原 SNTP 服务器配置名为 `FORTUNE_SNTP_SERVER`,只在每日运势功能开启时出现。墙上时钟属于设备通用能力,不应依赖运势功能是否启用。 + +### 2.4 内部 RAM 需要保持低占用 + +LCD 当前已经使用以下内部 DMA 行缓冲: + +```c +#define LCD_DIRECT_ROWS_PER_TRANSFER 8U +#define LCD_DIRECT_PIXELS_PER_TRANSFER \ + (LCD_DIRECT_WIDTH * LCD_DIRECT_ROWS_PER_TRANSFER) + +static uint16_t s_line_buffer[LCD_DIRECT_PIXELS_PER_TRANSFER]; +``` + +逻辑宽度为 320 像素,因此该缓冲大小为: + +```text +320 x 8 x 2 = 5120 字节 +``` + +如果再创建完整的 320x240 RGB565 framebuffer,需要额外占用 153600 字节;如果新建独立时间任务,还要增加任务栈。这两种方式都没有必要。 + +## 3. 解决方式 + +本次采用“通用联网校时 + Flash 点阵字模 + 复用 LCD 行缓冲”的方案。 + +完整数据流如下: + +```text +Wi-Fi 获得 IP + -> 启动幂等 SNTP 客户端 + -> SNTP 更新系统 wall clock + -> LCD 渲染当前 PNG 或测试图 + -> 读取本地 HH:MM + -> 将点阵字符写入现有 8 行 RGB565 缓冲 + -> 合成电池状态图标 + -> SPI 发送到 ST7789P3 +``` + +具体策略: + +1. 新增设备通用 SNTP 服务器和 POSIX 时区配置。 +2. Wi-Fi 状态任务启动时如果已经联网,立即启动校时。 +3. Wi-Fi 从断开变为连接时,再次调用幂等校时启动接口。 +4. 每日运势调度复用同一套通用时间配置。 +5. 使用 5x7、1 bit 点阵保存 `0-9`、冒号和横线。 +6. 点阵放大 4 倍,绘制白色主体和 1 像素黑色阴影。 +7. 未校时或本地时间转换失败时显示 `--:--`。 +8. 时间直接合成到现有行缓冲,不分配新画布。 +9. 二维码页面跳过时间合成,避免影响扫码。 + +## 4. 具体代码 + +### 4.1 通用时间配置 + +Kconfig 增加两个通用配置: + +```kconfig +config TQ_TIME_SNTP_SERVER + string "Wall-clock SNTP server" + default "ntp.aliyun.com" + help + SNTP server used to establish wall-clock time after Wi-Fi + obtains an IP address. + +config TQ_TIME_POSIX_TZ + string "Wall-clock POSIX time zone" + default "CST-8" + help + POSIX TZ string used for local wall-clock display and daily + scheduling. For China Standard Time use "CST-8". +``` + +默认配置: + +```ini +CONFIG_TQ_TIME_SNTP_SERVER="ntp.aliyun.com" +CONFIG_TQ_TIME_POSIX_TZ="CST-8" +``` + +代码保留编译期兜底值,避免某些旧配置环境缺少新符号时无法构建: + +```c +#ifndef CONFIG_TQ_TIME_SNTP_SERVER +#define CONFIG_TQ_TIME_SNTP_SERVER "ntp.aliyun.com" +#endif + +#ifndef CONFIG_TQ_TIME_POSIX_TZ +#define CONFIG_TQ_TIME_POSIX_TZ "CST-8" +#endif +``` + +原 `FORTUNE_SNTP_SERVER` 被移除,每日运势和 LCD 时钟统一使用 `TQ_TIME_*` 配置。 + +### 4.2 Wi-Fi 联网后独立启动校时 + +新增通用启动函数: + +```c +static void control_plane_start_wall_clock_sync(void) +{ + esp_err_t err = domain_runtime_time_sync_start(CONFIG_TQ_TIME_SNTP_SERVER, + CONFIG_TQ_TIME_POSIX_TZ); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Start wall-clock synchronization failed: %s", + esp_err_to_name(err)); + } +} +``` + +Wi-Fi 状态任务启动时,如果 STA 已经连接,则在启动 WebSocket 和用户配置服务之前启动校时: + +```c +if (!was_connected) { + /* 保持原有离线处理。 */ +} else { + control_plane_start_wall_clock_sync(); + (void)control_plane_schedule_websocket_wait_task(); + esp_err_t web_err = control_plane_ensure_user_config_server(); + /* 保持原有错误处理。 */ +} +``` + +运行过程中检测到 Wi-Fi 恢复时,再次启动校时: + +```c +if (connected && (!was_connected || disconnected_expression_active)) { + ESP_LOGI(TAG, "Wi-Fi STA connected; restoring idle expression"); + control_plane_start_wall_clock_sync(); + control_plane_request_wifi_state_expression( + DOMAIN_EXPRESSION_IDLE, + DOMAIN_EXPRESSION_TRIGGER_WIFI_RECONNECTED, + "Wi-Fi reconnected"); + (void)control_plane_schedule_websocket_wait_task(); +} +``` + +底层 `domain_runtime_time_sync_start()` 最终调用幂等的 `platform_time_sync_start()`。SNTP 已经启动时,重复调用只会重新应用时区并返回成功,不会创建多个 SNTP 客户端。 + +每日运势等待有效时间时也改为使用通用配置: + +```c +static esp_err_t control_plane_wait_for_synced_time(void) +{ + esp_err_t err = domain_runtime_time_sync_start(CONFIG_TQ_TIME_SNTP_SERVER, + CONFIG_TQ_TIME_POSIX_TZ); + if (err != ESP_OK) { + return err; + } + + /* 保持原有的联网状态、停止请求和超时检查。 */ +} +``` + +### 4.3 时钟布局参数 + +时钟从屏幕左上角 `(12, 10)` 开始绘制。每个点阵像素放大为 4x4 实际像素,字符步进为 24 像素: + +```c +#define LCD_DIRECT_CLOCK_X 12U +#define LCD_DIRECT_CLOCK_Y 10U +#define LCD_DIRECT_CLOCK_GLYPH_WIDTH 5U +#define LCD_DIRECT_CLOCK_GLYPH_HEIGHT 7U +#define LCD_DIRECT_CLOCK_GLYPH_SCALE 4U +#define LCD_DIRECT_CLOCK_GLYPH_ADVANCE 24U +#define LCD_DIRECT_CLOCK_TEXT_LENGTH 5U +#define LCD_DIRECT_CLOCK_OUTLINE_PX 1 +#define LCD_DIRECT_CLOCK_COLOR 0xFFFFU +#define LCD_DIRECT_CLOCK_OUTLINE_COLOR 0x0000U +``` + +五个字符的实际覆盖区域约为: + +```text +x: 12~128 +y: 10~38 +``` + +### 4.4 Flash 点阵字模 + +每行仅使用低 5 bit 表示亮点,数组声明为 `static const`,由链接器放入只读区: + +```c +static const uint8_t s_clock_glyph_rows[][LCD_DIRECT_CLOCK_GLYPH_HEIGHT] = { + {0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E}, /* 0 */ + {0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E}, /* 1 */ + {0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F}, /* 2 */ + {0x1E, 0x01, 0x01, 0x0E, 0x01, 0x01, 0x1E}, /* 3 */ + {0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02}, /* 4 */ + {0x1F, 0x10, 0x10, 0x1E, 0x01, 0x01, 0x1E}, /* 5 */ + {0x0E, 0x10, 0x10, 0x1E, 0x11, 0x11, 0x0E}, /* 6 */ + {0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08}, /* 7 */ + {0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E}, /* 8 */ + {0x0E, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x0E}, /* 9 */ + {0x00, 0x04, 0x04, 0x00, 0x04, 0x04, 0x00}, /* : */ + {0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00}, /* - */ +}; +``` + +整个数组为 12x7,即 84 字节。横线用于组成未校时状态 `--:--`。 + +字符到字模索引的映射: + +```c +static size_t lcd_direct_clock_glyph_index(char character) +{ + if (character >= '0' && character <= '9') { + return (size_t)(character - '0'); + } + return character == ':' ? 10U : 11U; +} +``` + +### 4.5 获取显示文本 + +```c +static void lcd_direct_clock_text(char text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U]) +{ + if (!platform_time_is_synced()) { + memcpy(text, "--:--", LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U); + return; + } + + time_t now = time(NULL); + struct tm local_tm = {0}; + if (localtime_r(&now, &local_tm) == NULL) { + memcpy(text, "--:--", LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U); + return; + } + + text[0] = (char)('0' + local_tm.tm_hour / 10); + text[1] = (char)('0' + local_tm.tm_hour % 10); + text[2] = ':'; + text[3] = (char)('0' + local_tm.tm_min / 10); + text[4] = (char)('0' + local_tm.tm_min % 10); + text[5] = '\0'; +} +``` + +这里直接生成固定长度字符串,没有使用动态内存,也没有引入通用格式化缓冲。 + +### 4.6 将字符绘制到现有行缓冲 + +绘制函数只处理与当前 `row_start~row_start+row_count` 相交的点阵行: + +```c +static void lcd_direct_draw_clock_pass( + uint16_t *pixel_buffer, + uint16_t row_start, + uint16_t row_count, + const char text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U], + int x_offset, + int y_offset, + uint16_t color) +{ + const int row_end = (int)row_start + (int)row_count; + + for (size_t character_index = 0; + character_index < LCD_DIRECT_CLOCK_TEXT_LENGTH; + ++character_index) { + const uint8_t *glyph = + s_clock_glyph_rows[ + lcd_direct_clock_glyph_index(text[character_index])]; + const int glyph_x = (int)LCD_DIRECT_CLOCK_X + + (int)(character_index * + LCD_DIRECT_CLOCK_GLYPH_ADVANCE) + + x_offset; + const int glyph_y = (int)LCD_DIRECT_CLOCK_Y + y_offset; + + for (uint16_t source_y = 0; + source_y < LCD_DIRECT_CLOCK_GLYPH_HEIGHT; + ++source_y) { + for (uint16_t scale_y = 0; + scale_y < LCD_DIRECT_CLOCK_GLYPH_SCALE; + ++scale_y) { + const int destination_y = glyph_y + + (int)(source_y * LCD_DIRECT_CLOCK_GLYPH_SCALE + scale_y); + if (destination_y < (int)row_start || destination_y >= row_end) { + continue; + } + + for (uint16_t source_x = 0; + source_x < LCD_DIRECT_CLOCK_GLYPH_WIDTH; + ++source_x) { + const uint8_t mask = + 1U << (LCD_DIRECT_CLOCK_GLYPH_WIDTH - 1U - source_x); + if ((glyph[source_y] & mask) == 0U) { + continue; + } + + for (uint16_t scale_x = 0; + scale_x < LCD_DIRECT_CLOCK_GLYPH_SCALE; + ++scale_x) { + const int destination_x = glyph_x + + (int)(source_x * LCD_DIRECT_CLOCK_GLYPH_SCALE + + scale_x); + if (destination_x < 0 || + destination_x >= (int)LCD_DIRECT_WIDTH) { + continue; + } + + const uint32_t destination_index = + (uint32_t)(destination_y - (int)row_start) * + LCD_DIRECT_WIDTH + + (uint32_t)destination_x; + pixel_buffer[destination_index] = + lcd_direct_swap_color(color); + } + } + } + } + } +} +``` + +时钟分两次绘制,先画右下偏移 1 像素的黑色阴影,再画白色主体: + +```c +static void lcd_direct_overlay_clock( + uint16_t *pixel_buffer, + uint16_t row_start, + uint16_t row_count, + const char text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U]) +{ + if (pixel_buffer == NULL || text == NULL) { + return; + } + + lcd_direct_draw_clock_pass(pixel_buffer, + row_start, + row_count, + text, + LCD_DIRECT_CLOCK_OUTLINE_PX, + LCD_DIRECT_CLOCK_OUTLINE_PX, + LCD_DIRECT_CLOCK_OUTLINE_COLOR); + lcd_direct_draw_clock_pass(pixel_buffer, + row_start, + row_count, + text, + 0, + 0, + LCD_DIRECT_CLOCK_COLOR); +} +``` + +### 4.7 合成到 PNG 和测试图 + +绘制 PNG 前生成一次当前帧的时钟文本;二维码路径直接关闭时钟: + +```c +const bool clock_visible = strstr(image->path, "/qrcode.png") == NULL; +char clock_text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U] = {0}; +if (clock_visible) { + lcd_direct_clock_text(clock_text); +} +``` + +每组 8 行完成背景复制后,先合成时钟,再合成状态图标,最后发送 LCD: + +```c +if (clock_visible) { + lcd_direct_overlay_clock(s_line_buffer, + row_start, + row_count, + clock_text); +} +lcd_direct_overlay_icons(s_line_buffer, + row_start, + row_count, + prepared_icons, + prepared_icon_count); +err = lcd_direct_draw_rows(row_start, row_count); +``` + +测试图和转轮占位画面也使用同一个时钟合成函数: + +```c +char clock_text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U] = {0}; +lcd_direct_clock_text(clock_text); + +for (uint16_t row_start = 0; + row_start < LCD_DIRECT_HEIGHT; + row_start += LCD_DIRECT_ROWS_PER_TRANSFER) { + /* 生成当前测试图的 8 行背景。 */ + lcd_direct_overlay_clock(s_line_buffer, + row_start, + row_count, + clock_text); + ESP_RETURN_ON_ERROR(lcd_direct_draw_rows(row_start, row_count), + TAG, + "write pixels failed"); +} +``` + +## 5. 解决成果 + +### 5.1 功能成果 + +| 场景 | 最终行为 | +| --- | --- | +| 启动后尚未校时 | 左上角显示 `--:--` | +| Wi-Fi 已连接 | 自动启动 SNTP,不要求存在出生资料 | +| SNTP 校时成功 | 显示本地 24 小时制 `HH:MM` | +| Wi-Fi 临时断开 | 系统时钟继续运行,保留时间显示 | +| Wi-Fi 重连 | 幂等调用校时启动接口 | +| 普通 PNG 表情 | 合成时间和原有状态图标 | +| 测试图/转轮占位图 | 同样显示时间 | +| 二维码页面 | 隐藏时间,避免遮挡二维码 | + +### 5.2 内存成果 + +- 没有新增动态内存分配。 +- 没有新增 framebuffer。 +- 没有新增 FreeRTOS 任务、定时器或任务栈。 +- 继续复用原有 5120 字节 LCD DMA 行缓冲。 +- 点阵字模为 84 字节,并经目标文件符号检查确认位于只读区。 +- 每次绘制只增加 6 字节时间字符串、`struct tm` 和少量局部变量,使用现有 LCD 渲染任务栈。 +- 新增 PSRAM 占用为 0。 + +### 5.3 构建成果 + +使用 ESP-IDF 5.5.4 执行重新配置和完整构建,结果成功: + +```text +TQ_Cat_Code.bin binary size 0x214200 bytes +Smallest app partition is 0x380000 bytes +0x16be00 bytes (41%) free +``` + +新配置成功进入生成配置: + +```c +#define CONFIG_TQ_TIME_SNTP_SERVER "ntp.aliyun.com" +#define CONFIG_TQ_TIME_POSIX_TZ "CST-8" +``` + +## 6. 后续影响与注意事项 + +### 6.1 每日运势配置发生迁移 + +原配置: + +```ini +CONFIG_FORTUNE_SNTP_SERVER="ntp.aliyun.com" +``` + +已经改为: + +```ini +CONFIG_TQ_TIME_SNTP_SERVER="ntp.aliyun.com" +CONFIG_TQ_TIME_POSIX_TZ="CST-8" +``` + +如果外部构建脚本、私有 `sdkconfig.defaults` 或生产配置仍设置 `CONFIG_FORTUNE_SNTP_SERVER`,需要迁移到新名称。每日运势请求中发送给服务器的 IANA 时区 `CONFIG_FORTUNE_TIMEZONE="Asia/Shanghai"` 没有改变。 + +### 6.2 SNTP 会更早产生一次网络请求 + +校时现在在 Wi-Fi 可用后立即启动,不再等待出生资料。这会在联网阶段增加正常的 SNTP UDP 请求,但不会增加常驻工作任务,也不会重复创建 SNTP 客户端。 + +### 6.3 POSIX 时区是进程级全局状态 + +`platform_time_sync_start()` 使用 `setenv("TZ", ...)` 和 `tzset()` 设置进程时区。LCD 时钟和每日运势的本地午夜计算会共享同一时区,这是当前统一配置的预期行为。 + +如果未来设备需要同时处理多个用户时区,不能反复修改进程级 `TZ`;届时需要引入显式时区转换方案。 + +### 6.4 没有外置 RTC 的断电限制 + +设备正常运行期间,即使 Wi-Fi 断开,系统时钟仍会继续走。但是彻底断电后,如果开机时无法联网,设备不能恢复准确绝对时间,会继续显示 `--:--`。 + +如果产品要求断电、无网启动后立即显示准确时间,需要增加带后备电池的外置 RTC,例如 DS3231。 + +### 6.5 时间刷新依赖画面重绘 + +当前基础表情和动画会持续重绘,因此分钟变化会在后续帧中反映出来。当前没有单独创建每分钟刷新定时器。 + +如果未来增加“绘制一次后长期不再刷新的静态画面”,需要在分钟变化时通知 LCD 渲染任务重新绘制当前帧,否则该静态画面上的时间不会自动变化。 + +### 6.6 每帧会读取一次本地时间 + +每个完整画面生成一次 `HH:MM` 文本,不会在每组 8 行中重复调用 `localtime_r()`。现有刷新频率下开销较低。 + +如果后续提高 LCD 帧率或增加更多文字,可缓存当前分钟,仅在分钟变化时重新转换本地时间,以进一步减少 CPU 开销。 + +### 6.7 UI 仍需实机确认 + +当前时钟使用白色像素字和右下黑色阴影,覆盖范围约为 `117x29` 像素。二维码已经排除,但其他业务图片仍会显示时间。 + +后续硬件验证应确认: + +1. 基础表情下的可读性; +2. 浅色背景上的黑色阴影是否足够; +3. 是否遮挡五行、抽签、OTA 或财位画面的重要内容; +4. 左上角位置和字号是否符合最终 UI 设计; +5. 充电图标、低电量图标与时间是否同时正常显示。 + +## 7. 结论 + +本次实现复用了已有 SNTP 和 LCD 直绘架构,用 84 字节点阵字模解决了无 LVGL 环境下的时间显示问题。方案没有新增动态内存、PSRAM、任务或 framebuffer,并将校时从每日运势业务中提升为联网后的通用设备能力。 + +当前软件构建验证已经完成;最终上线前仍需要烧录设备,对显示位置、背景对比度、分钟跳变和断网重连场景进行实机验证。 diff --git a/docs/knowledge vault/littlefs-resource-build-flash-mount.md b/docs/knowledge vault/littlefs-resource-build-flash-mount.md new file mode 100644 index 0000000..bba64f7 --- /dev/null +++ b/docs/knowledge vault/littlefs-resource-build-flash-mount.md @@ -0,0 +1,810 @@ +# LittleFS 资源构建、烧录与挂载流程 + +本文说明项目中的 LCD 图片和提示音资源如何从源码目录进入设备 Flash,以及设备运行时如何通过 LittleFS 访问这些资源。同时给出新增、替换和排查资源问题的完整操作步骤。 + +## 1. 先建立整体认识 + +项目不会把每一张图片分别写入 Flash,也不会在设备启动时从电脑复制资源。 + +实际流程是:构建系统先将需要的资源整理成一个临时目录,再将整个目录制作成 LittleFS 文件系统镜像 `gifpack.bin`,最后把这个镜像作为一个独立分区烧录到设备。设备启动后,将该分区挂载到 `/littlefs`,应用程序便可以像读取普通文件一样读取图片和音频。 + +```text +源码资源 +components/platform/assets/ + | + | CMake 选择并复制资源 + v +构建临时目录 +build/esp-idf/platform/asset_image/ + | + | 制作 LittleFS 文件系统镜像 + v +build/gifpack.bin + | + | idf.py flash + v +设备 Flash 中的 gifpack 分区 + | + | esp_vfs_littlefs_register() + v +设备文件路径 /littlefs/ +``` + +需要区分以下三个概念: + +| 名称 | 所在位置 | 作用 | +| --- | --- | --- | +| 源码资源目录 | `components/platform/assets/` | Git 管理的原始待烧录资源 | +| 构建临时目录 | `build/esp-idf/platform/asset_image/` | 生成镜像前整理出来的目录树 | +| LittleFS 镜像 | `build/gifpack.bin` | 最终烧入设备 `gifpack` 分区的文件系统镜像 | + +临时目录只存在于开发电脑上。设备真正挂载的是 Flash 中的 `gifpack.bin` 内容,不是电脑上的 `build` 目录。 + +## 2. 相关文件在哪里 + +| 文件或目录 | 作用 | +| --- | --- | +| `components/platform/assets/lcd/` | LCD 的 `.rle` 图片资源 | +| `components/platform/assets/audio_prompts/` | `.opus` 提示音资源 | +| `components/platform/CMakeLists.txt` | 选择资源、整理临时目录、生成 LittleFS 镜像 | +| `components/platform/idf_component.yml` | 声明 `joltwallet/littlefs` 组件依赖 | +| `managed_components/joltwallet__littlefs/project_include.cmake` | 定义 `littlefs_create_partition_image()` | +| `16m.csv` | 定义 `gifpack` 分区的类型、大小和位置 | +| `components/platform/src/platform_behavior_stubs.c` | 运行时挂载 `gifpack` 分区 | +| `components/platform/src/lcd_direct_assets.c` | 将 LCD 资源名映射到 `/littlefs/lcd/*.rle` | +| `components/platform/src/lcd_direct.c` | 打开、读取、解码并绘制 RLE 文件 | +| `components/domain/src/runtime/domain_runtime_adapters.c` | 将业务表达式映射为 LCD 资源播放请求 | + +## 3. CMake 如何选中资源 + +### 3.1 定义资源目录 + +`components/platform/CMakeLists.txt` 中定义: + +```cmake +set(platform_audio_prompt_src_dir "${CMAKE_CURRENT_SOURCE_DIR}/assets/audio_prompts") +set(platform_lcd_asset_src_dir "${CMAKE_CURRENT_SOURCE_DIR}/assets/lcd") +set(platform_asset_image_dir "${CMAKE_CURRENT_BINARY_DIR}/asset_image") +``` + +在当前组件中,这些变量最终大致对应: + +```text +platform_audio_prompt_src_dir + -> components/platform/assets/audio_prompts + +platform_lcd_asset_src_dir + -> components/platform/assets/lcd + +platform_asset_image_dir + -> build/esp-idf/platform/asset_image +``` + +### 3.2 LCD 使用显式白名单 + +LCD 资源由 `platform_lcd_flash_assets` 明确列出: + +```cmake +set(platform_lcd_flash_assets + buyunqian1 + jichubiaoqing0 + jichubiaoqing1 + jichubiaoqing2 + muyu0 + muyu1 + muyu2 + # ... +) +``` + +列表中不写 `.rle` 后缀。后续循环会自动拼出完整文件名: + +```cmake +"${platform_lcd_asset_src_dir}/${lcd_rle_asset}.rle" +``` + +例如白名单中的: + +```text +jichubiaoqing0 +``` + +最终对应: + +```text +components/platform/assets/lcd/jichubiaoqing0.rle +``` + +因此,把新 `.rle` 文件放进 `assets/lcd/` 并不代表它会自动进入 Flash。只有加入 `platform_lcd_flash_assets` 的 LCD 文件才会进入 `gifpack.bin`。 + +### 3.3 音频使用自动扫描 + +音频资源采用另一种策略: + +```cmake +file(GLOB_RECURSE platform_audio_prompt_files CONFIGURE_DEPENDS + "${platform_audio_prompt_src_dir}/*.opus" +) +``` + +这会递归选中 `assets/audio_prompts/` 下的全部 `.opus` 文件。因此: + +- 新增 LCD 文件时,需要手动加入 CMake 白名单; +- 新增 `.opus` 文件时,一般不需要修改资源白名单。 + +## 4. CMake 如何形成临时目录 + +构建时,CMake 会先清理旧的临时目录: + +```cmake +COMMAND ${CMAKE_COMMAND} -E rm -rf "${platform_asset_image_dir}" +COMMAND ${CMAKE_COMMAND} -E make_directory "${platform_asset_image_dir}" +``` + +这样可以避免已经从白名单删除的旧资源继续残留在镜像中。 + +随后,LCD 文件被复制到临时目录的 `lcd/` 子目录: + +```cmake +COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${platform_lcd_asset_src_dir}/${lcd_rle_asset}.rle" + "${platform_asset_image_dir}/lcd/${lcd_rle_asset}.rle" +``` + +音频文件被复制到 `audio_prompts/` 子目录。完成后,临时目录大致如下: + +```text +build/esp-idf/platform/asset_image/ +├── audio_prompts/ +│ ├── boot.opus +│ ├── network_connect_success.opus +│ └── ... +└── lcd/ + ├── jichubiaoqing0.rle + ├── jichubiaoqing1.rle + ├── muyu0.rle + └── ... +``` + +这个目录结构会原样成为 LittleFS 镜像内部的目录结构。 + +资源文件还被加入 `DEPENDS`: + +```cmake +DEPENDS + ${platform_audio_prompt_files} + ${platform_lcd_flash_asset_files} +``` + +因此,白名单内的资源发生变化时,构建系统能够重新整理目录并生成镜像。 + +## 5. `littlefs_create_partition_image()` 从哪里来 + +项目在 `components/platform/idf_component.yml` 中声明: + +```yaml +dependencies: + joltwallet/littlefs: 1.20.4 +``` + +ESP-IDF Component Manager 因此引入: + +```text +managed_components/joltwallet__littlefs/ +``` + +这个组件包含特殊文件: + +```text +managed_components/joltwallet__littlefs/project_include.cmake +``` + +ESP-IDF 构建系统会自动加载各组件的 `project_include.cmake`。该文件定义了: + +```cmake +function(littlefs_create_partition_image partition base_dir) + # ... +endfunction() +``` + +所以 `littlefs_create_partition_image()`: + +- 不是标准 CMake 内置函数; +- 也不是本项目自行定义的普通函数; +- 它是 `joltwallet/littlefs` 组件提供并由 ESP-IDF 自动导入的构建扩展。 + +## 6. 如何生成 `gifpack.bin` + +项目调用: + +```cmake +littlefs_create_partition_image( + gifpack + "${platform_asset_image_dir}" + FLASH_IN_PROJECT + DEPENDS "${platform_asset_stage_stamp}" +) +``` + +参数含义如下: + +| 参数 | 含义 | +| --- | --- | +| `gifpack` | 要匹配的分区名称 | +| `platform_asset_image_dir` | 要制作成文件系统镜像的目录 | +| `FLASH_IN_PROJECT` | 将镜像加入项目的完整 `flash` 目标 | +| `DEPENDS` | 确保资源整理完成后再生成镜像 | + +该函数会根据分区名查询分区表: + +```cmake +partition_table_get_partition_info(size "--partition-name ${partition}" "size") +partition_table_get_partition_info(offset "--partition-name ${partition}" "offset") +``` + +随后调用 `littlefs-python`,大致执行: + +```text +littlefs-python create \ + build/esp-idf/platform/asset_image \ + build/gifpack.bin \ + --fs-size= \ + --block-size=4096 +``` + +得到: + +```text +build/gifpack.bin +``` + +`gifpack.bin` 不是简单压缩包,而是一个包含目录、文件名、文件内容和文件系统元数据的完整 LittleFS 镜像。 + +## 7. 分区表如何决定烧录位置 + +项目通过 `sdkconfig.defaults` 选择自定义分区表: + +```text +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="16m.csv" +``` + +`16m.csv` 中定义: + +```csv +gifpack, data, littlefs, , 0xa80000, +``` + +字段含义: + +| 字段 | 当前值 | 含义 | +| --- | --- | --- | +| Name | `gifpack` | 分区标签,构建和挂载都依赖它 | +| Type | `data` | 数据分区 | +| SubType | `littlefs` | LittleFS 文件系统分区 | +| Offset | 空 | 由 ESP-IDF 根据前面的分区自动计算 | +| Size | `0xa80000` | 分区大小,约 10.5 MiB | + +当前生成结果中,`gifpack` 的起始地址是 `0x10000`。由于分区 Offset 没有写死,如果前面的分区布局发生变化,实际地址也可能变化;应以当前构建生成的分区表和 `build/flasher_args.json` 为准。 + +下面三处名称必须一致: + +```text +CMake: +littlefs_create_partition_image(gifpack ...) + +分区表: +gifpack, data, littlefs, ... + +运行时: +.partition_label = "gifpack" +``` + +任何一处名称不一致,都可能导致镜像生成失败、烧错位置或运行时找不到分区。 + +## 8. `FLASH_IN_PROJECT` 如何让镜像随项目烧录 + +`FLASH_IN_PROJECT` 会让 LittleFS 组件执行类似以下注册: + +```cmake +esptool_py_flash_target_image( + flash + "${partition}" + "${offset}" + "${image_file}" +) +``` + +它的含义是:把 `gifpack.bin` 加入 ESP-IDF 的完整 `flash` 目标。 + +当前 `build/flasher_args.json` 中可看到类似内容: + +```json +"0x10000": "gifpack.bin" +``` + +所以执行: + +```bash +idf.py flash +``` + +时,ESP-IDF 会分别烧录 bootloader、分区表、应用固件和 `gifpack.bin`。`gifpack.bin` 并没有编入应用程序 `.bin`,它始终是一个独立的分区镜像。 + +需要特别区分: + +| 命令 | 对资源分区的影响 | +| --- | --- | +| `idf.py build` | 生成或更新 `gifpack.bin`,不写设备 | +| `idf.py flash` | 按完整烧录清单写入 `gifpack.bin` | +| `idf.py app-flash` | 通常只更新应用分区,不更新 `gifpack` | +| 普通应用 OTA | 通常只更新 OTA 应用分区,不更新 `gifpack` | + +因此,修改资源后只执行 `app-flash` 或普通应用 OTA,设备中的资源可能仍然是旧版本。 + +## 9. 设备运行时如何挂载 + +挂载函数位于 `components/platform/src/platform_behavior_stubs.c`: + +```c +#define VOICE_PROMPT_LITTLEFS_BASE_PATH "/littlefs" +#define VOICE_PROMPT_LITTLEFS_PARTITION "gifpack" + +esp_vfs_littlefs_conf_t conf = { + .base_path = VOICE_PROMPT_LITTLEFS_BASE_PATH, + .partition_label = VOICE_PROMPT_LITTLEFS_PARTITION, + .partition = NULL, + .format_if_mount_failed = false, + .dont_mount = false, +}; + +esp_err_t err = esp_vfs_littlefs_register(&conf); +``` + +### 9.1 `partition_label = "gifpack"` + +LittleFS 驱动根据标签在运行时分区表中查找 `gifpack`,得到该分区的实际 Flash 地址和大小。 + +### 9.2 `partition = NULL` + +调用方没有直接传入 `esp_partition_t`,由 LittleFS 驱动根据 `partition_label` 自动查找分区。 + +### 9.3 `base_path = "/littlefs"` + +将 LittleFS 注册到 ESP-IDF VFS 的 `/littlefs` 路径。挂载成功后,可以使用标准文件 API: + +```c +open("/littlefs/lcd/jichubiaoqing0.rle", O_RDONLY); +fopen("/littlefs/audio_prompts/boot.opus", "rb"); +``` + +### 9.4 `format_if_mount_failed = false` + +挂载失败时不格式化分区。这对预置资源分区非常重要,因为格式化会清空已经烧录的图片和音频。 + +### 9.5 `dont_mount = false` + +注册后立即执行挂载,而不是只注册驱动。 + +### 9.6 VFS 路径如何对应到 LittleFS 内部路径 + +临时目录中的: + +```text +asset_image/lcd/jichubiaoqing0.rle +``` + +进入 LittleFS 后对应: + +```text +/lcd/jichubiaoqing0.rle +``` + +挂载到 `/littlefs` 后,应用程序看到: + +```text +/littlefs/lcd/jichubiaoqing0.rle +``` + +应用调用 `open()` 时,ESP-IDF VFS 识别 `/littlefs` 前缀,并将剩余的 `/lcd/jichubiaoqing0.rle` 交给 LittleFS 驱动读取。 + +## 10. 当前启动时机和隐式依赖 + +当前 `platform_mount_littlefs()` 不是由 `platform_init()` 直接调用,而是在 `voice_prompt_play_file()` 播放提示音之前调用: + +```c +esp_err_t mount_err = platform_mount_littlefs(); +``` + +当前启动配置启用了 `boot.opus`,启动顺序大致是: + +```text +准备运行时存储 + -> 初始化音频 + -> 播放 boot.opus + -> platform_mount_littlefs() + -> 挂载 gifpack 到 /littlefs + -> 初始化 LCD + -> LCD 渲染线程读取 /littlefs/lcd/*.rle +``` + +这意味着当前 LCD 成功读取资源,隐式依赖“开机音流程已经触发 LittleFS 挂载”。如果以后禁用开机音,或者在调用 `voice_prompt_play_file()` 之前就失败,LCD 可能在尚未挂载时访问 `/littlefs/lcd/`。 + +更稳妥的长期方案是把 `platform_mount_littlefs()` 作为独立启动阶段,在音频和 LCD 使用资源之前显式调用。修改启动流程前,应保持现有分层规则,并由 `app_composition` 编排启动顺序。 + +## 11. LCD RLE 文件格式 + +当前 `lcd_direct` 驱动读取的 `.rle` 是原始二元组序列: + +```c +typedef struct { + uint16_t color; + uint16_t count; +} lcd_direct_rle_run_t; +``` + +每个 run 占 4 字节: + +```text +2 字节 RGB565 color,little-endian +2 字节重复像素数 count,little-endian +``` + +全屏图片解压后的像素总数应与屏幕绘制区域匹配。当前横屏画布通常为: + +```text +320 x 240 = 76800 像素 +``` + +图标资源则应与 `lcd_direct_icon_t` 中配置的宽高匹配。 + +### 11.1 `run_count` 必须与文件一致 + +`components/platform/src/lcd_direct_assets.c` 不仅保存文件路径,还保存 `run_count`: + +```c +{ + .path = LCD_ASSET_BASE_PATH "jichubiaoqing0.rle", + .run_count = 5411U, +}, +``` + +对于当前原始格式: + +```text +run_count = 文件字节数 / 4 +``` + +替换资源后,即使文件名没有变化,只要编码后的 run 数发生变化,就必须同步更新 `run_count`。否则驱动可能少读、读到文件末尾失败或显示异常。 + +可以用下面的方法检查: + +```bash +python3 -c 'from pathlib import Path; p=Path("components/platform/assets/lcd/jichubiaoqing0.rle"); print(p.stat().st_size // 4)' +``` + +还应确认文件大小能被 4 整除。 + +### 11.2 不要误用 `tools/png_to_rle.py` + +当前仓库的 `tools/png_to_rle.py` 使用 Pillow 生成标准 TGA RLE 文件;而 `lcd_direct.c` 读取的是上面描述的原始 `color + count` 二元组格式。 + +两种格式不兼容。不要直接用 `tools/png_to_rle.py` 的输出替换 `components/platform/assets/lcd/*.rle`,除非先统一转换工具和驱动格式。 + +`tools/extract_lcd_assets.py` 能写出当前原始二元组格式,但其中包含旧工程的固定 Windows 路径和固定资源映射,使用前必须检查和调整,不能假设它在所有开发环境中可直接运行。 + +## 12. 替换现有 LCD 资源 + +适用场景:资源名、帧数和业务行为不变,只替换画面内容。 + +以替换 `jichubiaoqing0.rle` 为例: + +1. 使用与当前驱动兼容的原始 RLE 转换流程生成新文件。 +2. 确认解压像素总数与目标画布一致。 +3. 用新文件替换: + + ```text + components/platform/assets/lcd/jichubiaoqing0.rle + ``` + +4. 计算新文件的 `run_count`。 +5. 更新 `components/platform/src/lcd_direct_assets.c` 中对应条目的 `run_count`。 +6. 因为文件名没有变化且已经在 CMake 白名单中,一般不需要修改 `platform_lcd_flash_assets`。 +7. 执行完整构建和烧录: + + ```bash + eim run "idf.py build" v5.5.4 + eim run "idf.py flash" v5.5.4 + ``` + +8. 在串口日志中确认 LittleFS 挂载成功,并检查 LCD 是否有 `open` 或 `read` 失败日志。 + +如果当前环境没有 `eim`,按照项目 `AGENTS.md` 中规定的 ESP-IDF v5.5.4 激活流程执行相同命令。 + +## 13. 给已有动画新增一帧 + +适用场景:例如已有 `muyu0.rle`、`muyu1.rle`、`muyu2.rle`,现在增加 `muyu3.rle`。 + +### 第一步:添加文件 + +放到: + +```text +components/platform/assets/lcd/muyu3.rle +``` + +### 第二步:加入 CMake 白名单 + +在 `components/platform/CMakeLists.txt` 的 `platform_lcd_flash_assets` 中加入: + +```cmake +muyu3 +``` + +否则文件不会进入 `gifpack.bin`。 + +### 第三步:加入平台资源映射 + +在 `components/platform/src/lcd_direct_assets.c` 的对应数组中增加: + +```c +{ + .path = LCD_ASSET_BASE_PATH "muyu3.rle", + .run_count = <实际 run 数>, +}, +``` + +平台通过数组下标解释 `frame_index`,所以新条目在数组中的位置就是它的帧索引。 + +### 第四步:确认业务播放顺序 + +如果希望业务流程实际播放新帧,还需要检查 `components/domain/src/runtime/domain_runtime_adapters.c` 中的帧顺序,例如: + +```c +static const uint8_t s_muyu_frame_order[] = {0, 1, 2, 3, 2, 1}; +``` + +只增加平台资源映射但不修改播放顺序,新帧可能已经烧入设备,却永远不会被请求。 + +### 第五步:完整构建并烧录 + +```bash +eim run "idf.py build" v5.5.4 +eim run "idf.py flash" v5.5.4 +``` + +## 14. 新增一组 LCD 资源 + +适用场景:增加一个新的动画组,例如: + +```text +happy0.rle +happy1.rle +happy2.rle +``` + +需要同时完成以下工作: + +1. 将所有文件放入 `components/platform/assets/lcd/`。 +2. 将 `happy0`、`happy1`、`happy2` 加入 `platform_lcd_flash_assets`。 +3. 在 `components/platform/src/lcd_direct_assets.c` 中建立资源数组,填写每帧路径和 `run_count`。 +4. 在 `lcd_direct_asset_frame()` 中增加资源名到数组的查找映射,使 `asset_dir = "happy"` 能找到对应帧。 +5. 如果这是新的业务表达式,按照 `docs/lcd-expressions.md` 的分层要求: + - 在 `domain` 公共契约中增加语义表达式 ID; + - 在 `domain_runtime_adapters.c` 中映射到 `asset_dir = "happy"`; + - 由 `control_plane` 决定何时请求该表达式; + - 不要让 `control_plane` 直接调用 `platform_lcd_*`。 +6. 构建并完整烧录。 +7. 在硬件上验证帧序、帧间隔、中断优先级和播放结束后的返回行为。 + +这里涉及两个彼此独立的问题: + +```text +资源是否进入 Flash + -> 由 CMake 白名单决定 + +资源是否会被业务播放 + -> 由 platform 资源映射和 domain 表达式映射决定 +``` + +文件成功烧入 Flash,不代表业务代码一定能找到或播放它。 + +## 15. 新增或替换音频资源 + +### 15.1 替换已有音频 + +如果文件名和调用路径不变: + +1. 替换 `components/platform/assets/audio_prompts/` 下对应的 `.opus` 文件。 +2. 执行 `idf.py build` 重新生成 `gifpack.bin`。 +3. 执行完整 `idf.py flash` 更新资源分区。 + +一般不需要修改 CMake,因为 `.opus` 文件会被自动扫描。 + +### 15.2 新增音频 + +1. 将 `.opus` 文件加入 `components/platform/assets/audio_prompts/`。 +2. 确认播放代码引用的路径正确: + + ```text + /littlefs/audio_prompts/<文件名>.opus + ``` + +3. 如果传给 `voice_prompt_play_file()` 的是相对文件名,它会自动补上 `/littlefs/audio_prompts/`。 +4. 完整构建并烧录。 + +## 16. 推荐验证流程 + +资源修改后,建议按以下顺序验证。 + +### 16.1 检查源码资源 + +确认: + +- 文件名和大小写正确; +- LCD 文件已加入白名单; +- LCD `run_count` 与文件一致; +- 资源映射路径与文件名一致。 + +### 16.2 构建镜像 + +```bash +eim run "idf.py build" v5.5.4 +``` + +检查是否生成: + +```text +build/gifpack.bin +``` + +也可检查临时目录中是否存在目标文件: + +```text +build/esp-idf/platform/asset_image/lcd/<资源名>.rle +build/esp-idf/platform/asset_image/audio_prompts/<资源名>.opus +``` + +### 16.3 检查烧录清单 + +检查 `build/flasher_args.json` 中是否包含: + +```json +"gifpack": { + "offset": "...", + "file": "gifpack.bin" +} +``` + +不要把 `0x10000` 永久当作固定值,应以当前分区表生成结果为准。 + +### 16.4 完整烧录 + +```bash +eim run "idf.py flash" v5.5.4 +``` + +不要只执行 `app-flash`。 + +### 16.5 检查运行日志 + +挂载成功时应出现类似日志: + +```text +LittleFS mounted at /littlefs, used=... total=... +``` + +LCD 资源异常时重点关注: + +```text +open LCD asset failed +read LCD asset failed +LCD presentation draw failed +``` + +音频异常时重点关注: + +```text +Mount LittleFS failed +Open prompt failed +``` + +## 17. 常见问题排查 + +### 17.1 源码目录里有文件,但设备提示找不到 + +按顺序检查: + +1. LCD 文件是否加入 `platform_lcd_flash_assets`; +2. 是否重新执行了 `idf.py build`; +3. 是否执行了完整 `idf.py flash`; +4. `lcd_direct_assets.c` 中路径是否完全一致; +5. `/littlefs` 是否已经挂载。 + +### 17.2 替换图片后还是显示旧图 + +最常见原因是只执行了 `app-flash` 或 OTA,只更新了应用分区,没有更新 `gifpack`。 + +重新执行完整烧录: + +```bash +eim run "idf.py flash" v5.5.4 +``` + +### 17.3 能打开文件,但读取失败 + +检查: + +- `run_count` 是否与新文件一致; +- 文件大小是否能被 4 整除; +- 文件是否为当前驱动要求的原始二元组 RLE; +- 是否错误使用了 TGA RLE 文件; +- RLE 解压后的像素数量是否匹配目标画布。 + +### 17.4 构建时报分区空间不足 + +说明临时目录中的总资源超过 `gifpack` 容量。处理方式包括: + +- 删除未使用资源并从白名单移除; +- 优化资源编码; +- 在确认整个 Flash 布局允许后调整 `16m.csv`; +- 调整分区表时同时检查 OTA、模型和日志分区是否仍有足够空间。 + +不要只增大 `gifpack` 而忽略后续分区的地址和总 Flash 容量。 + +### 17.5 挂载失败 + +检查: + +1. 是否已经完整烧录 `gifpack.bin`; +2. CMake、分区表、运行时使用的分区名是否都是 `gifpack`; +3. 分区表是否和当前固件一起烧录; +4. 镜像大小是否与当前分区表一致; +5. 是否使用旧构建目录生成的镜像; +6. 必要时执行 `idf.py fullclean` 后重新构建和完整烧录。 + +由于资源分区设置了 `format_if_mount_failed = false`,挂载失败不会自动格式化,这可以避免资源被意外清空。 + +## 18. 修改资源时的最终检查清单 + +### 只替换现有 LCD 文件 + +- [ ] 替换 `components/platform/assets/lcd/.rle` +- [ ] 确认文件格式为原始 `uint16_t color + uint16_t count` +- [ ] 更新 `lcd_direct_assets.c` 中的 `run_count` +- [ ] 执行 `idf.py build` +- [ ] 执行完整 `idf.py flash` +- [ ] 检查挂载和 LCD 读取日志 + +### 新增 LCD 帧或资源组 + +- [ ] 添加 `.rle` 文件 +- [ ] 加入 `platform_lcd_flash_assets` +- [ ] 在 `lcd_direct_assets.c` 中增加路径和 `run_count` +- [ ] 在 `lcd_direct_asset_frame()` 中增加或确认名称映射 +- [ ] 在 domain 层配置播放顺序或表达式映射 +- [ ] 执行完整构建和烧录 +- [ ] 在硬件上验证实际播放 + +### 替换或新增音频 + +- [ ] 添加或替换 `.opus` 文件 +- [ ] 确认调用路径正确 +- [ ] 执行 `idf.py build` +- [ ] 执行完整 `idf.py flash` +- [ ] 检查提示音打开和播放日志 + +## 19. 一句话总结 + +资源交付链路可以概括为: + +```text +源码资源 + -> CMake 选择并整理目录 + -> LittleFS 组件生成 gifpack.bin + -> ESP-IDF 根据分区表烧入 gifpack 分区 + -> 设备把 gifpack 挂载为 /littlefs + -> LCD 和音频通过标准文件 API 读取资源 +``` + +新增资源时要同时确认“是否进入 `gifpack.bin`”和“运行时代码是否会找到并使用它”;替换资源时则要特别注意更新 LCD `run_count`,并执行完整 `idf.py flash` 更新独立的资源分区。 diff --git a/docs/knowledge vault/multimodal-voice-startup-upload-isolation.zh-CN.md b/docs/knowledge vault/multimodal-voice-startup-upload-isolation.zh-CN.md new file mode 100644 index 0000000..82e328c --- /dev/null +++ b/docs/knowledge vault/multimodal-voice-startup-upload-isolation.zh-CN.md @@ -0,0 +1,417 @@ +# OTA 后首次语音启动与后台上传隔离修复说明 + +## 1. 提交信息 + +```text +609fe97 fix(multimodal): protect voice startup from background uploads +``` + +本提交解决 OTA 加入后容易出现的以下现象: + +1. 开机后第一次说“喵掌柜”,设备识别到了唤醒词,但没有进入录音和回答流程。 +2. 第二次唤醒能够成功,但第一次正式回答等待时间明显偏长。 +3. 后续多轮对话又恢复正常。 +4. 第一次失败后,旧 task 可能延迟进入 `Listening`,影响下一次唤醒的状态判断。 + +## 2. 问题原因 + +### 2.1 第一次并不是没有识别到唤醒词 + +问题日志中的关键时间线为: + +```text +15.755s OTA 服务启动 +15.835s 本地唤醒因 OTA HTTPS window 延迟启动 +18.925s OTA 检查返回 HTTP 204 +18.945s 本地唤醒开始恢复 +19.935s 多模态鉴权开始 +21.935s WebSocket 连接成功 + +32.065s 本地识别到“喵掌柜” +32.165s Start 已发送 +32.255s task=1, listening=0 +42.135s 等待 Listening 超过 10 秒,本地唤醒超时 +``` + +因此首次失败点不是本地关键词识别,而是设备发出 `Start` 后,云端 task 没能在固件规定的 10 秒内进入 `Listening`。 + +### 2.2 OTA 改变了启动顺序 + +加入 OTA 前,主要顺序是: + +```text +调度 WebSocket + -> 启动本地唤醒 + -> 启动远程日志服务 +``` + +加入 OTA 后,初始 OTA HTTPS window 会暂时禁止语音服务: + +```text +启动 OTA + -> WebSocket 调度被暂时拒绝 + -> 本地唤醒被延迟 + -> 远程日志服务仍开始运行 + -> OTA 完成 + -> WebSocket、本地唤醒、日志上传同时恢复或继续 +``` + +问题设备启动时还有以下积压任务: + +```text +pending_files=12 +coredump_present=1 +coredump_size=41732 +``` + +这意味着 OTA 结束后,语音链路首次初始化会与历史日志和 coredump 的 HTTPS 上传竞争: + +- Wi-Fi 传输时间; +- TLS 握手; +- CPU 时间; +- internal RAM 和 DMA 连续内存; +- 网络任务调度时间。 + +第一次等待 `Listening` 期间还出现了新的 TLS 证书验证日志,证明后台 HTTPS 请求确实与首次 task 建立发生了重叠。 + +### 2.3 原来的上传避让条件不完整 + +旧逻辑只把以下状态当作实时链路忙碌: + +```c +return state.client_speech_active || + state.remote_responding || + state.request_to_speak_pending || + state.received_downstream_audio; +``` + +它没有覆盖: + +- OTA 正占用 voice services; +- WebSocket 正在启动; +- 本地唤醒已经进入前台,但还在等待网络; +- task 已创建但 `Listening` 尚未到达; +- ASR 已结束、正在等待模型回答; +- 本地回复事件尚未结束。 + +因此后台日志线程会误以为设备空闲,并在这些关键阶段发起新的 HTTPS 上传。 + +### 2.4 ASR 结束到 TTS 开始之间没有明确状态 + +用户停止讲话后: + +```text +client_speech_active=0 +remote_responding=0 +received_downstream_audio=0 +``` + +但此时本轮对话并没有结束,设备正在等待云端生成回答。旧状态结构无法表达“正在等回答”,远程日志可能在这个窗口开始上传,进一步放大首轮回答延迟。 + +### 2.5 首次超时后遗留了迟到 task + +旧的本地唤醒超时处理只执行: + +```c +control_plane_set_ptt_active(false); +``` + +这只会退出本地前台唤醒流程,不会停止已经发送到云端的 task。 + +因此出现了以下状态变化: + +```text +第一次超时时:task=1, listening=0 +第二次唤醒时:task=1, listening=1 +``` + +旧 task 在第一次已经失败后才进入 `Listening`。第二次唤醒直接复用了它,所以第二次和后续对话看起来又正常了。 + +如果旧 task 的 `Listening` 事件到达得更晚,还可能污染一个刚创建的新 task。 + +## 3. 解决方式 + +### 3.1 启动阶段延迟远程日志上传 + +增加 60 秒语音启动保护时间: + +```c +#define REMOTE_LOG_VOICE_STARTUP_GRACE_MS 60000 +``` + +启动保护并不是简单地固定等待 60 秒。如果语音 task 提前准备完成,可以提前打开上传门: + +```c +static bool voice_startup_upload_gate_open(TickType_t startup_tick) +{ +#if CONFIG_TQ_LOCAL_WAKE_ENABLE && CONFIG_ENABLE_MULTIMODAL_INTERACTION + control_plane_multimodal_runtime_state_t state = {0}; + if (control_plane_multimodal_get_runtime_state(&state) == ESP_OK && + state.connected && + state.has_active_task && + state.server_listening) { + return true; + } + + return (xTaskGetTickCount() - startup_tick) >= + pdMS_TO_TICKS(REMOTE_LOG_VOICE_STARTUP_GRACE_MS); +#else + (void)startup_tick; + return true; +#endif +} +``` + +上传循环只有在 gate 打开后才会执行网络上传: + +```c +TickType_t last_flush = xTaskGetTickCount(); +TickType_t startup_tick = last_flush; + +while (!s_stop_requested) { + TickType_t now = xTaskGetTickCount(); + + if (voice_startup_upload_gate_open(startup_tick) && + (backoff_ms == 0U || now >= next_upload_tick)) { + drain_memory_queue_to_flash(); + (void)upload_coredump_report(); + bool ok = upload_pending_files(); + /* 后续退避处理保持不变 */ + } +} +``` + +保护期间远程日志仍会写入内存队列和 LogFS,只延迟上传到服务端,不会丢失日志采集能力。 + +### 3.2 扩大实时语音忙碌判断 + +新的避让判断为: + +```c +static bool realtime_path_busy(void) +{ +#if CONFIG_TQ_LOCAL_WAKE_ENABLE + if (!control_plane_ota_voice_services_allowed() || + !control_plane_voice_turn_is_idle()) { + return true; + } +#endif + +#if CONFIG_ENABLE_MULTIMODAL_INTERACTION + control_plane_multimodal_runtime_state_t state = {0}; + if (control_plane_multimodal_get_runtime_state(&state) != ESP_OK) { + return false; + } + + return state.starting || + (state.connected && + state.has_active_task && + !state.server_listening) || + state.client_speech_active || + state.response_pending || + state.remote_responding || + state.request_to_speak_pending || + state.local_responding_started || + state.received_downstream_audio; +#else + return false; +#endif +} +``` + +现在后台上传会避让完整的语音关键路径,而不只是避让“正在录音”和“正在播放”。 + +### 3.3 增加等待回答状态 + +运行状态增加: + +```c +bool response_pending; +``` + +会话内部保存对应状态: + +```c +static bool s_response_pending; +``` + +成功发送 `StopSpeech` 或收到 `SpeechEnded` 后设置为 `true`: + +```c +if (lock_state()) { + s_client_speech_active = false; + s_ptt_release_required = false; + if (err == ESP_OK && has_active_task) { + s_response_pending = true; + } + s_request_to_speak_pending = false; + unlock_state(); +} +``` + +```c +if (strcmp(output_event->valuestring, "SpeechEnded") == 0) { + control_plane_multimodal_stop_uplink(); + if (lock_state()) { + s_ptt_release_required = s_client_speech_active; + s_response_pending = true; + unlock_state(); + } + ESP_LOGI(TAG, "ASR ended"); +} +``` + +开始收到回复、开始新一轮语音、取消语音、Stop 或会话重置时清零: + +```c +if (strcmp(output_event->valuestring, "RespondingStarted") == 0) { + if (lock_state()) { + s_response_pending = false; + s_remote_responding = true; + unlock_state(); + } +} +``` + +这样,从用户说完话到 TTS 开始之间也属于实时链路忙碌期,后台 HTTPS 不会插入。 + +### 3.4 首次 Listening 超时后清理未完成 task + +超时后先判断它是否确实是一个“未完成启动”的 task: + +```c +bool incomplete_task_start = runtime.connected && + runtime.has_active_task && + !runtime.server_listening && + !runtime.remote_responding && + !runtime.request_to_speak_pending && + !runtime.client_speech_active; +``` + +只有满足这些条件才发送 Stop: + +```c +if (incomplete_task_start) { + esp_err_t stop_err = control_plane_multimodal_send_stop( + "LocalWakeListeningTimeout"); + ESP_LOGW(TAG, + "Local wake timed-out task cleanup: result=%s", + esp_err_to_name(stop_err)); +} + +control_plane_set_ptt_active(false); +``` + +限制条件避免误停以下正常场景: + +- 云端正在回答; +- 正在进行打断请求; +- 用户语音仍在发送; +- task 已经进入 `Listening`,只是其他前台条件暂时未满足。 + +`control_plane_multimodal_send_stop()` 会发送 `finish-task/Stop`,随后清理本地 task、dialog 和响应状态。WebSocket 本身仍可复用,下一次唤醒会创建一个干净的新 task。 + +### 3.5 过滤迟到的 Listening 事件 + +解析服务端消息时读取其 task ID: + +```c +cJSON *message_task_id = header ? + cJSON_GetObjectItemCaseSensitive(header, "task_id") : NULL; +``` + +收到 `Listening` 时必须同时满足: + +- 本地当前存在 active task; +- 如果消息携带 task ID,它必须与当前 task ID 相同。 + +```c +bool accepted = false; +if (lock_state()) { + accepted = s_task_id[0] != '\0' && + (!cJSON_IsString(message_task_id) || + message_task_id->valuestring == NULL || + strcmp(s_task_id, message_task_id->valuestring) == 0); + + if (accepted) { + s_server_listening = true; + s_remote_responding = false; + s_remote_responding_ended = false; + s_request_to_speak_pending = false; + } + unlock_state(); +} + +if (!accepted) { + ESP_LOGW(TAG, + "Ignore stale Listening event for task_id=%s", + cJSON_IsString(message_task_id) && + message_task_id->valuestring != NULL ? + message_task_id->valuestring : "-"); + cJSON_Delete(root); + return; +} +``` + +这可以防止旧 task 的迟到事件把新会话错误标记为已准备好。 + +### 3.6 增加关键时序日志 + +新增日志: + +```c +ESP_LOGI(TAG, "Cloud task started"); +ESP_LOGI(TAG, "Cloud task started (output event)"); +ESP_LOGI(TAG, "Cloud dialog entered Listening"); +``` + +配合已有的 `Start sent` 和 `Local wake network ready`,可以直接计算: + +```text +Start 发送耗时 +task-started 到达时间 +Listening 到达时间 +本地唤醒等待总时间 +``` + +## 4. 解决成果 + +1. 开机 OTA 检查期间,历史日志和 coredump 不会抢占首次语音链路需要的 HTTPS、CPU 和内存资源。 +2. WebSocket/task 启动、等待 `Listening`、录音、等待回答和播放回复期间,后台上传都会主动避让。 +3. ASR 结束到 TTS 开始之间有明确状态,不再被误判为空闲窗口。 +4. 首次 task 超时后会被清理,不会由第二次唤醒继续复用一个来源不明的迟到 task。 +5. 旧 task 的迟到 `Listening` 不会污染新 task 状态。 +6. 串口日志能够明确展示 task-started 和 `Listening` 的到达时刻。 +7. 使用 ESP-IDF 5.5.4 完整编译和增量编译均通过。 + +## 5. 后续影响 + +### 5.1 远程日志上传会延迟 + +开机后的日志和 coredump 网络上传最多延迟 60 秒。如果语音 task 提前进入 `Listening`,并且当前没有正在进行的语音交互,可以提前上传。 + +这只影响“上传时间”,不影响本地日志采集和写入 LogFS。对于启动崩溃反复重启的设备,coredump 可能无法在 60 秒内上报,需要结合设备稳定性和问题诊断优先级评估是否调整该时间。 + +### 5.2 连续对话会继续推迟后台上传 + +如果用户持续对话,远程日志会持续避让。待语音链路真正空闲后,积压文件会按照原有退避策略继续上传。 + +这是有意的优先级选择:实时语音体验优先于后台诊断数据的即时送达。 + +### 5.3 超时后下一次唤醒会创建新 task + +首次 `Listening` 超时后,旧 task 被 Stop 并清理。下一次唤醒可能比直接复用迟到 task 多一次 `Start` 请求,但状态是确定且干净的,不会出现“第二次碰巧成功”的隐式恢复。 + +### 5.4 状态字段影响很小 + +新增的 `response_pending` 是一个布尔状态,对内存占用可以忽略。它由现有会话锁保护,没有新增 worker、队列或长期资源。 + +### 5.5 仍需实机验证的项目 + +1. 冷启动后立即说“喵掌柜”,确认第一次即可进入 `Listening` 和录音。 +2. 检查首次唤醒期间不再出现远程日志上传产生的 TLS 连接。 +3. 确认日志顺序包含 `Start sent`、`Cloud task started`、`Cloud dialog entered Listening` 和 `Local wake network ready`。 +4. 模拟服务端 task 启动超过 10 秒,确认出现 `Local wake timed-out task cleanup`,下一次唤醒创建新 task。 +5. 在 ASR 结束到 TTS 开始期间观察远程日志,确认没有启动后台 HTTPS 上传。 +6. 开机 60 秒后保持语音空闲,确认历史日志和 coredump 能继续上传,而不是永久被阻塞。 + diff --git a/docs/knowledge vault/ota-and-multimodal-stability-fixes.zh-CN.md b/docs/knowledge vault/ota-and-multimodal-stability-fixes.zh-CN.md new file mode 100644 index 0000000..811eae3 --- /dev/null +++ b/docs/knowledge vault/ota-and-multimodal-stability-fixes.zh-CN.md @@ -0,0 +1,849 @@ +# OTA 功能与多媒体稳定性修复说明 + +## 1. 文档目的 + +本文整理以下两个提交的背景、问题原因、解决方式、关键代码、验证成果和后续影响: + +```text +0cfb102 fix(multimodal): harden playback and client lifecycle handling +5a18036 feat(ota): add authenticated firmware update workflow +``` + +两个提交需要分开理解: + +- 多媒体稳定性修复处理的是原本就存在的通用并发和时序问题。即使不增加 OTA,也可能在网络重连、快速打断对话或音频初始化较慢时出现。 +- OTA 提交实现了完整的固件检查、鉴权、下载、校验、切换和重启流程,同时增加了 OTA 与语音网络任务之间的资源协调。 +- OTA 改变了设备启动顺序,并增加了一次 HTTPS 请求,因此提高了原有多媒体问题的触发概率,但不是这些问题的唯一根因。 + +## 2. 总体结论 + +本次修改解决了以下核心问题: + +1. “服务器连接成功”提示音播放失败后不再被永久跳过。 +2. WebSocket、多媒体会话和音频任务的启动、停止不再并发交叉执行。 +3. 上一轮对话遗留的音频包和播放事件不会再错误影响下一轮对话。 +4. 设备启动后可自动检查固件版本,正常周期为每 6 小时一次。 +5. OTA 上报使用具有 `ota:report` 权限的设备 JWT;遇到 `401/403` 时刷新 JWT 并重试一次。 +6. 可选支持 HMAC 内部签名,但默认不启用,不影响当前只使用设备 JWT 的服务端。 +7. 固件下载后会校验 HTTPS 状态、文件大小、SHA-256 和 ESP-IDF 镜像格式,全部通过后才切换启动分区。 +8. OTA 请求会暂时释放语音相关网络和内存资源,结束后恢复本地唤醒与 WebSocket。 + +--- + +## 3. 多媒体稳定性修复 + +### 3.1 “服务器连接成功”提示音不播放 + +#### 问题原因 + +原逻辑在收到 WebSocket 连接成功事件时,先把提示音状态设置成“已经播放”,然后立即调用音频播放接口: + +```c +if (!s_server_success_prompt_played) { + s_server_success_prompt_played = true; + should_play_prompt = true; +} + +if (should_play_prompt) { + (void)domain_runtime_behavior_play_prompt_file( + "/littlefs/audio_prompts/server_connect_success.opus"); +} +``` + +这段逻辑有两个问题: + +1. WebSocket 的“已连接”只代表网络连接完成,不代表音频播放链路已经准备好。 +2. 播放接口的返回值被忽略。即使播放失败,状态也已经变成“播放过”,之后不会再尝试。 + +因此,只要连接成功时音频通道正在初始化、停止、切换或者被其他播放占用,就可能出现提示音完全不播放。没有 OTA 时也可能发生;OTA 增加启动阶段的 HTTPS 和语音服务恢复流程后,更容易撞上这个时间窗口。 + +#### 解决方式 + +连接成功事件现在只负责登记“待播放”,真正的播放由多媒体监控任务在以下条件满足后执行: + +- WebSocket 仍然处于连接状态; +- 当前没有正在进行的云端交互任务; +- 当前没有另一线程正在播放该提示音; +- 已达到下一次允许重试的时间。 + +关键代码如下: + +```c +#define CONTROL_PLANE_SERVER_SUCCESS_PROMPT_RETRY_MS 1000U +#define CONTROL_PLANE_SERVER_SUCCESS_PROMPT_MAX_ATTEMPTS 3U + +static void control_plane_schedule_server_success_prompt(void) +{ + uint32_t now_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); + + portENTER_CRITICAL(&s_multimodal_runtime_lock); + if (!s_server_success_prompt_played && + s_server_success_prompt_attempts < + CONTROL_PLANE_SERVER_SUCCESS_PROMPT_MAX_ATTEMPTS) { + s_server_success_prompt_pending = true; + s_server_success_prompt_next_attempt_ms = now_ms; + } + portEXIT_CRITICAL(&s_multimodal_runtime_lock); +} +``` + +播放结束后才根据真实返回值更新状态: + +```c +esp_err_t err = domain_runtime_behavior_play_prompt_file( + CONTROL_PLANE_SERVER_SUCCESS_PROMPT_FILE); + +portENTER_CRITICAL(&s_multimodal_runtime_lock); +s_server_success_prompt_in_progress = false; + +if (err == ESP_OK) { + s_server_success_prompt_played = true; + s_server_success_prompt_pending = false; +} else if (s_server_success_prompt_attempts >= + CONTROL_PLANE_SERVER_SUCCESS_PROMPT_MAX_ATTEMPTS) { + s_server_success_prompt_pending = false; +} else { + s_server_success_prompt_next_attempt_ms = + now_ms + CONTROL_PLANE_SERVER_SUCCESS_PROMPT_RETRY_MS; +} +portEXIT_CRITICAL(&s_multimodal_runtime_lock); +``` + +现在只有播放接口返回 `ESP_OK` 时,提示音才会被标记为播放成功。失败时最多重试 3 次,每次间隔 1 秒。 + +### 3.2 多媒体启动和停止相互冲突 + +#### 问题原因 + +设备中有多个任务可能操作多媒体客户端: + +- Wi-Fi 状态机在网络恢复后启动 WebSocket; +- 网络断开或空闲超时时停止 WebSocket; +- OTA 检查前释放语音网络资源; +- OTA 检查后恢复 WebSocket 和本地唤醒; +- 下行音频断流保护可能触发恢复流程。 + +原来的 `starting`、`stopping` 标志只能描述状态,不能保证完整的启动过程和停止过程互斥。可能出现任务 A 正在释放传输和音频资源,任务 B 已经开始重新注册回调或建立连接的情况。 + +#### 解决方式 + +在客户端层和会话层分别增加生命周期互斥锁,并设置 30 秒的有限等待时间: + +```c +#define CONTROL_PLANE_CLIENT_LIFECYCLE_WAIT_MS 30000U + +static bool lock_client_lifecycle(void) +{ + TickType_t timeout_ticks = + pdMS_TO_TICKS(CONTROL_PLANE_CLIENT_LIFECYCLE_WAIT_MS); + + if (xSemaphoreTake(s_client_lifecycle_mutex, timeout_ticks) != pdTRUE) { + ESP_LOGW(TAG, "Timeout acquiring multimodal client lifecycle lock"); + return false; + } + return true; +} +``` + +启动和停止都必须先拿到同一把锁: + +```c +esp_err_t control_plane_start_multimodal_client(void) +{ + if (!lock_client_lifecycle()) { + return ESP_ERR_TIMEOUT; + } + + esp_err_t err = control_plane_start_multimodal_client_locked(); + unlock_client_lifecycle(); + return err; +} + +esp_err_t control_plane_stop_multimodal_client(void) +{ + if (!lock_client_lifecycle()) { + return ESP_ERR_TIMEOUT; + } + + stop_watchdog_task(); + esp_err_t err = control_plane_multimodal_stop(); + unlock_client_lifecycle(); + return err; +} +``` + +这样可以保证一个完整的停止流程结束后,另一个任务才能启动新的会话。停止接口也从无返回值改为返回 `esp_err_t`,调用方可以知道停机是否真正完成。 + +### 3.3 上一轮音频事件污染下一轮对话 + +#### 问题原因 + +音频数据、解码任务和播放完成回调运行在不同任务中。当用户快速打断上一轮回复并立即开始下一轮时,可能出现以下顺序: + +```text +第 1 轮开始播放 +用户打断第 1 轮 +第 2 轮开始播放 +第 1 轮的“播放结束”事件晚到 +第 2 轮被错误地标记为结束 +``` + +旧实现只有“当前是否播放中”的布尔状态,不能判断某个音频包或事件属于哪一轮回复。 + +#### 解决方式 + +每次开始下行回复时生成一个递增的 `playback_generation`,可以把它理解为“播放批次号”: + +```c +static uint32_t advance_downstream_playback_generation_locked(void) +{ + s_audio.downstream_playback_generation++; + if (s_audio.downstream_playback_generation == 0U) { + s_audio.downstream_playback_generation = 1U; + } + + s_audio.downstream_decoder_reset_pending = true; + return s_audio.downstream_playback_generation; +} +``` + +开始新回复时把本轮批次号返回给会话层: + +```c +esp_err_t multimodal_audio_begin_downstream_response( + uint32_t *out_playback_generation) +{ + portENTER_CRITICAL(&s_audio_runtime_lock); + uint32_t playback_generation = + advance_downstream_playback_generation_locked(); + s_audio.downstream_response_active = true; + portEXIT_CRITICAL(&s_audio_runtime_lock); + + *out_playback_generation = playback_generation; + return ESP_OK; +} +``` + +每个音频包在解码前检查批次号: + +```c +snapshot_downstream_playback_state( + &playback_generation, + &decoder_reset_pending); + +if (playback_generation != expected_generation) { + return ESP_ERR_INVALID_STATE; +} +``` + +播放事件到达控制层时再次检查: + +```c +generation_matches = s_current_playback_generation != 0U && + s_current_playback_generation == + event->playback_generation; + +if (!generation_matches) { + ESP_LOGD(TAG, + "Ignore stale or local playback event type=%d generation=%u", + (int)event->type, + (unsigned)event->playback_generation); + return; +} +``` + +因此,旧回复的音频包、停止事件、完成事件或断流事件即使晚到,也只能影响旧批次,不能结束当前新回复。 + +### 3.4 启动失败后的残留回调 + +#### 问题原因 + +多媒体客户端启动包含注册回调、启动监控任务、初始化音频、获取鉴权和建立传输等多个步骤。如果中途失败,已经注册的回调可能继续指向一次未完成的会话。 + +#### 解决方式 + +启动失败时停止监控任务并注销已经注册的回调: + +```c +esp_err_t err = control_plane_multimodal_start(); +if (err != ESP_OK) { + stop_watchdog_task(); + (void)control_plane_multimodal_register_downstream_audio_callback( + NULL, + NULL); + (void)control_plane_multimodal_register_event_callback(NULL, NULL); + return err; +} +``` + +会话层在状态锁失败时也会停止已启动的传输、注销音频回调,并停止本地响应事件工作任务,避免“启动一半”的资源留到下一次重试。 + +### 3.5 多媒体修复成果 + +设备实测日志确认: + +- WebSocket 成功连接; +- “服务器连接成功”提示音在第 1 次尝试即播放成功; +- 多轮唤醒、ASR、TTS 下发、音频播放和状态上报均正常; +- 快速开始下一轮对话时,没有观察到旧事件错误结束新回复; +- 没有出现栈溢出、断言、崩溃或重启。 + +日志中仍出现两次短暂下行音频断流,约为 410 ms 和 230 ms,但播放器分别在约 290 ms 和 110 ms 后自动恢复,没有丢失整段回复。这属于网络数据到达不均匀,现有缓冲和恢复机制已经正确处理。 + +### 3.6 多媒体修复的后续影响 + +正面影响: + +- Wi-Fi 重连、OTA、空闲断开和异常恢复时的启停行为更稳定; +- 快速打断和连续对话的状态隔离更可靠; +- 播放失败和停机超时有明确日志,不再静默失败。 + +需要注意: + +- 生命周期锁的最长等待时间为 30 秒。如果底层网络或音频停止异常,调用方会收到 `ESP_ERR_TIMEOUT`,而不是无限等待。 +- 提示音最多重试 3 次。如果连续 3 次都失败,本轮启动中不会无限重试,以免持续占用音频通道。 +- 播放事件结构新增了 `playback_generation`。以后新增任何播放回调或本地音频来源时,都应明确它属于哪一个播放批次。 + +--- + +## 4. OTA 功能实现 + +### 4.1 OTA 完整流程 + +设备端流程如下: + +```text +设备启动 + -> 确认当前待验证固件 + -> 等待 Wi-Fi + -> 暂停本地唤醒和 WebSocket + -> 检查内部内存和 DMA 连续内存 + -> 获取具有 ota:report 权限的新 JWT + -> POST 上报设备 ID、序列号和当前版本 + -> HTTP 204:没有更新,恢复语音服务,6 小时后再检查 + -> HTTP 200:解析新固件信息 + -> 强制更新:等待当前对话结束后开始下载 + -> 非强制且已确认:等待当前对话结束后开始下载 + -> 非强制且未确认:恢复语音服务,6 小时后再次查询 + -> GET 下载固件 + -> 校验大小、SHA-256 和 ESP-IDF 镜像格式 + -> 设置新启动分区 + -> 重启 + -> 新固件启动成功后确认镜像,取消回滚 +``` + +OTA 采用三层职责划分: + +- 控制层:决定何时检查、何时暂停和恢复语音、是否允许更新、失败后多久重试。 +- 领域层:构造上报 JSON、添加 JWT/HMAC 请求头、解析服务端响应、转换 SHA-256。 +- 平台层:执行 HTTPS GET、写入 OTA 分区、计算摘要、验证镜像并切换启动分区。 + +### 4.2 固件版本存储 + +仓库根目录的 `version.txt` 当前内容为: + +```text +1.0.0 +``` + +构建时 ESP-IDF 会将版本写入应用描述信息。设备运行时通过应用描述读取版本,而不是从普通配置文件或 NVS 中读取: + +```c +const esp_app_desc_t *app_desc = esp_app_get_description(); +strlcpy(out, app_desc->version, out_len); +``` + +因此,发布新固件前必须更新 `version.txt`,否则服务端会一直看到旧版本号。 + +### 4.3 OTA 检查时机 + +默认配置如下: + +```c +CONFIG_TQ_OTA_ENABLE=y +CONFIG_TQ_OTA_POLL_INTERVAL_MS=21600000 +CONFIG_TQ_OTA_CONFIRMATION_POLL_MS=21600000 +CONFIG_TQ_OTA_HTTP_TIMEOUT_MS=15000 +CONFIG_TQ_OTA_INSTALL_TIMEOUT_MS=600000 +CONFIG_TQ_OTA_TASK_STACK_BYTES=16384 +CONFIG_TQ_OTA_MIN_FREE_INTERNAL_BYTES=32768 +CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES=4096 +``` + +含义为: + +- 启动联网后立即执行首次检查; +- 没有更新时,每 6 小时检查一次; +- 有非强制更新但小程序尚未确认时,每 6 小时重新查询; +- 版本上报请求最多等待 15 秒; +- 固件下载和安装总时限为 10 分钟; +- OTA 独立任务栈为 16 KB; +- HTTPS 开始前至少保留 32 KB 内部内存和 4 KB 最大 DMA 连续块。 + +### 4.4 设备与服务端的数据交换 + +#### 4.4.1 请求体 + +设备向版本上报接口发送 HTTP POST,请求体如下: + +```json +{ + "deviceId": "设备唯一 ID", + "version": "1.0.0", + "serialNumber": "设备序列号" +} +``` + +其中: + +- `deviceId` 用于确认是哪台设备; +- `version` 是当前正在运行的固件版本; +- `serialNumber` 可选,用于服务端进一步关联设备。 + +构造请求体的代码如下: + +```c +bool ok = cJSON_AddStringToObject( + root, + "deviceId", + request->device_id) != NULL && + cJSON_AddStringToObject( + root, + "version", + request->current_version) != NULL; + +if (ok && request->serial_number != NULL && + request->serial_number[0] != '\0') { + ok = cJSON_AddStringToObject( + root, + "serialNumber", + request->serial_number) != NULL; +} +``` + +#### 4.4.2 JWT 和 scope + +请求始终携带设备 JWT: + +```http +Authorization: Bearer +``` + +设备在 OTA 上报前要求 JWT 至少具有 `ota:report` 权限: + +```c +control_plane_device_auth_result_t auth_result = + control_plane_multimodal_device_auth_ensure( + CONTROL_PLANE_DEVICE_AUTH_SCOPE_OTA_REPORT, + force_jwt_refresh); +``` + +`scope` 可以理解为 JWT 中的“允许操作清单”。当前设备认识以下四类权限: + +```c +#define CONTROL_PLANE_DEVICE_AUTH_SCOPE_VOICE_SESSION (1u << 0) +#define CONTROL_PLANE_DEVICE_AUTH_SCOPE_OTA_REPORT (1u << 1) +#define CONTROL_PLANE_DEVICE_AUTH_SCOPE_OTA_READ (1u << 2) +#define CONTROL_PLANE_DEVICE_AUTH_SCOPE_OTA_CONFIRM (1u << 3) +``` + +它们分别表示: + +- `voice:session`:允许建立语音会话; +- `ota:report`:允许上报当前固件版本; +- `ota:read`:允许读取 OTA 固件资源; +- `ota:confirm`:允许确认升级。 + +日志中的 `scope=0x0000000f` 表示低 4 位全部为 1,即上述四种权限全部具备。OTA 版本上报实际只强制要求 `ota:report`。 + +首次启动检查会强制刷新 JWT,避免缓存 JWT 因签发时间、过期时间或权限集合不满足而被服务端拒绝: + +```c +for (int attempt = 0; attempt < 2; ++attempt) { + bool force_jwt_refresh = force_initial_jwt_refresh || attempt > 0; + + err = control_plane_ota_prepare_report( + &ctx->request, + ctx->device_id, + sizeof(ctx->device_id), + ctx->serial_number, + sizeof(ctx->serial_number), + ctx->version, + sizeof(ctx->version), + ctx->jwt, + sizeof(ctx->jwt), + force_jwt_refresh); + + err = domain_runtime_ota_report(&ctx->request, response); + + bool unauthorized = err == ESP_OK && + (response->http_status == 401 || + response->http_status == 403); + if (!unauthorized) { + break; + } + + control_plane_multimodal_device_auth_invalidate( + response->http_status); +} +``` + +如果服务端仍返回 `401/403`,设备会让当前 JWT 失效、重新获取,并且只重试一次,避免错误配置造成无限鉴权请求。 + +#### 4.4.3 可选 HMAC + +HMAC 不是 JWT,也不是固件 SHA-256 校验。 + +- JWT 证明“这台设备是谁,以及它有哪些权限”。 +- HMAC 证明“请求内容由持有共享密钥的一方生成,并且内容没有被修改”。 +- 固件 SHA-256 证明“下载到的固件文件与服务端声明的文件完全一致”。 + +默认配置中 HMAC 密钥为空,因此当前请求只发送 JWT: + +```c +CONFIG_TQ_OTA_INTERNAL_AUTH_SECRET="" +``` + +只有部署环境明确要求内部签名,并配置非空共享密钥时,设备才增加下面三个请求头: + +```http +X-Internal-Timestamp: +X-Internal-Nonce: <随机数> +X-Internal-Signature: +``` + +签名原文由请求方法、路径、查询参数、请求体摘要、时间戳和随机数组成: + +```c +snprintf(signature_payload, + (size_t)payload_len + 1U, + "POST\n%s\n%s\n%s\n%s\n%s", + path, + query, + body_hash, + timestamp, + nonce); + +mbedtls_md_hmac(md_info, + (const uint8_t *)secret, + strlen(secret), + (const uint8_t *)signature_payload, + (size_t)payload_len, + digest); +``` + +HMAC 使用时间戳防止旧请求被长期重放,使用随机数防止同一秒内的重复请求被重放。因此启用 HMAC 时必须先保证设备时间已经同步。当前代码会拒绝早于合理时间范围的时间戳: + +```c +time_t now = time(NULL); +if ((int64_t)now < 1700000000LL) { + return ESP_ERR_INVALID_STATE; +} +``` + +当前服务端如果没有要求内部 HMAC,必须保持密钥为空,不应自行填写一个与服务端不一致的值。 + +#### 4.4.4 服务端响应 + +没有更新时,服务端返回: + +```http +HTTP/1.1 204 No Content +``` + +设备收到 204 后恢复语音服务,并在 6 小时后再次检查。 + +有更新时,服务端返回 HTTP 200,数据结构类似: + +```json +{ + "code": 200, + "data": { + "notUptodate": 1, + "version": "1.0.1", + "url": "https://example.com/firmware.bin", + "sha256": "64 位十六进制 SHA-256", + "sizeBytes": 2140400, + "force": 0, + "update": 1 + } +} +``` + +字段含义: + +- `notUptodate=1`:当前固件不是最新版本; +- `version`:目标固件版本; +- `url`:固件 HTTPS 下载地址; +- `sha256`:固件文件的 SHA-256; +- `sizeBytes`:固件精确字节数; +- `force=1`:强制更新,不等待小程序确认; +- `update=1`:非强制更新已经由小程序或服务端确认,可以开始更新。 + +设备严格检查字段类型、长度和数值: + +```c +if (err != ESP_OK || + !cJSON_IsNumber(size_bytes) || + size_bytes->valuedouble <= 0 || + size_bytes->valuedouble > (double)UINT32_MAX || + strlen(firmware->sha256) != 64U) { + return ESP_ERR_INVALID_RESPONSE; +} + +firmware->forced = + cJSON_IsNumber(forced) && forced->valueint == 1; +firmware->confirmed = + cJSON_IsNumber(confirmed) && confirmed->valueint == 1; +``` + +### 4.5 OTA 与语音任务的资源协调 + +#### 问题原因 + +ESP32-S3 的内部 RAM 和 DMA 连续内存有限。WebSocket、TLS、音频录制、Opus 解码和本地唤醒同时运行时,再启动 OTA HTTPS 可能造成: + +- TLS 建连内存不足; +- 最大连续 DMA 块不足; +- WebSocket 和 OTA 同时竞争网络资源; +- OTA 完成后语音任务没有恢复; +- 多媒体停止未完成,OTA 已经开始申请大块内存。 + +#### 解决方式 + +OTA 在每次 HTTPS 操作前申请一个“语音资源窗口”: + +```c +s_ota_voice_services_reserved = true; + +esp_err_t wake_stop_err = control_plane_local_wake_stop(1000); +if (wake_stop_err != ESP_OK) { + return wake_stop_err; +} + +esp_err_t stop_err = control_plane_stop_multimodal_client(); +if (stop_err != ESP_OK) { + return stop_err; +} +``` + +语音服务完全停止后检查内存: + +```c +domain_ota_memory_headroom_t headroom = {0}; +esp_err_t err = domain_runtime_ota_get_memory_headroom(&headroom); + +if (headroom.free_internal < + CONFIG_TQ_OTA_MIN_FREE_INTERNAL_BYTES || + headroom.largest_dma < + CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES) { + return ESP_ERR_NO_MEM; +} +``` + +OTA 检查结束后释放窗口,并根据之前的状态恢复服务: + +```c +control_plane_ota_set_voice_services_reserved(false); + +if (window->restore_local_wake && + control_plane_voice_turn_is_idle() && + control_plane_ota_start_default_local_wake()) { + ESP_LOGI(TAG, + "Local wake restore requested after OTA HTTPS window"); +} + +if (window->restore_multimodal || + !control_plane_voice_turn_is_idle()) { + (void)control_plane_schedule_websocket_wait_task(); +} +``` + +后续周期检查不会强行打断正在进行的对话。如果当前语音交互未结束,OTA 会进入等待资源状态,等设备空闲后再检查或升级。 + +### 4.6 固件下载与完整性校验 + +下载只接受 HTTPS 地址: + +```c +if (url == NULL || + strncmp(url, "https://", 8) != 0 || + expected_sha256 == NULL || + expected_size == 0U || + timeout_ms <= 0) { + return ESP_ERR_INVALID_ARG; +} +``` + +设备先选择当前未运行的 OTA 分区,并确认固件能放入该分区: + +```c +const esp_partition_t *partition = + esp_ota_get_next_update_partition(NULL); + +if (partition == NULL || expected_size > partition->size) { + return ESP_ERR_INVALID_SIZE; +} +``` + +服务器必须返回 HTTP 200。如果存在 `Content-Length`,必须和 `sizeBytes` 完全一致: + +```c +int status_code = esp_http_client_get_status_code(client); +if (status_code != 200) { + return ESP_ERR_INVALID_RESPONSE; +} + +if (content_length >= 0 && + (uint64_t)content_length != expected_size) { + return ESP_ERR_INVALID_SIZE; +} +``` + +设备边下载、边计算 SHA-256、边写入未运行分区,不需要把完整固件放进内存: + +```c +while (received < expected_size) { + int read_len = esp_http_client_read( + client, + (char *)buffer, + read_capacity); + + mbedtls_sha256_update( + &sha_ctx, + buffer, + (size_t)read_len); + + err = esp_ota_write( + ota_handle, + buffer, + (size_t)read_len); + + received += (uint64_t)read_len; +} +``` + +下载结束后比较摘要,再由 ESP-IDF 验证镜像格式: + +```c +if (memcmp(actual_sha256, + expected_sha256, + sizeof(actual_sha256)) != 0) { + return ESP_ERR_INVALID_CRC; +} + +err = esp_ota_end(ota_handle); +if (err != ESP_OK) { + return err; +} + +err = esp_ota_set_boot_partition(partition); +``` + +任何失败都会调用 `esp_ota_abort()`,当前正在运行的分区保持不变,不会启动一个只下载了一半或校验失败的镜像。 + +### 4.7 回滚保护 + +OTA 配置启用了 ESP-IDF 应用回滚功能。新镜像第一次启动时处于待验证状态,运行时启动 OTA 服务会确认当前镜像: + +```c +esp_ota_img_states_t state = ESP_OTA_IMG_UNDEFINED; +esp_err_t err = esp_ota_get_state_partition(running, &state); + +if (err == ESP_ERR_NOT_FOUND || + state != ESP_OTA_IMG_PENDING_VERIFY) { + return ESP_OK; +} + +return esp_ota_mark_app_valid_cancel_rollback(); +``` + +如果新固件无法正常启动并完成确认,ESP-IDF 可以回滚到上一份可用固件。 + +### 4.8 OTA 实现成果 + +在最终设备日志中已经确认: + +```text +Device JWT ready (exp=1786692109 scope=0x0000000f kv=1) +OTA version report response: http=204 +OTA task stack phase=version_report headroom=8072B configured=16384B +OTA HTTPS window released: restore_multimodal=1 restore_local_wake=1 +Local wake restore requested after OTA HTTPS window +OTA version report complete: running firmware is current (http=204) +WebSocket connected +Server success prompt played on attempt 1 +``` + +这组结果证明: + +- 启动检查前成功生成了新 JWT; +- JWT 权限覆盖 OTA 上报要求; +- 服务端返回 204,表示当前没有新版本; +- 没有再次出现 401 或 403; +- 16 KB OTA 栈在版本上报阶段仍剩余约 8072 B; +- OTA HTTPS 结束后,本地唤醒和 WebSocket 均成功恢复; +- 服务器连接成功提示音正常播放; +- 后续多轮对话和下发音频正常。 + +ESP-IDF 5.5.4 完整编译通过。生成的应用固件大小约为 `0x20a8f0` 字节,最小应用分区仍有 `0x175710` 字节,即约 42% 空间可用。 + +### 4.9 OTA 的后续影响和注意事项 + +#### 启动时间 + +设备联网后的首次 OTA 检查会先于语音服务执行,因此 WebSocket 和本地唤醒会比以前晚几秒启动。这是有意的资源保护策略,不是故障。版本接口超时上限为 15 秒;失败后会释放 OTA 窗口并恢复语音。 + +#### 网络流量 + +正常情况下每 6 小时增加一次很小的 JSON POST。只有服务端下发并批准升级时,设备才会 GET 下载完整固件。 + +#### 内存 + +OTA 已通过语音窗口和内存门槛降低 TLS 内存不足风险,但设备稳态内部内存仍然较紧。实测历史最低内部内存约 6.6 KB,虽然 `alloc_failures=0` 且没有崩溃,后续增加新网络功能时仍需关注内部 RAM、DMA 最大连续块和任务栈。 + +#### 服务可用性 + +固件下载期间本地唤醒和云端语音暂时不可用,下载完成后设备会直接重启。这是 OTA 的预期行为。后续如需改善用户体验,可以在升级前播放“系统升级中”提示音,但不能在下载过程中重新启动常驻音频任务。 + +#### 服务端契约 + +服务端必须保证以下内容稳定: + +- 版本上报接口接受带 `ota:report` 权限的设备 JWT; +- 无更新返回 HTTP 204; +- 有更新返回完整且类型正确的字段; +- 固件 URL 使用 HTTPS; +- `sizeBytes` 与实际文件长度一致; +- `sha256` 是实际二进制文件的 SHA-256; +- 如果启用 HMAC,设备和服务端必须使用相同密钥、签名格式和时间容差。 + +#### 版本发布 + +每次发布新固件前必须更新 `version.txt`,生成固件后还应记录: + +- 版本号; +- 文件字节数; +- SHA-256; +- 下载 URL; +- 是否强制升级; +- 灰度设备范围和回滚策略。 + +--- + +## 5. 最终评价 + +这两个提交形成了互补关系: + +- 多媒体提交修复了原有的提示音、启停竞争和旧音频事件问题,使语音链路可以可靠地暂停和恢复。 +- OTA 提交在此基础上实现了安全、可恢复、资源受控的固件升级流程。 + +当前验证结果说明“启动检查无更新”的主流程已经稳定运行。下一阶段最重要的验证是使用测试固件完成一次真实升级,覆盖以下场景: + +1. 非强制更新、等待确认后升级; +2. 强制更新; +3. 下载中途断网; +4. 固件大小不一致; +5. SHA-256 不一致; +6. 新镜像启动失败后的自动回滚; +7. OTA 检查时设备正在对话,确认不会中断当前交互。 diff --git a/docs/knowledge vault/ota-periodic-check-and-https-download-fixes.zh-CN.md b/docs/knowledge vault/ota-periodic-check-and-https-download-fixes.zh-CN.md new file mode 100644 index 0000000..a6fc8b9 --- /dev/null +++ b/docs/knowledge vault/ota-periodic-check-and-https-download-fixes.zh-CN.md @@ -0,0 +1,242 @@ +# OTA 周期检查与 HTTPS 下载稳定性修复说明 + +## 1. 提交信息 + +```text +451d989 fix(ota): stabilize periodic checks and HTTPS downloads +``` + +本提交解决三个相关问题: + +1. 首次 OTA 检查完成后,后续周期检查可能永远不再执行。 +2. 下载腾讯云 COS 固件时,TLS 接收缓冲区可能不足,导致 HTTPS 响应头读取失败。 +3. 固件版本仍为 `1.0.0`,无法作为新的 OTA 测试目标与旧固件区分。 + +## 2. 问题原因 + +### 2.1 后续 OTA 检查被常驻云端 task 永久阻塞 + +多模态语音使用的是常驻双工 task。一次对话结束后,WebSocket 和云端 task 不会立即销毁,而是保持在 `Listening` 状态等待下一轮对话。 + +旧的 OTA 空闲判断要求同时满足: + +```c +return !state.has_active_task && + !state.client_speech_active && + !state.remote_responding && + !state.local_responding_started; +``` + +其中 `has_active_task` 的含义是“云端 task 是否存在”,不是“用户是否正在对话”。 + +正常多轮对话空闲时的状态可能是: + +```text +has_active_task=1 +server_listening=1 +client_speech_active=0 +remote_responding=0 +local_responding_started=0 +``` + +此时用户没有说话,设备也没有播放回答,实际上可以安全进行 OTA 检查。但因为 `has_active_task=1`,旧逻辑始终认为设备忙碌。 + +结果是: + +```text +首次开机检查成功 + -> 云端 task 建立并常驻 + -> OTA 周期到期 + -> 空闲判断持续失败 + -> 每秒继续等待资源 + -> 后续检查永远无法开始 +``` + +所以即使把检查间隔改成 5 分钟,也不会在 5 分钟后看到第二次请求。这不是计时器没有到,而是计时器到期后一直卡在资源等待阶段。 + +### 2.2 等待资源时缺少明确诊断信息 + +旧代码进入 `WAITING_FOR_RESOURCES` 后只更新内部状态,没有打印具体阻塞原因。从串口日志看起来像 OTA worker 没有继续运行,很难区分以下情况: + +- 正在录音; +- 正在播放云端回复; +- 本地回复事件尚未结束; +- 仅仅存在一个空闲的常驻 task; +- 获取多模态状态失败。 + +如果每秒都打印状态又会产生大量重复日志,因此需要“一次等待周期只打印一次”的诊断机制。 + +### 2.3 OTA HTTPS 响应读取失败 + +固件下载地址使用腾讯云 COS 的 HTTPS 链接。日志中出现过: + +```text +esp-tls-mbedtls: read error :-0x7100 +platform_ota: OTA response header fetch failed +cp_ota: OTA install failed: ESP_ERR_INVALID_RESPONSE +``` + +TLS 握手和 HTTP 响应读取需要一个足够大的入站缓冲区。原配置为: + +```text +CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=8192 +``` + +当服务端证书链、TLS 记录或响应头超过当前可接收范围时,mbedTLS 无法完整读取记录,最终表现为 HTTPS 响应无效,而不是固件 SHA-256 错误。 + +### 2.4 固件版本无法区分 + +设备向 OTA 服务端上报 `version.txt` 中的版本。旧值是: + +```text +1.0.0 +``` + +如果新编译固件仍使用旧版本号,服务端无法可靠判断设备是否已经升级,也可能重复下发同一版本。 + +## 3. 解决方式 + +### 3.1 用“真实交互状态”判断 OTA 是否可以运行 + +修复后的判断不再把空闲常驻 task 当作繁忙条件: + +```c +static bool control_plane_ota_runtime_is_idle(void) +{ + if (!control_plane_voice_turn_is_idle()) { + return false; + } + + control_plane_multimodal_runtime_state_t state = {0}; + if (control_plane_multimodal_get_runtime_state(&state) != ESP_OK) { + return true; + } + + /* A duplex cloud task may remain in Listening between voice turns. OTA + * can retire and later restore that task once all real interaction has + * stopped. */ + return !state.client_speech_active && + !state.remote_responding && + !state.local_responding_started; +} +``` + +这里仍然保留了四层保护: + +1. `control_plane_voice_turn_is_idle()`:设备按键/本地唤醒控制的整轮语音流程必须处于空闲。 +2. `client_speech_active`:设备不能正在向云端发送用户语音。 +3. `remote_responding`:云端不能正在下发回答。 +4. `local_responding_started`:本地播放链路不能仍处于回复阶段。 + +只移除了 `!state.has_active_task`。OTA 获取资源窗口后会主动停止这个空闲 task,检查完成后再恢复 WebSocket 和本地唤醒,因此不会同时使用同一套 TLS、音频和内存资源。 + +### 3.2 增加一次性资源等待日志 + +worker 增加一个局部标志: + +```c +bool initial_check_pending = true; +bool resource_wait_logged = false; +``` + +当检查已经到期但语音仍忙时,只打印一次当前状态: + +```c +if (err == ESP_ERR_INVALID_STATE) { + control_plane_ota_set_state( + CONTROL_PLANE_OTA_STATE_WAITING_FOR_RESOURCES, + ESP_OK, + -1); + + if (!resource_wait_logged) { + resource_wait_logged = true; + control_plane_multimodal_runtime_state_t state = {0}; + + if (control_plane_multimodal_get_runtime_state(&state) == ESP_OK) { + ESP_LOGI(TAG, + "OTA check due; waiting for voice interaction: " + "task=%d listening=%d speech=%d responding=%d " + "local_responding=%d", + state.has_active_task ? 1 : 0, + state.server_listening ? 1 : 0, + state.client_speech_active ? 1 : 0, + state.remote_responding ? 1 : 0, + state.local_responding_started ? 1 : 0); + } else { + ESP_LOGI(TAG, "OTA check due; waiting for runtime resources"); + } + } + + control_plane_ota_wait_interruptible(1000U); + continue; +} + +resource_wait_logged = false; +``` + +网络断开或资源重新可用后会清除标志。下一次独立的等待周期仍会打印一次,不会丢失后续诊断信息。 + +### 3.3 将 TLS 入站缓冲区提高到 16 KB + +配置修改为: + +```text +CONFIG_MBEDTLS_DYNAMIC_BUFFER=y +CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=y +CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=16384 +CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096 +``` + +含义如下: + +- 入站缓冲区从 8 KB 增加到 16 KB,用于接收更大的证书链、TLS 记录和 HTTP 响应。 +- 出站缓冲区仍为 4 KB,因为设备请求体较小,不需要同步扩大。 +- 动态缓冲保持启用,TLS 会话结束后相关内存可以释放,不是永久占用 16 KB。 +- 入站和出站使用非对称大小,避免不必要地同时扩大两个方向。 + +### 3.4 更新固件版本 + +```text +1.0.0 -> 1.0.2 +``` + +设备上报版本变为 `1.0.2`。后端如果要让该设备继续升级,目标版本必须按照后端版本比较规则高于当前版本,并且固件文件内部版本也应与发布信息一致。 + +## 4. 解决成果 + +1. 周期到期后,即使云端 task 常驻在 `Listening`,OTA 仍能在真实语音空闲时执行。 +2. OTA 不会在用户录音、云端回复或本地回复期间强行抢占资源。 +3. 资源暂时不可用时,日志会明确显示是哪种语音状态阻塞检查,同时不会每秒刷屏。 +4. COS HTTPS 下载能够接收更大的 TLS 入站记录,降低响应头读取失败和 `ESP_ERR_INVALID_RESPONSE` 的概率。 +5. 新固件以 `1.0.2` 上报,服务端能够区分升级前后的设备版本。 +6. 使用 ESP-IDF 5.5.4 完整编译通过。 + +## 5. 后续影响 + +### 5.1 正向影响 + +- 5 分钟调试间隔和正式 6 小时间隔都能正常触发后续检查。 +- 常驻双工会话不再错误阻止 OTA。 +- OTA 等待语音结束后才运行,对正常对话的影响更可控。 +- HTTPS 下载兼容更大的证书链和 TLS 数据记录。 + +### 5.2 资源影响 + +TLS 入站缓冲上限增加了 8 KB。由于启用了动态缓冲,这部分主要体现在 HTTPS 会话期间的峰值内存,而不是设备全程常驻占用。 + +OTA 已经通过 voice window 暂停本地唤醒和多模态网络服务,以释放 internal RAM。后续如果新增其他会同时运行的 HTTPS 服务,仍应遵守同样的资源互斥策略。 + +### 5.3 行为影响 + +- 周期检查到期时如果用户正在对话,实际请求时间会顺延,直到语音链路空闲。 +- OTA 可以停止空闲的常驻云端 task,检查结束后会重新建立 WebSocket;这是预期行为。 +- 版本改为 `1.0.2` 后,服务端不应再向设备下发 `1.0.1` 或相同版本,除非后端明确支持强制降级。 + +### 5.4 仍需实机验证的项目 + +1. 将检查间隔设为 5 分钟,确认首次检查后还能出现第二次和第三次请求。 +2. 在检查到期前主动开始对话,确认先打印等待语音日志,对话结束后再执行 OTA。 +3. 使用 COS 固件链接进行完整下载,确认不再出现 TLS 响应头读取失败。 +4. 确认下载后的 size 和 SHA-256 校验仍然有效。 +5. 确认检查结束后本地唤醒和 WebSocket 均能恢复。 + diff --git a/docs/knowledge vault/project-runtime-workflow.md b/docs/knowledge vault/project-runtime-workflow.md new file mode 100644 index 0000000..163bfe2 --- /dev/null +++ b/docs/knowledge vault/project-runtime-workflow.md @@ -0,0 +1,708 @@ +# TQ Cat 固件整体架构与运行工作流 + +本文档基于 `feature/st7789p3-new-board` 分支的代码,面向项目接手、问题定位和后续重构。内容以实际函数调用关系为准。 + +## 1. 系统定位 + +项目运行在 ESP32-S3 上,主要负责: + +- ST7789P3 LCD 表情播放; +- ES8311 音频采集与播放; +- Opus 编解码与 WebSocket 多模态对话; +- TTP223 PTT、SW1、SW2 按键处理; +- ULN2003 步进电机和转盘校准; +- Wi-Fi 连接、重连和配置热点; +- 设备身份、JWT 和会话令牌; +- 用户出生资料、每日运势和财富方向; +- NVS、LittleFS、远程日志和工厂 CLI。 + +## 2. 分层架构 + +```mermaid +flowchart TB + Main["main / app_main"] + App["app_composition
启动顺序与应用装配"] + CP["control_plane
策略、状态机、任务、重试"] + Domain["domain
方向、校准、运势、表情语义"] + Platform["platform
GPIO、LCD、音频、网络、存储"] + HW["ESP32-S3 新板硬件"] + + Main --> App --> CP --> Domain --> Platform --> HW +``` + +各层职责: + +| 层 | 主要职责 | 典型内容 | +| --- | --- | --- | +| `main` | 固件入口 | 只调用 composition 入口 | +| `app_composition` | 决定启动顺序 | 存储、音频、LCD、校准、联网、workers | +| `control_plane` | 产品策略和生命周期 | Wi-Fi、PTT、多模态、任务、重试、异常恢复 | +| `domain` | 业务语义和状态 | 方向、角度、校准、运势、LCD 表情映射 | +| `platform` | 硬件与传输原语 | GPIO、I2S、I2C、SPI、WebSocket、NVS、HTTP | + +## 3. 启动总流程 + +入口: + +```text +app_main() +└── app_composition_start_default_runtime() + └── app_composition_run_plan_a_boot_sequence() +``` + +主要文件: + +- `main/main.c` +- `components/app_composition/src/runtime/app_composition_default_runtime.c` + +启动阶段: + +```mermaid +flowchart TD + Entry["app_main"] + Storage["准备 NVS / 文件系统"] + Audio["初始化音频并播放开机音"] + LCD["初始化 LCD 和渲染任务"] + Core["初始化 platform / domain"] + Expression["请求默认 LCD 表情"] + Calibration["执行转盘原点校准"] + WiFi["连接 Wi-Fi 或启动配置 AP"] + Workers["启动运行时 workers"] + + Entry --> Storage --> Audio --> LCD --> Core --> Expression --> Calibration --> WiFi --> Workers +``` + +### 3.1 存储初始化 + +```text +control_plane_prepare_runtime_storage() +└── domain_runtime_prepare_storage() + └── platform_prepare_storage() + ├── nvs_flash_init() + ├── 必要时 nvs_flash_erase() + ├── 再次 nvs_flash_init() + └── platform_log_nvs_stats() +``` + +默认 NVS namespace 包括 `wifi`、`ota`、`auth`、`auth_key` 和 `fortune`。 + +### 3.2 音频与开机音 + +```text +control_plane_play_boot_sound(timeout) +├── domain_runtime_multimodal_audio_init() +│ └── platform_multimodal_audio_init() +│ └── multimodal_audio_init() +└── domain_runtime_behavior_play_prompt_file(path) +``` + +`multimodal_audio_init()` 的主要步骤: + +```text +fill_codec_sample_info() +→ audio_init_heap_guard_passed() +→ init_opus_codec() +→ init_i2c_bus() +→ init_i2s_bus() +→ create_codec_path() +→ open_codec_device() +→ apply_codec_default_controls() +→ ensure_uplink_worker_tasks() +→ ensure_downstream_playback_task() +``` + +开机音失败只记录 warning,不会终止启动。 + +### 3.3 LCD 初始化 + +```text +control_plane_init_lcd_device() +└── domain_runtime_init_lcd_device() + └── platform_init_lcd_device() + └── lcd_direct_init_device() + ├── lcd_direct_gpio_init() + └── lcd_direct_panel_init() +``` + +渲染任务: + +```text +control_plane_start_lcd_render_worker() +└── control_plane_lcd_render_loop_task() + └── control_plane_start_lcd_render_loop() + └── domain_runtime_start_lcd_render_loop() + └── platform_start_lcd_render_loop() + └── lcd_direct_start_render_loop() +``` + +### 3.4 platform/domain 初始化 + +```text +control_plane_init() +├── domain_direction_default_axis_config() +├── domain_direction_default_calibration() +└── domain_init(axis_config, calibration) + ├── domain_runtime_platform_init() + │ └── platform_init() + │ ├── platform_power_hold_init() + │ ├── platform_io_effects_init() + │ ├── platform_stepper_driver_init() + │ └── platform_stepper_driver_power_off() + ├── 初始化方向状态 + └── domain_calibration_store_set() +``` + +注意:启动日志将 power hold 描述为已提前拉起,但实际 `platform_power_hold_init()` 位于 `platform_init()` 中。新板应真机验证 GPIO9 拉高时机是否足够早。 + +## 4. 新板关键 GPIO + +| 功能 | GPIO | 电气语义 | +| --- | ---: | --- | +| Power Hold | 9 | 高有效 | +| LCD CLK | 8 | SPI clock | +| LCD MOSI | 18 | SPI data | +| LCD CS | 15 | 低有效 | +| LCD DC | 16 | 数据/命令 | +| LCD RESET | 17 | 低有效 reset | +| LCD Backlight | 7 | 高有效 | +| TTP223 / PTT | 3 | 高有效,无内部上下拉 | +| SW1 校准开关 | 12 | 低有效,上拉 | +| SW2 功能开关 | 13 | 低有效,上拉 | +| Stepper IN1/2/3/4 | 45/48/47/21 | ULN2003 相线 | +| Audio PA | 11 | 功放使能 | +| ES8311 I2C SDA/SCL | 1/2 | codec 控制 | +| I2S MCLK/BCLK/DOUT/WS/DIN | 38/39/40/41/42 | 音频数据 | + +## 5. 转盘校准与旋转 + +启动校准调用链: + +```text +control_plane_calibrate() +└── control_plane_calibrate_with_timeout() + └── control_plane_direction_calibrate_internal() + └── domain_calibrate_with_timeout() +``` + +`domain_calibrate_with_timeout()`: + +```text +锁定方向状态 +→ prepare_calibration_switch() +→ 检查 SW1 是否已有效 +→ 循环执行 calibration_rotate_and_track() +→ calibration_settle() +→ 再次读取 SW1 +→ 触发后 calibration_return_from_limit() +→ domain_set_current_angle(home_angle) +→ domain_runtime_stepper_power_off() +``` + +底层旋转: + +```text +domain_runtime_stepper_rotate_angle_with_accel_timeout() +└── platform_stepper_rotate_angle_with_accel_timeout() + └── platform_stepper_driver_rotate_angle_with_accel() + └── rotate_with_accel() + ├── calculate_motion_segments() + ├── stepper_advance_phase() + └── precise_delay_us() +``` + +绝对角度旋转: + +```text +control_plane_rotate_to_angle() +└── domain_rotate_to_angle_with_timeout() + ├── domain_angle_normalize() + ├── domain_angle_shortest_rotation() + ├── 执行电机旋转 + └── 更新 s_current_angle +``` + +## 6. Wi-Fi 启动与恢复 + +启动入口: + +```text +control_plane_bootstrap_connectivity(seed_ssid, seed_password, timeout, "TQ-CAT") +``` + +调用链: + +```text +control_plane_register_wifi_behavior_hooks() +→ domain_runtime_wifi_portal_init_defaults() +→ wifi_portal_init_defaults() +→ 检查已保存凭据 +→ control_plane_connect_sta_with_feedback() +→ 最多三次 STA 连接 +→ 成功则播放提示音 +→ 失败则按错误类型保留或清除凭据 +→ 必要时启动 TQ-CAT 配置 AP +``` + +运行时由 `control_plane_wifi_state_task()` 负责: + +- 检测 Wi-Fi 断开; +- 等待短暂 disconnect grace; +- 停止多模态 client 和用户配置 server; +- 请求断网表情; +- 按指数退避重连; +- 重连后恢复 idle 表情、WebSocket 和配置 server; +- 同时驱动 PTT 状态机。 + +## 7. 运行时任务拓扑 + +入口: + +```text +control_plane_start_runtime_workers() +``` + +```mermaid +flowchart TB + Start["control_plane_start_runtime_workers"] + Model["cp_model_cmd
模型设备命令"] + WiFi["cp_wifi_state
Wi-Fi、WS、PTT"] + LCD["cp_lcd_render
LCD 启动"] + Events["cp_system_event
事件分发"] + Keys["cp_key_input
GPIO 与消抖"] + Logs["remote log worker"] + Fortune["cp_fortune
每日运势"] + Monitor["cp_task_monitor
可选诊断"] + + Start --> Model + Start --> WiFi + Start --> LCD + Start --> Events + Start --> Keys + Start --> Logs + Start --> Fortune + Start --> Monitor +``` + +`control_plane_schedule_websocket_wait_task()` 当前只设置 `s_ws_start_requested = true`,实际 WebSocket 启动由 `cp_wifi_state` 执行。 + +## 8. 按键和系统事件 + +按键中断链路: + +```text +GPIO edge +→ control_plane_key_gpio_isr_handler() +→ xQueueSendFromISR(s_gpio_irq_queue) +→ control_plane_key_input_task() +→ control_plane_process_key_timers() +→ control_plane_handle_debounced_transition() +→ control_plane_emit_key_event() +→ control_plane_post_system_event() +→ control_plane_system_event_task() +→ control_plane_dispatch_system_event() +→ control_plane_dispatch_key_event() +``` + +事件包括: + +- `PRESSED`; +- `RELEASED`; +- `SHORT_PRESS`; +- `LONG_PRESS_START`; +- `LONG_PRESS_RELEASE`; +- `DOUBLE_CLICK`。 + +行为映射: + +| 输入 | 行为 | +| --- | --- | +| PTT 短按 | `control_plane_set_ptt_active(true)` | +| PTT 在服务端回复时按下 | `control_plane_multimodal_send_request_to_speak()` | +| PTT 双击 | 请求 `DOMAIN_EXPRESSION_MUYU` | +| SW2 单击 | 请求 `DOMAIN_EXPRESSION_CHOUQIAN` | +| SW2 双击 | 请求 `DOMAIN_EXPRESSION_WUXING` | + +## 9. PTT 状态机 + +状态: + +```text +IDLE +WAIT_WIFI +WAIT_WS +WAIT_START_SENT +WAIT_LISTENING +WAIT_SPEECH_STARTED +STREAMING +TEARDOWN_LOCAL +TEARDOWN_REMOTE +DENIED +``` + +状态机由 `control_plane_drive_ptt_state_machine()` 在 `cp_wifi_state` 中周期驱动: + +```text +无 Wi-Fi → WAIT_WIFI +无 WebSocket → WAIT_WS +无 active task → WAIT_START_SENT +服务端未 Listening → WAIT_LISTENING +未发送 SendSpeech → WAIT_SPEECH_STARTED +uplink 已运行 → STREAMING +释放/超时 → TEARDOWN_LOCAL → TEARDOWN_REMOTE → IDLE +``` + +每次推进调用 `control_plane_multimodal_handle_ptt_press_step()`,根据当前会话状态选择: + +- `control_plane_multimodal_send_start()`; +- `control_plane_multimodal_send_request_to_speak()`; +- `control_plane_multimodal_send_speech_start()`; +- `control_plane_multimodal_start_uplink()`。 + +## 10. 设备认证和 WebSocket + +启动链: + +```text +cp_wifi_state +→ control_plane_try_start_multimodal_from_state_machine() +→ control_plane_start_multimodal_client() +→ control_plane_multimodal_start() +``` + +`control_plane_start_multimodal_client()`: + +```text +control_plane_prepare_for_multimodal_start() +→ 注册 event callback +→ 注册 downstream audio callback +→ 创建 mm_watchdog +→ control_plane_multimodal_start() +``` + +认证链: + +```text +control_plane_multimodal_auth_fetch() +├── resolve_device_identity() +├── control_plane_multimodal_device_auth_ensure() +│ ├── control_plane_multimodal_device_auth_init() +│ ├── 检查已有 JWT 和 scope +│ └── refresh_jwt_internal() +├── control_plane_multimodal_device_auth_copy_jwt() +├── HTTP POST token endpoint +├── 401/403 时 invalidate JWT 并重试 +└── parse_token_response() +``` + +WebSocket 链: + +```text +domain_runtime_multimodal_transport_start() +└── platform_multimodal_transport_start() + └── multimodal_ws_start() + ├── 停止旧 client + ├── 构造 Authorization 和 Workspace headers + ├── 创建 send mutex + ├── create_worker_pipeline() + └── start_websocket_client_with_fallback() +``` + +## 11. 一次完整语音会话 + +```mermaid +sequenceDiagram + participant User as 用户/PTT + participant CP as Control Plane + participant Session as Session Service + participant Audio as Audio Pipeline + participant WS as WebSocket + participant Server as 服务端 + + User->>CP: 短按 + CP->>Session: send_start() + Session->>WS: run-task / Start + Server-->>Session: DialogStateChanged(Listening) + CP->>Session: send_speech_start() + CP->>Audio: start_uplink() + Audio->>WS: Opus binary frames + Server-->>Session: SpeechEnded / RespondingStarted + Session->>Audio: stop uplink / begin response + Server-->>Audio: Opus binary frames + Audio-->>User: 扬声器播放 + Server-->>Session: RespondingEnded + Audio-->>Session: playback completed + Session->>WS: LocalRespondingEnded +``` + +### 11.1 Start + +```text +control_plane_multimodal_send_start() +→ 生成 task UUID +→ 构造 run-task / Start JSON +→ 设置 upstream/downstream Opus 参数 +→ domain_runtime_multimodal_transport_send_text() +→ 保存 s_task_id +``` + +### 11.2 开始采集和上行 + +```text +control_plane_multimodal_send_speech_start() +→ send_directive("continue-task", "SendSpeech") +→ control_plane_multimodal_start_uplink() +→ domain_runtime_multimodal_audio_start_uplink() +→ multimodal_audio_start_uplink() +``` + +实时音频: + +```text +I2S capture task +→ PCM 整理 +→ Opus encode +→ uplink send queue +→ uplink_send_task +→ control_plane_audio_uplink_send_cb() +→ control_plane_multimodal_send_audio_realtime() +→ multimodal_ws_send_binary_realtime() +``` + +### 11.3 下行播放 + +```text +WebSocket binary frame +→ transport_event_bridge() +→ control_plane_downstream_audio_bridge() +→ control_plane_multimodal_play_downstream_audio() +→ multimodal_audio_handle_downstream_opus() +→ downstream playback queue +→ Opus decode +→ I2S / ES8311 播放 +``` + +服务端 `RespondingEnded` 和本地播放完成都满足后,才发送 `LocalRespondingEnded`。 + +## 12. 服务端事件解析 + +所有文本帧进入 `process_text_json()`,主要处理: + +- `task-failed`; +- `task-started`; +- `DialogStateChanged`; +- `RequestAccepted`; +- `RespondingStarted`; +- `RespondingContent`; +- `RespondingEnded`; +- `SpeechStarted`; +- `SpeechContent`; +- `SpeechEnded`; +- `Stopped`; +- `Error`。 + +回复文本还会进入: + +```text +notify_responding_text() +→ control_plane_responding_text_bridge() +→ control_plane_extract_model_command() +→ control_plane_on_model_command() +→ model command queue +→ control_plane_model_command_task() +``` + +设备命令包括方向、校准动作和抽签。 + +## 13. LCD 表情工作流 + +```text +domain_runtime_request_expression() +├── domain_resolve_expression_platform_request() +└── platform_lcd_request_presentation() + └── lcd_direct_request_presentation() +``` + +domain 负责把业务语义映射成 platform 播放请求: + +```text +IDLE +SERVER_ERROR +WIFI_DISCONNECTED +WEALTH_SPIN +WEALTH_DIRECTION +MUYU +CHOUQIAN +WUXING +``` + +platform 只处理: + +- asset directory; +- frame index; +- frame interval; +- priority; +- interrupt policy; +- repeat count; +- return-to-idle。 + +渲染循环: + +```text +lcd_direct_take_pending() +→ lcd_direct_play_asset_sequence() 或 lcd_direct_play_step_sequence() +→ lcd_direct_signal_completion() +→ 无 pending 时播放 basic idle face +``` + +## 14. 财富方向与运势 + +财富方向动作: + +```text +control_plane_apply_wealth_direction() +├── domain_runtime_preheat_expression(reveal) +├── 阻塞播放 WEALTH_SPIN +├── 请求 WEALTH_DIRECTION reveal +├── 记录 start_angle +├── 旋转固定角度 +├── 延时 +└── 返回 start_angle +``` + +当前 `direction_index` 主要选择 LCD 方向资源,电机仍执行固定角度转出再返回,并非直接旋转到方向目录的绝对角度。 + +每日运势 worker: + +```text +control_plane_daily_fortune_task() +├── 等待 birth profile +├── 等待 Wi-Fi +├── control_plane_wait_for_synced_time() +├── 等待 WebSocket TLS 握手稳定 +├── 计算本地日期 +├── control_plane_fortune_fetch() +├── domain_fortune_result_store_set() +└── control_plane_apply_wealth_direction() +``` + +运势 HTTPS 与 WebSocket TLS 握手被有意串行化,以避免内部 DMA RAM 不足。 + +## 15. 局域网用户配置 + +联网后 `control_plane_wifi_state_task()` 启动用户配置 server: + +```text +control_plane_ensure_user_config_server() +└── domain_user_config_server_start() + └── platform_lan_http_server_start() +``` + +路由: + +| 方法 | 路径 | 处理函数 | +| --- | --- | --- | +| GET | `/` | `handle_index()` | +| GET | `/api/profile` | `handle_get_profile()` | +| POST | `/api/profile` | `handle_post_profile()` | +| GET | `/api/fortune` | `handle_get_fortune()` | + +资料更新后: + +```text +domain_birth_profile_store_set() +→ birth profile observer +→ control_plane_request_fortune_refresh() +→ 唤醒 cp_fortune +``` + +## 16. Watchdog 和异常恢复 + +`multimodal_watchdog_task()` 每秒检查: + +- WebSocket 是否连接; +- 是否存在 active task; +- heartbeat 是否到期; +- 是否长时间没有下行; +- transport 是否空闲超过 30 秒。 + +处理: + +```text +heartbeat 到期 +→ control_plane_multimodal_send_heartbeat() + +无下行超时 +→ control_plane_multimodal_send_stop("NoResponseTimeout") +→ stop uplink + +transport 空闲 30 秒 +→ control_plane_multimodal_stop() +``` + +WebSocket 断开或错误时,会停止 uplink,并由 `cp_wifi_state` 安排后续重连。 + +## 17. 停止流程 + +```text +control_plane_stop_runtime_workers() +├── 设置 runtime stop flag +├── 停止用户配置 server +├── 注销模型命令和 profile observer +├── 停止 key task 和 system event task +├── 停止 LCD render loop +├── bounded wait 各 worker +├── 必要时强制删除 task +├── 删除 model command queue +└── control_plane_stop_multimodal_client() + ├── stop_watchdog_task() + └── control_plane_multimodal_stop() + ├── stop uplink + ├── 停止本地 responding worker + ├── 注销 callbacks + ├── transport_stop(2000) + └── reset_session_state_locked() +``` + +## 18. 调试索引 + +遇到问题时先判断属于哪个状态机: + +1. `control_plane_wifi_state_task()`:Wi-Fi、WebSocket 启动、PTT; +2. multimodal session:task、Listening、Speech、Responding; +3. audio pipeline:capture、send queue、decode、playback。 + +推荐排查路径: + +| 现象 | 优先检查 | +| --- | --- | +| 上电后掉电 | GPIO9、`platform_power_hold_init()` 调用时机 | +| LCD 无显示 | GPIO、`lcd_direct_init_device()`、背光和 SPI | +| 电机不动 | `platform_stepper_driver_init()`、相序、限位状态 | +| 开机校准超时 | GPIO12、有效电平、最大 sweep、旋转方向 | +| PTT 无反应 | ISR queue、消抖、system event、PTT state | +| 有按键但没录音 | Listening、SendSpeech、audio init、uplink task | +| 有录音但服务端收不到 | Opus queue、uplink callback、WS binary send | +| 服务端回复但没声音 | binary callback、decode queue、I2S、PA GPIO11 | +| 第二轮对话失败 | `s_task_id`、Listening、speech flags、playback completion | +| 断网不恢复 | `cp_wifi_state`、saved credentials、backoff、WS start request | + +## 19. 关键源码入口 + +- `main/main.c` +- `components/app_composition/src/runtime/app_composition_default_runtime.c` +- `components/control_plane/src/runtime/control_plane_runtime_workers.c` +- `components/control_plane/src/runtime/control_plane_connectivity_runtime.c` +- `components/control_plane/src/runtime/control_plane_key_input.c` +- `components/control_plane/src/runtime/control_plane_system_events.c` +- `components/control_plane/src/multimodal/control_plane_multimodal_runtime.c` +- `components/control_plane/src/multimodal/control_plane_multimodal_session_service.c` +- `components/control_plane/src/multimodal/control_plane_multimodal_auth_service.c` +- `components/control_plane/src/multimodal/control_plane_multimodal_device_auth.c` +- `components/domain/src/direction/domain_direction_service.c` +- `components/domain/src/calibration/domain_calibration_service.c` +- `components/domain/src/runtime/domain_runtime_adapters.c` +- `components/platform/src/multimodal/audio_pipeline/audio_pipeline_runtime_impl.c` +- `components/platform/src/multimodal/transport_ws/transport_ws_runtime_impl.c` +- `components/platform/src/lcd_direct.c` +- `components/platform/src/platform_stepper_driver.c` + diff --git a/docs/knowledge vault/runtime-diagnostics-and-network-recovery-config.md b/docs/knowledge vault/runtime-diagnostics-and-network-recovery-config.md new file mode 100644 index 0000000..d664574 --- /dev/null +++ b/docs/knowledge vault/runtime-diagnostics-and-network-recovery-config.md @@ -0,0 +1,172 @@ +# Runtime Diagnostics and Network Recovery Configuration + +This document describes the build-time configuration switches used for runtime diagnostics and automatic network recovery. All options are available under `Example Configuration` in `idf.py menuconfig`. + +These are compile-time options. Rebuild and flash the firmware after changing them. + +## Quick reference + +| Kconfig symbol | Default in `sdkconfig.defaults` | Category | Effect | +| --- | --- | --- | --- | +| `CONFIG_TQ_TASK_MONITOR_ENABLE` | Disabled | Diagnostics | Periodically prints a compact FreeRTOS task table. | +| `CONFIG_TQ_MEMORY_MONITOR_ENABLE` | Enabled | Diagnostics | Samples internal RAM, DMA-capable RAM, PSRAM, fragmentation, and allocation failures. | +| `CONFIG_TQ_NETWORK_DIAGNOSTICS_ENABLE` | Enabled | Diagnostics | Collects detailed DNS, Wi-Fi, heap, ping, and cached-endpoint TCP probe information after DNS failures. | +| `CONFIG_TQ_MULTIMODAL_DIAGNOSTICS_ENABLE` | Enabled | Diagnostics | Logs multimodal session generations, transport cleanup stages, and the automatic-reassociation failure streak. | +| `CONFIG_MULTIMODAL_AUDIO_LOG_UPLINK_LEVELS` | Disabled | Diagnostics | Logs microphone input levels and uplink queue/send statistics. | +| `CONFIG_TQ_NETWORK_AUTO_REASSOCIATION_ENABLE` | Enabled | Functional recovery | Disconnects and reconnects the Wi-Fi station after the first multimodal HTTPS connection failure. | + +## Task monitor + +### `CONFIG_TQ_TASK_MONITOR_ENABLE` + +Enables periodic FreeRTOS task snapshots. It is useful for finding task leaks, stack pressure, and workers that fail to exit. + +Related options: + +- `CONFIG_TQ_TASK_MONITOR_INTERVAL_MS`: reporting interval. +- `CONFIG_TQ_TASK_MONITOR_MAX_TASKS`: maximum number of task entries captured per report. + +Runtime impact: periodic task enumeration, formatting, and log output. Keep disabled in normal production builds unless task lifecycle data is required. + +## Memory watermark monitor + +### `CONFIG_TQ_MEMORY_MONITOR_ENABLE` + +Enables the runtime heap watermark monitor and the failed-allocation callback. Reports include: + +- Internal 8-bit RAM free/minimum/largest block. +- DMA-capable RAM free/minimum/largest block. +- PSRAM free/minimum/largest block. +- Allocated and free block counts. +- Contiguous-memory percentage. +- Current PTT, WebSocket, uplink, and local wake state. +- Allocation failure count and most recent failed allocation. + +Related options: + +- `CONFIG_TQ_MEMORY_MONITOR_SAMPLE_INTERVAL_MS`: heap sampling interval. +- `CONFIG_TQ_MEMORY_MONITOR_REPORT_INTERVAL_MS`: normal periodic report interval. +- `CONFIG_TQ_MEMORY_MONITOR_INTERNAL_WARN_BYTES`: internal-memory pressure threshold. +- `CONFIG_TQ_MEMORY_MONITOR_DMA_LARGEST_WARN_BYTES`: largest DMA block warning threshold. + +Runtime impact: periodic heap queries, state snapshots, and log output. Disable after long-duration memory stability has been established. + +## Network diagnostics + +### `CONFIG_TQ_NETWORK_DIAGNOSTICS_ENABLE` + +Enables detailed diagnostics when HTTPS DNS resolution fails. The diagnostic bundle records: + +- STA address, netmask, gateway, DHCP state, RSSI, and DNS servers. +- Internal and DMA heap state. +- One ICMP probe to the gateway. +- One ICMP probe to the fallback DNS server when available. +- One TCP connection probe to the last successfully resolved HTTPS endpoint. +- DNS lookup identifiers, concurrent lookup count, failure count, and lookup duration. + +When disabled, the diagnostic functions, probe state, and direct `lwip` diagnostic dependency are compiled out. Normal DNS retry and HTTPS error handling remain active. + +Runtime impact when enabled: a failed DNS attempt can spend additional time running bounded ping and TCP probes, create a temporary semaphore and socket, and generate multiple warning logs. Use during network investigation and disable in production when the extra failure-path latency is undesirable. + +## Multimodal session diagnostics + +### `CONFIG_TQ_MULTIMODAL_DIAGNOSTICS_ENABLE` + +Enables additional logs for: + +- Multimodal session generation numbers. +- Waiting for stale transport cleanup before authentication. +- Completion or failure of the cleanup stage. +- Consecutive HTTPS failure count used by automatic Wi-Fi reassociation, when that recovery option is enabled. + +Disabling this option does not disable transport cleanup, downstream stall recovery, WebSocket retirement, or error warnings required to understand a functional failure. + +Runtime impact: small counter updates and extra log formatting/output. It does not initiate network recovery by itself. + +## Microphone uplink level diagnostics + +### `CONFIG_MULTIMODAL_AUDIO_LOG_UPLINK_LEVELS` + +Enables periodic microphone and uplink statistics, including raw channel level, selected uplink channel level, queued packets, sent bytes, drops, and send failures. + +Runtime impact: periodic audio-level calculations and verbose logging on the real-time audio path. Keep disabled unless investigating capture mapping, gain, clipping, silence, or uplink congestion. + +## Automatic Wi-Fi reassociation + +### `CONFIG_TQ_NETWORK_AUTO_REASSOCIATION_ENABLE` + +This is a functional recovery option, not a diagnostic-only option. + +When enabled, the first multimodal HTTPS connection failure requests controlled STA reassociation. The Wi-Fi state worker then: + +1. Keeps the device awake. +2. Disconnects the Wi-Fi station. +3. Requests reconnection using saved credentials. +4. Waits for the bounded Wi-Fi reconnect timeout. +5. Restarts multimodal connection scheduling after successful reassociation. + +When disabled, HTTPS and WebSocket retry behavior remains active, but the firmware does not intentionally disconnect a Wi-Fi connection that is still reported as associated. + +Runtime impact when enabled: all device network traffic is interrupted during reassociation, including WebSocket, HTTPS, remote logging, LAN HTTP, and mDNS traffic. It is enabled by default because field testing demonstrated a stale STA data path that repeated transport-level reconnects could not recover. Disable it only when deliberate reassociation is unacceptable for a deployment. + +The HTTPS preflight performs one application-level DNS lookup. lwIP already +retransmits DNS queries internally, and the captured failure took about 14 +seconds before `getaddrinfo()` returned. A second application-level lookup only +duplicated the wait and did not recover the stale data path. + +### Field validation: stale associated STA recovery + +The 2026-08-12 device log captured the complete failure and recovery chain: + +1. Downstream TTS audio stopped arriving and the three-second playback watchdog + retired the unresponsive WebSocket after its bounded Stop acknowledgement + also timed out. +2. The token HTTPS preflight failed to resolve `gz.api.talkingq.com`. The STA + still reported an IP address, DHCP state, strong RSSI (`-38` to `-41 dBm`), + and configured DNS servers. +3. The gateway and fallback-DNS probes received no reply. More importantly, a + TCP probe to the last cached server address also timed out, proving that the + failure was not limited to name resolution. +4. With the old policy, two DNS attempts were made during each of two complete + multimodal start attempts. About 69.2 seconds elapsed from retiring the old + transport to connecting the replacement WebSocket; nearly 60 seconds were + spent waiting for repeated DNS failures. +5. After the second failed start, controlled STA reassociation completed in + about 1.25 seconds. The next DNS lookup succeeded in 296 ms, subsequent + lookups completed in 7 ms or less, authentication succeeded, and the new + WebSocket connected about 5.7 seconds later. +6. A subsequent local wake completed normally through listening, uplink ASR, + and downstream TTS, validating recovery without rebooting the device. + +This evidence supports both default reassociation and earlier recovery. With +one application-level DNS attempt and reassociation after the first failed +multimodal HTTPS start, the expected recovery time for the same fault is about +20 to 25 seconds rather than about 69 seconds. This estimate still requires a +device run to confirm because the lwIP DNS timeout dominates the remaining +delay. + +## Recommended profiles + +### Stability investigation + +```text +CONFIG_TQ_MEMORY_MONITOR_ENABLE=y +CONFIG_TQ_NETWORK_DIAGNOSTICS_ENABLE=y +CONFIG_TQ_MULTIMODAL_DIAGNOSTICS_ENABLE=y +# CONFIG_TQ_TASK_MONITOR_ENABLE is not set +# CONFIG_MULTIMODAL_AUDIO_LOG_UPLINK_LEVELS is not set +CONFIG_TQ_NETWORK_AUTO_REASSOCIATION_ENABLE=y +``` + +### Production candidate + +```text +# CONFIG_TQ_MEMORY_MONITOR_ENABLE is not set +# CONFIG_TQ_NETWORK_DIAGNOSTICS_ENABLE is not set +# CONFIG_TQ_MULTIMODAL_DIAGNOSTICS_ENABLE is not set +# CONFIG_TQ_TASK_MONITOR_ENABLE is not set +# CONFIG_MULTIMODAL_AUDIO_LOG_UPLINK_LEVELS is not set +CONFIG_TQ_NETWORK_AUTO_REASSOCIATION_ENABLE=y +``` + +Functional warnings and normal connection recovery remain available in the production candidate profile. diff --git a/docs/knowledge vault/versioned-firmware-artifact.zh-CN.md b/docs/knowledge vault/versioned-firmware-artifact.zh-CN.md new file mode 100644 index 0000000..92527f6 --- /dev/null +++ b/docs/knowledge vault/versioned-firmware-artifact.zh-CN.md @@ -0,0 +1,267 @@ +# 带版本号固件产物生成说明 + +## 1. 对应提交 + +```text +f5b4bd8 build: add versioned firmware artifact +``` + +该提交在不改变 ESP-IDF 原有应用镜像和烧录流程的前提下,为每次构建额外生成一份带项目版本号的固件文件,方便 OTA 发布、固件归档和版本追踪。 + +## 2. 问题原因 + +### 2.1 默认应用镜像名称无法区分版本 + +ESP-IDF 默认按照项目名称生成应用镜像。本项目的项目名为 `TQ_Cat_Code`,因此默认产物固定为: + +```text +build/TQ_Cat_Code.bin +``` + +无论当前固件版本是 `1.0.1`、`1.0.2` 还是后续版本,文件名都不会变化。连续构建或复制不同版本时,新文件容易覆盖旧文件,单看文件名也无法判断固件版本。 + +### 2.2 OTA 发布和固件归档依赖人工改名 + +在增加本次构建逻辑之前,如果需要向 OTA 服务上传带版本标识的固件,只能在构建完成后手工复制或重命名,例如: + +```text +TQ_Cat_Code.bin -> TQ_Cat_Code-1.0.2.bin +``` + +这种方式存在以下问题: + +1. 人工填写的版本号可能与固件内部版本不一致。 +2. 重命名时可能选错构建目录或上传旧文件。 +3. 自动发布脚本无法依赖稳定、可预测的版本化文件名。 +4. 多个版本放在同一目录时,固定名称不能提供有效区分。 + +### 2.3 不能直接替换 ESP-IDF 的默认产物 + +ESP-IDF 的烧录参数和项目描述仍引用 `TQ_Cat_Code.bin`。如果直接修改默认应用镜像名称,可能影响: + +- `idf.py flash` 的默认烧录流程; +- `flasher_args.json` 等构建元数据; +- 已经依赖固定文件名的开发和生产脚本。 + +因此,本次修改选择保留默认产物,并额外生成版本化副本,而不是改变 ESP-IDF 的标准输出名称。 + +## 3. 解决方式 + +### 3.1 从 ESP-IDF 构建属性读取项目版本 + +提交在 `project()` 完成项目初始化后读取 `PROJECT_VER`: + +```cmake +idf_build_get_property(TQ_PROJECT_VERSION PROJECT_VER) +``` + +本项目通过根目录的 `version.txt` 管理版本。ESP-IDF 配置项目时会解析该文件,并将结果写入 `PROJECT_VER`。提交创建时的项目版本为: + +```text +1.0.2 +``` + +构建逻辑直接使用 ESP-IDF 已解析的版本属性,而不是在 CMake 中重复读取或硬编码版本号。这样可以保证文件名中的版本与固件构建版本来自同一数据源。 + +### 3.2 组合版本化固件路径 + +版本化产物使用“项目名-项目版本.bin”的命名规则: + +```cmake +set(TQ_VERSIONED_FIRMWARE + "${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}-${TQ_PROJECT_VERSION}.bin") +``` + +各变量在当前项目中的值为: + +```text +CMAKE_BINARY_DIR = build 目录 +CMAKE_PROJECT_NAME = TQ_Cat_Code +TQ_PROJECT_VERSION = 1.0.2 +``` + +因此最终输出路径为: + +```text +build/TQ_Cat_Code-1.0.2.bin +``` + +版本升级后不需要修改构建脚本。例如将 `version.txt` 更新为 `1.0.3`,重新配置并构建后,产物会自动变为: + +```text +build/TQ_Cat_Code-1.0.3.bin +``` + +### 3.3 在应用镜像生成后复制版本化副本 + +提交新增了一个默认参与构建的自定义目标: + +```cmake +add_custom_target(versioned_firmware ALL + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}.bin" + "${TQ_VERSIONED_FIRMWARE}" + DEPENDS app + BYPRODUCTS "${TQ_VERSIONED_FIRMWARE}" + COMMENT "Creating versioned firmware ${CMAKE_PROJECT_NAME}-${TQ_PROJECT_VERSION}.bin" + VERBATIM +) +``` + +这段代码的行为如下。 + +#### `versioned_firmware ALL` + +定义名为 `versioned_firmware` 的构建目标。`ALL` 表示执行常规项目构建时会自动执行该目标,无需额外运行单独命令。 + +#### `copy_if_different` + +使用 CMake 自带的跨平台复制命令,将默认应用镜像复制为版本化文件: + +```text +build/TQ_Cat_Code.bin + -> build/TQ_Cat_Code-1.0.2.bin +``` + +只有源文件与目标文件内容不同时才会重新写入目标文件,可以避免未发生变化时进行无意义覆盖。该操作只是字节复制,不会再次编译、链接或修改固件内容。 + +#### `DEPENDS app` + +声明版本化目标依赖 ESP-IDF 的 `app` 目标。CMake 必须先完成应用镜像构建,随后才能复制文件,避免在源 `.bin` 尚未生成时执行复制。 + +#### `BYPRODUCTS` + +将版本化 `.bin` 声明为该目标的构建副产物,使 CMake 和底层构建工具能够识别这个文件由构建系统生成,而不是人工放入构建目录的未知文件。 + +#### `COMMENT` + +构建过程中输出明确提示,例如: + +```text +Creating versioned firmware TQ_Cat_Code-1.0.2.bin +``` + +开发人员可以通过构建日志确认版本化产物生成步骤已执行。 + +#### `VERBATIM` + +要求 CMake 按参数原意生成命令,正确处理路径中的空格、引号和平台差异,降低命令转义导致复制失败的风险。 + +## 4. 完整执行流程 + +构建过程变为: + +```text +读取 version.txt + | + v +ESP-IDF 设置 PROJECT_VER + | + v +编译并链接 app + | + v +生成 build/TQ_Cat_Code.bin + | + v +versioned_firmware 目标执行复制 + | + v +生成 build/TQ_Cat_Code-1.0.2.bin +``` + +最终两个应用镜像同时存在: + +```text +build/TQ_Cat_Code.bin +build/TQ_Cat_Code-1.0.2.bin +``` + +两者内容完全相同,区别仅在文件名。当前工作区的实际构建结果中,两份文件大小一致,SHA-256 也一致: + +```text +baa959fda9572c249ceaf6b8937d35940bc4c7dece2814fa2daebe1c616abc6d +``` + +## 5. 解决成果 + +### 5.1 固件版本可以直接从文件名识别 + +版本信息进入构建产物名称后,无需打开固件或查询构建记录,即可识别文件对应的项目版本。 + +### 5.2 降低 OTA 上传选错版本的概率 + +OTA 发布人员或自动化脚本可以直接选择带版本号的固件文件,避免手工改名时版本号与实际固件不一致。 + +### 5.3 支持多个版本并行归档 + +不同版本使用不同文件名,可以在发布目录、测试目录或制品库中同时保存: + +```text +TQ_Cat_Code-1.0.1.bin +TQ_Cat_Code-1.0.2.bin +TQ_Cat_Code-1.0.3.bin +``` + +这有利于版本回溯、测试对比和问题复现。 + +### 5.4 保持原有开发与烧录流程兼容 + +默认的 `TQ_Cat_Code.bin` 仍然生成,ESP-IDF 的烧录参数没有改变。因此原有的构建、串口烧录和调试命令无需调整。 + +### 5.5 不增加固件运行时开销 + +修改只发生在构建阶段,没有增加设备端代码、任务、内存、Flash 占用或启动时间。版本化文件也不是另一份不同的固件镜像,只是原应用镜像的副本。 + +## 6. 后续影响 + +### 6.1 构建目录磁盘占用增加 + +构建目录会额外保存一份与应用镜像等大的文件。当前应用镜像约为 2.1 MiB,因此单次当前版本构建会增加约 2.1 MiB 的磁盘占用。 + +该影响只存在于开发机或构建服务器,不会使设备 Flash 中出现两份应用镜像,也不会增加 OTA 下载大小。 + +### 6.2 旧版本产物不会被自动删除 + +当版本从 `1.0.2` 更新为 `1.0.3` 后,新构建会生成新文件,但原来的版本化文件可能继续保留: + +```text +TQ_Cat_Code-1.0.2.bin +TQ_Cat_Code-1.0.3.bin +``` + +这便于本地归档,但也可能让长期复用的构建目录逐渐积累旧产物。发布脚本不应简单选择目录中任意一个带版本号的 `.bin`,而应根据当前 `PROJECT_VER` 计算准确文件名。需要清理时,可以通过完整清理构建目录移除历史副产物。 + +### 6.3 版本变化后必须重新配置构建系统 + +版本化路径在 CMake 配置阶段根据 `PROJECT_VER` 生成。修改 `version.txt` 后,应通过正常的 ESP-IDF 构建流程触发重新配置,确保目标文件名使用新版本。持续集成环境使用干净构建目录时不会受到这一问题影响。 + +### 6.4 该文件只是应用镜像,不是完整烧录包 + +`TQ_Cat_Code-1.0.2.bin` 只对应应用程序分区。它不包含以下独立镜像或资源: + +- Bootloader; +- 分区表; +- OTA data 初始化镜像; +- LittleFS/LCD/音频资源镜像。 + +该文件适合应用分区 OTA,不能被误认为包含全部分区的整机合并烧录镜像。工厂烧录仍需按照 ESP-IDF 生成的烧录参数写入各个分区。 + +### 6.5 OTA 服务和发布脚本需要明确选择规则 + +新增文件不会自动改变 OTA 上传流程,也不会自动把固件发布到服务器。后续自动化应明确: + +1. 从当前项目版本计算版本化文件名; +2. 上传前校验文件存在且构建成功; +3. 计算并记录文件大小与 SHA-256; +4. 保证 OTA 元数据中的版本、大小和摘要与该文件一致。 + +### 6.6 项目名或版本字符串会直接进入文件名 + +文件名由 `CMAKE_PROJECT_NAME` 和 `PROJECT_VER` 直接组合。当前值只包含适合文件名的字母、数字、下划线、点和连字符,不存在兼容性问题。后续如果引入带斜杠、空格或其他特殊字符的版本规则,需要额外确认制品库、上传脚本和目标文件系统是否支持。 + +## 7. 结论 + +该提交以较小的构建配置改动解决了固件产物缺少版本标识的问题。它复用 ESP-IDF 的统一项目版本,保留原始应用镜像和烧录流程,并自动生成内容完全一致的版本化副本。 + +该方案不会影响设备运行行为,主要后续影响是构建目录增加一份应用镜像、历史版本文件可能累积,以及发布流程必须正确区分“应用 OTA 镜像”和“完整烧录镜像”。 diff --git a/docs/knowledge vault/voice-uplink-flow-zh.md b/docs/knowledge vault/voice-uplink-flow-zh.md new file mode 100644 index 0000000..b34f1e9 --- /dev/null +++ b/docs/knowledge vault/voice-uplink-flow-zh.md @@ -0,0 +1,1050 @@ +# 语音对话上行流程详解 + +## 1. 文档目的 + +本文结合当前工程代码,完整说明设备从用户按下语音键开始,到麦克风音频被编码为 Opus 并通过 WebSocket 发送给服务端为止的上行流程。 + +本文重点解释以下内容: + +- PTT 按键如何驱动多模态会话状态机; +- 为什么必须先等待服务端进入 `Listening`; +- `SendSpeech`、音频采集和 `StopSpeech` 之间的关系; +- ES8311/I2S PCM 数据如何变成单声道 Opus; +- `uplink_capture_task()` 的每一段代码具体在做什么; +- 采集任务和发送任务为什么必须分开; +- 队列满、编码失败、Codec 读取失败和停止录音时如何处理; +- 一帧音频在当前配置下具体包含多少样本和字节。 + +主要代码文件如下: + +| 职责 | 文件 | +| --- | --- | +| PTT 与会话编排 | `components/control_plane/src/multimodal/control_plane_multimodal_runtime.c` | +| 多模态协议和运行状态 | `components/control_plane/src/multimodal/control_plane_multimodal_session_service.c` | +| PTT 后台状态机 | `components/control_plane/src/runtime/control_plane_runtime_workers.c` | +| Domain 层适配 | `components/domain/src/runtime/domain_runtime_adapters.c` | +| Platform 多模态适配 | `components/platform/src/runtime/platform_multimodal_runtime.c` | +| 音频管线结构与配置 | `components/platform/src/multimodal/audio_pipeline.c` | +| 音频采集、编码、队列和停止逻辑 | `components/platform/src/multimodal/audio_pipeline/audio_pipeline_runtime_impl.c` | +| Codec 和 Opus Buffer 初始化 | `components/platform/src/multimodal/audio_pipeline/audio_pipeline_codec_impl.c` | +| WebSocket 实时发送 | `components/platform/src/multimodal/transport_ws/transport_ws_workers_impl.c` | + +## 2. 上行流程总览 + +一次正常的语音上行链路如下: + +```text +用户按下 PTT + ↓ +确认 Wi-Fi 和 WebSocket 已连接 + ↓ +确认服务端多模态 Task 已创建 + ↓ +等待服务端进入 Listening + ↓ +发送 SendSpeech 指令 + ↓ +将本地 client_speech_active 置为 true + ↓ +启动或唤醒 uplink_capture_task + ↓ +ES8311 ADC / I2S RX 采集 PCM + ↓ +从 Codec 原始 PCM 中提取麦克风通道 + ↓ +得到 16kHz、16-bit、单声道、60ms PCM + ↓ +Opus 编码 + ↓ +写入 uplink_send_queue + ↓ +uplink_send_task 取出 Opus 包 + ↓ +再次检查会话是否允许上行 + ↓ +WebSocket Binary 发送 + ↓ +服务端接收并执行 ASR +``` + +松开 PTT 时: + +```text +用户松开 PTT + ↓ +停止产生新的 PCM/Opus 包 + ↓ +限时等待发送队列排空 + ↓ +清除上行发送回调 + ↓ +发送 StopSpeech 指令 +``` + +## 3. 工程分层与职责 + +上行过程跨越四层: + +```text +app_composition + ↓ +control_plane + ↓ +domain + ↓ +platform +``` + +各层职责如下: + +### 3.1 Control Plane + +Control Plane 不直接操作 I2S,也不直接调用 ESP WebSocket API。它负责: + +- 判断是否允许开始说话; +- 维护 task、Listening、Speech、Responding 等协议状态; +- 发送 `SendSpeech`、`StopSpeech` 等文本指令; +- 启动和停止本地音频上行; +- 在每个 Opus 包发送前再次检查协议状态; +- 在断线、服务端开始回答或发生错误时停止上行。 + +### 3.2 Domain + +Domain 层在这条链路中主要承担接口边界适配。其函数通常直接把 Control Plane 的请求转发给 Platform,例如: + +```c +esp_err_t domain_runtime_multimodal_audio_start_uplink( + domain_multimodal_audio_uplink_cb_t cb, + void *user_data) +{ + return platform_multimodal_audio_start_uplink(cb, user_data); +} +``` + +### 3.3 Platform + +Platform 层负责实际能力: + +- ES8311 和 I2S 初始化; +- PCM 采集; +- 通道整理; +- Opus 编码; +- FreeRTOS 音频任务和队列; +- WebSocket 二进制发送。 + +## 4. PTT 按下后的状态推进 + +按键按下后,`control_plane_multimodal_handle_ptt_press_step()` 不会无条件启动麦克风,而是根据当前状态逐步推进。 + +核心检查顺序如下。 + +### 4.1 WebSocket 必须已经连接 + +```c +if (!state.connected) { + return ESP_ERR_INVALID_STATE; +} +``` + +没有可用传输连接时,即使采集了音频也无法发送,因此不能开始录音。 + +### 4.2 必须存在活动 Task + +```c +if (!state.has_active_task) { + return control_plane_multimodal_send_start(); +} +``` + +如果还没有服务端多模态 Task,当前按键步骤只负责发送启动 Task 的指令。任务创建是异步的,代码会在后续轮询中继续推进,而不是在一次函数调用中完成所有步骤。 + +### 4.3 服务端必须进入 Listening + +```c +if (!state.server_listening) { + return ESP_ERR_INVALID_STATE; +} +``` + +这是协议层的重要门禁。设备必须先收到服务端的 `DialogStateChanged(Listening)`,才可以开始本轮语音输入。 + +如果在服务端尚未准备好时发送二进制音频,可能产生以下问题: + +- 音频被服务端丢弃; +- 音频被错误归属到上一轮会话; +- 服务端返回协议状态错误; +- 设备本地已经录音,但 ASR 没有收到开头部分。 + +### 4.4 发送 SendSpeech + +如果本地还没有激活语音输入: + +```c +if (!state.client_speech_active) { + esp_err_t speech_err = control_plane_multimodal_send_speech_start(); + if (speech_err != ESP_OK) { + return speech_err; + } + return ESP_ERR_INVALID_STATE; +} +``` + +`control_plane_multimodal_send_speech_start()` 最终发送: + +```c +send_directive("continue-task", "SendSpeech", true, false); +``` + +发送成功后,本地立即更新: + +```c +s_client_speech_active = true; +s_ptt_release_required = false; +s_request_to_speak_pending = false; +``` + +需要特别注意:当前代码中的 `PTT_STATE_WAIT_SPEECH_STARTED` 名称容易让人误以为设备会等待服务端返回 `SpeechStarted` 后才开始采集。实际逻辑是: + +1. `SendSpeech` 发送成功; +2. 本地立即将 `client_speech_active` 设为 `true`; +3. 下一次状态机轮询即可启动上行; +4. 服务端返回的 `SpeechStarted` 主要用于表示 ASR 已经开始以及通知上层。 + +因此,真正启动本地上行的门禁是本地状态组合,而不是单独等待 `SpeechStarted` 事件。 + +### 4.5 启动音频上行 + +当以下条件全部满足时,代码调用 `control_plane_multimodal_start_uplink()`: + +- WebSocket 已连接; +- 存在活动 Task; +- 服务端处于 Listening; +- 本地 `client_speech_active` 为 true; +- 不要求用户先松开按键; +- 服务端没有正在 Responding; +- 当前不在下行播放保护时间内。 + +调用链如下: + +```text +control_plane_multimodal_start_uplink() + ↓ +domain_runtime_multimodal_audio_start_uplink() + ↓ +platform_multimodal_audio_start_uplink() + ↓ +multimodal_audio_start_uplink() +``` + +## 5. `multimodal_audio_start_uplink()` 做了什么 + +`multimodal_audio_start_uplink()` 并不直接执行一个永久采集循环。它负责配置并激活已经存在或即将创建的工作任务。 + +主要步骤为: + +```c +audio_set_uplink_callback(&s_audio, cb, cb_ctx); +``` + +保存 Opus 包发送回调。采集任务只把包放入队列,真正的发送任务取到包后通过该回调交给 Control Plane。 + +```c +s_audio.uplink_mono_fill_bytes = 0U; +memset(s_audio.uplink_mono_buf, 0, s_audio.uplink_mono_buf_capacity_bytes); +reset_uplink_debug_stats(); +``` + +清理上一轮残留状态、PCM Buffer 和调试统计。 + +```c +esp_err_t err = ensure_uplink_worker_tasks(&s_audio); +``` + +确保以下资源存在: + +- `uplink_send_queue`; +- `uplink_capture_task`; +- `uplink_send_task`。 + +随后: + +```c +reset_uplink_send_queue(&s_audio); +audio_set_uplink_recording_active(&s_audio, true); +xTaskNotifyGive(capture_task); +``` + +清空旧队列,将录音状态设为活动,并立即唤醒采集任务。 + +## 6. 为什么有两个上行任务 + +上行由两个 FreeRTOS 任务组成: + +```text +uplink_capture_task + 读取 PCM + 提取通道 + Opus 编码 + 写入队列 + │ + ▼ +uplink_send_queue + │ + ▼ +uplink_send_task + 读取 Opus 包 + 调用发送回调 + WebSocket Binary 发送 +``` + +拆分任务的根本原因是采集和网络的实时特性不同。 + +`esp_codec_dev_read()` 受到 I2S 音频时钟驱动,应当稳定地按音频速率读取。WebSocket 发送则可能因为以下原因出现不确定延迟: + +- 等待发送锁; +- Wi-Fi 短时重传; +- TCP 发送缓冲区拥塞; +- TLS 加密处理; +- WebSocket 客户端状态切换。 + +如果采集和网络发送放在同一任务里,网络阻塞会让设备不能及时读取 I2S 数据,最终可能导致 DMA 溢出、丢帧或明显断音。 + +因此当前实现采用生产者—消费者模型: + +- 采集任务是生产者; +- 发送任务是消费者; +- `uplink_send_queue` 是二者之间的缓冲区。 + +## 7. `uplink_capture_task()` 函数详解 + +函数签名如下: + +```c +static void uplink_capture_task(void *arg) +``` + +### 7.1 函数签名 + +`static` 表示函数只在当前编译单元内部可见。 + +第一个 `void` 表示函数不返回结果。 + +`void *arg` 是 FreeRTOS 任务函数的通用参数。当前创建任务时传入的是 `NULL`,所以函数开头写: + +```c +(void)arg; +``` + +这表示参数是接口要求保留的,但当前实现有意不使用它,可以避免编译器产生未使用参数警告。 + +正确签名是: + +```c +static void uplink_capture_task(void *arg) +``` + +它不是一个返回 `void *` 的函数,也不是接收 `void **arg`。 + +### 7.2 获取音频运行上下文 + +```c +multimodal_audio_ctx_t *ctx = &s_audio; +``` + +`s_audio` 是整个音频管线的单例运行上下文。该结构保存: + +- Codec 设备句柄; +- Opus 编码器和解码器; +- PCM/Opus Buffer; +- 音频格式; +- 上行队列; +- 采集和发送任务句柄; +- 录音活动状态; +- 任务退出状态; +- 上行发送回调。 + +使用局部 `ctx` 指针可以缩短后续表达式,也明确表示函数操作的是同一个共享音频上下文。 + +### 7.3 检查采集和编码资源 + +```c +if (!ctx->uplink_codec_buf || !ctx->uplink_mono_buf || !ctx->uplink_opus_buf || + !ctx->opus_enc || ctx->uplink_mono_bytes == 0U || ctx->uplink_codec_read_bytes == 0U) { +``` + +这里检查三个数据阶段的 Buffer: + +| 字段 | 内容 | +| --- | --- | +| `uplink_codec_buf` | Codec/I2S 读取出的原始 PCM | +| `uplink_mono_buf` | 提取麦克风通道后的单声道 PCM | +| `uplink_opus_buf` | Opus 编码器输出 Buffer | + +同时检查: + +- `opus_enc`:Opus 编码器是否已经创建; +- `uplink_mono_bytes`:每个 Opus 输入帧需要多少 PCM 字节; +- `uplink_codec_read_bytes`:每次应从 Codec 读取多少字节。 + +如果资源不完整: + +```c +audio_set_uplink_capture_task(ctx, NULL); +delete_audio_task_with_caps(NULL); +return; +``` + +先把上下文中的任务句柄清空,再删除当前任务。清空句柄非常重要,否则其他代码可能认为采集任务仍然存在,从而不会重新创建它。 + +### 7.4 检查发送队列 + +```c +if (!ctx->uplink_send_queue) { +``` + +采集任务不直接发送 WebSocket。它编码出的 Opus 包只能写入 `uplink_send_queue`。如果队列不存在,继续采集只会产生无法消费的数据,因此任务直接记录错误并退出。 + +### 7.5 输出启动参数 + +启动日志包含: + +- 任务栈大小; +- Opus 输入 PCM 大小; +- Opus 输出 Buffer 大小; +- Codec 每次读取大小; +- Codec 通道数量; +- 当前选择的麦克风通道。 + +这条日志可以快速判断音频格式和 Buffer 计算是否符合预期。 + +### 7.6 任务生命周期循环 + +```c +while (!audio_get_uplink_task_exit_requested(ctx)) { +``` + +这里存在两个不同的状态: + +| 状态 | 含义 | +| --- | --- | +| `uplink_recording_active` | 当前是否需要采集、编码和入队 | +| `uplink_task_exit_requested` | 整个任务是否应该退出并销毁 | + +松开 PTT 时通常只把 `uplink_recording_active` 设为 false,采集任务仍然存在,只是不再读取 Codec。 + +只有音频模块反初始化时,才会把 `uplink_task_exit_requested` 设为 true,使循环结束并真正删除任务。 + +因此任务生命周期是: + +```text +创建任务 + ↓ +等待开始录音 + ↓ +连续采集和编码 + ↓ +停止录音,重新等待 + ↓ +下一轮再次采集 + ↓ +音频反初始化 + ↓ +任务退出 +``` + +这种设计避免每一轮对话都重新创建任务和分配栈内存。 + +### 7.7 未录音时等待通知 + +```c +if (!audio_get_uplink_recording_active(ctx)) { + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)); + continue; +} +``` + +当录音没有激活时,任务通过 FreeRTOS Task Notification 进入阻塞等待。 + +`pdTRUE` 表示收到通知后清空通知计数。 + +`pdMS_TO_TICKS(100)` 表示最多等待 100ms。 + +开始上行时,`multimodal_audio_start_uplink()` 会执行: + +```c +xTaskNotifyGive(capture_task); +``` + +所以任务通常会被立即唤醒,不需要等满 100ms。 + +等待仍然设置 100ms 超时,是为了让任务即使没有收到通知,也能定期返回循环顶部检查 `uplink_task_exit_requested`。如果永久阻塞,反初始化阶段可能无法及时结束该任务。 + +### 7.8 每次采集前检查运行资源 + +```c +if (!ctx->initialized || !ctx->codec_dev || !ctx->opus_enc) { + vTaskDelay(pdMS_TO_TICKS(20)); + continue; +} +``` + +任务启动时资源有效,不代表运行过程中始终有效。音频模块可能正在反初始化、恢复或者切换状态。因此每轮采集前再次检查: + +- 音频管线是否初始化; +- Codec 句柄是否有效; +- Opus 编码器是否有效。 + +暂时无效时等待 20ms 再试。延迟可以避免异常状态下形成高速空循环并占满 CPU。 + +### 7.9 从 Codec 读取 PCM + +```c +int ret = esp_codec_dev_read( + ctx->codec_dev, + ctx->uplink_codec_buf, + (int)ctx->uplink_codec_read_bytes); +``` + +数据路径为: + +```text +麦克风模拟信号 + ↓ +ES8311 ADC + ↓ +I2S RX / DMA + ↓ +esp_codec_dev_read() + ↓ +uplink_codec_buf +``` + +`esp_codec_dev_read()` 通常是阻塞式读取。它等待底层 I2S/DMA 提供指定长度的数据,因此这个调用本身就按照音频采样时钟控制循环速度。 + +代码不需要在成功读取后额外等待 60ms。如果再增加固定延迟,采集速度可能低于真实音频产生速度,从而导致底层数据积压或丢失。 + +读取失败时: + +```c +if (ret != ESP_CODEC_DEV_OK) { + ESP_LOGW(TAG, "Mic read failed: 0x%x", ret); + vTaskDelay(pdMS_TO_TICKS(20)); + continue; +} +``` + +当前帧被放弃,不会对无效数据进行编码。任务等待 20ms 后重试,而不是直接退出。 + +### 7.10 当前配置下一帧有多大 + +工程对语音格式有编译期约束: + +```text +采样率:16000 Hz +位深:16 bit +通道:单声道 +Opus 帧长:60 ms +Opus 码率:24 kbps +``` + +每个 60ms 帧包含: + +```text +16000 × 0.060 = 960 个采样点 +``` + +每个采样点为一个 `int16_t`,即 2 字节: + +```text +960 × 2 = 1920 字节 PCM +``` + +所以当前正常值是: + +```text +uplink_frames = 960 +uplink_mono_bytes = 1920 +uplink_codec_read_bytes = 1920(当前为单通道) +``` + +如果底层 Codec 返回双通道交错 PCM,则同样的 960 个时间采样点需要读取: + +```text +960 × 2字节 × 2通道 = 3840 字节 +``` + +但提取后的单声道数据仍然是 1920 字节。 + +### 7.11 计算 PCM 帧数 + +```c +size_t uplink_frames = ctx->uplink_mono_bytes / sizeof(int16_t); +``` + +这里的 `uplink_frames` 表示 PCM 采样时刻数量。当前为: + +```text +1920 / 2 = 960 +``` + +需要区分三个不同层次的“帧”: + +- PCM frame:一个采样时刻,单声道时包含一个样本; +- Opus frame:当前包含 960 个 PCM 样本,也就是 60ms 音频; +- WebSocket frame:承载一个 Opus 包的二进制消息。 + +### 7.12 提取麦克风通道 + +```c +extract_mic_channel_16( + (const int16_t *)ctx->uplink_codec_buf, + ctx->fs.channel, + ctx->mic_capture_channel, + ctx->uplink_mono_buf, + uplink_frames); +``` + +`uplink_codec_buf` 的类型是 `uint8_t *`,但里面保存的是 16-bit PCM,所以先转换为 `const int16_t *`。 + +参数含义如下: + +| 参数 | 含义 | +| --- | --- | +| `uplink_codec_buf` | 原始交错 PCM | +| `fs.channel` | 原始 PCM 通道数 | +| `mic_capture_channel` | 选择哪个通道作为麦克风 | +| `uplink_mono_buf` | 单声道输出 Buffer | +| `uplink_frames` | 要提取的采样点数量 | + +假设原始数据为双通道交错格式: + +```text +ch0_0, ch1_0, ch0_1, ch1_1, ch0_2, ch1_2 ... +``` + +选择通道 0 后输出: + +```text +ch0_0, ch0_1, ch0_2 ... +``` + +选择通道 1 后输出: + +```text +ch1_0, ch1_1, ch1_2 ... +``` + +当前工程强制单声道,因此最终通常选择通道 0。保留通道抽取层可以让 Opus 编码器始终面对统一的连续单声道 PCM,而不关心底层 Codec 数据如何排列。 + +### 7.13 PCM 调试导出 + +```c +audio_debug_dump_pcm(ctx->uplink_mono_buf, ctx->uplink_mono_bytes); +``` + +该函数位于通道提取之后、Opus 编码之前,用于调试最终准备上传的 PCM。 + +它可以帮助判断: + +- 麦克风是否真正采到声音; +- 麦克风通道是否选择正确; +- 输入音量是否过低; +- 是否存在削波、底噪或直流偏置; +- 故障发生在硬件采集阶段,还是 Opus/网络阶段。 + +调试功能关闭时,该调用近似为空操作,不参与正式业务数据传输。 + +### 7.14 PCM 编码为 Opus 并入队 + +```c +esp_err_t err = encode_and_queue_mono_pcm( + ctx, + ctx->uplink_mono_buf, + ctx->uplink_mono_bytes); +``` + +该函数包含两个连续步骤: + +```text +单声道 PCM + ↓ esp_opus_enc_process() +Opus 包 + ↓ xQueueSend() +uplink_send_queue +``` + +Opus 编码核心为: + +```c +esp_opus_enc_process(ctx->opus_enc, &in_frame, &out_frame); +``` + +输入为固定的 1920 字节 PCM,输出为压缩后的 Opus 包。按照 24kbps、60ms 计算,理论平均大小约为: + +```text +24000 bit/s × 0.060s = 1440 bit +1440 / 8 = 180 字节 +``` + +实际包大小仍可能受到编码参数和音频内容影响。 + +编码成功后,代码把 Opus 数据复制到独立队列元素: + +```c +uplink_packet_t packet = { + .len = (uint16_t)out_frame.encoded_bytes, +}; +memcpy(packet.data, ctx->uplink_opus_buf, packet.len); +``` + +必须复制,不能只把 `uplink_opus_buf` 指针交给队列。因为该 Buffer 会被下一轮编码复用,如果发送任务还没有处理上一包,数据就会被覆盖。 + +### 7.15 队列满时为什么丢旧包 + +如果队列无法及时接收最新包,代码会尝试先移除最旧包,再放入最新包。 + +其策略是: + +```text +优先保持低延迟,而不是保证每个历史音频包都送达。 +``` + +对于实时对话,如果网络持续变慢而所有旧包都保留,排队延迟会不断增加,最后出现“用户已经说完很久,服务端仍在接收旧语音”的情况。 + +丢掉一个旧的 60ms 包可能造成短暂语音损失,但能够阻止延迟无限增长。该取舍更符合实时对话场景。 + +### 7.16 记录输入电平和队列状态 + +```c +maybe_log_uplink_levels(...); +``` + +这一步可以统计: + +- Codec 原始通道的平均绝对幅度和峰值; +- 提取后单声道 PCM 的平均绝对幅度和峰值; +- 已入队包数和字节数; +- 队列丢包数。 + +典型判断方式如下: + +| 现象 | 可能问题 | +| --- | --- | +| 原始通道和单声道都接近 0 | 麦克风、Codec 输入、增益或 I2S RX 问题 | +| 原始通道正常,单声道接近 0 | 通道选择或通道抽取问题 | +| PCM 电平正常,但队列持续丢包 | WebSocket 发送速度不足或网络拥塞 | +| PCM 和队列都正常,但服务端无声音 | 会话门禁、WebSocket 二进制发送或服务端协议问题 | + +### 7.17 错误分类 + +```c +if (err != ESP_OK && err != ESP_ERR_INVALID_SIZE && err != ESP_ERR_TIMEOUT) { +``` + +`ESP_ERR_INVALID_SIZE` 通常表示编码后的 Opus 包超过允许的最大尺寸,内部已经记录了专门日志。 + +`ESP_ERR_TIMEOUT` 通常表示上行发送队列拥塞,内部也有丢包统计和限频日志。 + +这两类错误不在外层重复打印,避免实时循环每 60ms 刷一条日志,进一步影响系统性能。 + +对于真正的编码异常,外层记录警告并等待 10ms,避免持续失败时形成高 CPU 占用的忙循环。 + +### 7.18 任务退出清理 + +循环结束后执行: + +```c +audio_set_uplink_capture_task(ctx, NULL); +``` + +清空任务句柄,使其他生命周期代码准确知道任务已经退出。 + +随后记录: + +```c +uxTaskGetStackHighWaterMark(NULL) +``` + +它表示任务运行历史中栈剩余空间的最低值,可用于判断栈配置: + +- 数值过小:任务曾经接近栈溢出; +- 数值很大:任务栈可能配置过多。 + +最后: + +```c +delete_audio_task_with_caps(NULL); +``` + +删除当前任务并释放创建任务时使用的相关资源。这里传入 `NULL` 表示删除当前正在运行的任务。 + +## 8. `uplink_send_task()` 如何发送 Opus + +发送任务循环等待队列: + +```c +xQueueReceive(ctx->uplink_send_queue, &packet, pdMS_TO_TICKS(100)); +``` + +取到有效包后,快照保存好的回调: + +```c +audio_snapshot_uplink_callback(ctx, &cb, &cb_ctx); +``` + +然后调用: + +```c +esp_err_t send_err = cb(packet.data, packet.len, cb_ctx); +``` + +当前回调为: + +```c +control_plane_audio_uplink_send_cb() +``` + +它继续调用: + +```c +control_plane_multimodal_send_audio_realtime(data, len); +``` + +## 9. 每个 Opus 包发送前的协议门禁 + +Control Plane 不会因为采集任务已经启动,就无条件发送所有队列数据。每个包发送前都会检查: + +```c +allow_uplink = s_task_id[0] != '\0' && + s_server_listening && + s_client_speech_active && + !s_ptt_release_required && + !s_remote_responding && + !s_drop_downstream_audio; +``` + +具体含义: + +| 条件 | 含义 | +| --- | --- | +| `task_id` 非空 | 存在活动多模态 Task | +| `server_listening` | 服务端允许接收语音 | +| `client_speech_active` | 本地处于 SendSpeech 生命周期内 | +| `!ptt_release_required` | 服务端没有要求用户先完成松键动作 | +| `!remote_responding` | 服务端没有正在输出 TTS | +| `!drop_downstream_audio` | 当前没有处于插话/丢弃旧响应状态 | + +任何条件不满足,当前包都会返回 `ESP_ERR_INVALID_STATE`,不会通过 WebSocket 发送。 + +这种逐包检查非常重要,因为音频包在队列中等待期间,会话状态可能已经发生变化。例如服务端突然进入 Responding,此时队列里尚未发送的麦克风数据不能继续上传。 + +## 10. WebSocket 二进制发送 + +调用链如下: + +```text +control_plane_multimodal_send_audio_realtime() + ↓ +domain_runtime_multimodal_transport_send_binary_realtime() + ↓ +platform_multimodal_transport_send_binary_realtime() + ↓ +multimodal_ws_send_binary_realtime() + ↓ +websocket_send_binary_internal() + ↓ +esp_websocket_client_send_bin() +``` + +实际发送 API: + +```c +esp_websocket_client_send_bin( + client, + (const char *)data, + (int)len, + send_timeout); +``` + +发送前需要取得 WebSocket 发送锁,确保文本指令和二进制数据不会并发破坏底层客户端状态。 + +实时二进制发送策略只尝试一次,不在音频线程里进行长时间重试。原因是旧音频包即使稍后重发,也可能已经失去实时意义。 + +WebSocket Binary 的 payload 是原始 Opus 包,不使用 RTP 封装。 + +## 11. 停止上行流程 + +用户松开 PTT 后,Control Plane 执行: + +```c +control_plane_multimodal_stop_uplink(); +control_plane_multimodal_send_speech_stop(); +``` + +### 11.1 停止产生新包 + +平台层首先执行: + +```c +audio_set_uplink_recording_active(&s_audio, false); +``` + +采集任务下次循环时进入通知等待,不再读取 Codec,也不再产生新的 Opus 包。 + +### 11.2 等待队列排空 + +停止函数会在给定超时内等待: + +- `uplink_send_queue` 为空; +- 没有正在发送的包。 + +这样可以尽量保证用户松键之前已经采集完成的最后几帧不会立即丢失。 + +如果超时仍未排空,代码记录警告并清空剩余队列,避免停止流程无限阻塞。 + +### 11.3 清除发送回调 + +```c +audio_set_uplink_callback(&s_audio, NULL, NULL); +``` + +这可以阻止旧队列数据在下一轮会话中误用上一轮发送上下文。 + +### 11.4 发送 StopSpeech + +本地停止采集后,Control Plane 向服务端发送: + +```c +send_directive("continue-task", "StopSpeech", true, false); +``` + +服务端据此结束本轮语音输入,并继续执行 ASR、LLM 和 TTS。 + +## 12. 其他会触发停止上行的情况 + +除了用户松开 PTT,以下情况也会停止麦克风上行: + +- WebSocket 断开; +- WebSocket 发生错误; +- 服务端 Task 失败; +- 服务端退出 Listening; +- 收到 `SpeechEnded`; +- 服务端进入 `RespondingStarted`; +- 服务端返回协议 `Error`; +- 无下行响应看门狗超时; +- 用户取消本轮 Speech。 + +其中服务端开始 Responding 时停止上行,说明当前默认流程偏向半双工:设备播放服务端 TTS 时不继续上传麦克风音频。 + +## 13. 一帧上行音频的完整生命周期 + +以当前默认配置为例: + +```text +时间范围:60ms + +1. ES8311 以 16kHz 采集 + 产生 960 个采样点 + +2. 每个采样点为 int16_t + 原始单声道 PCM 大小为 1920 字节 + +3. esp_codec_dev_read() + 将 PCM 读取到 uplink_codec_buf + +4. extract_mic_channel_16() + 产生 1920 字节 uplink_mono_buf + +5. esp_opus_enc_process() + 压缩为大约 180 字节的 Opus 包 + +6. 复制到 uplink_packet_t + 写入 uplink_send_queue + +7. uplink_send_task 取出包 + 调用 Control Plane 回调 + +8. Control Plane 检查本包是否仍允许发送 + +9. esp_websocket_client_send_bin() + 发送一个 WebSocket Binary 消息 +``` + +在录音持续期间,上述过程约每 60ms 重复一次。 + +## 14. 常见问题定位 + +### 14.1 按键后完全没有采集日志 + +重点检查: + +- WebSocket 是否已连接; +- Task 是否创建成功; +- 是否收到 Listening; +- `SendSpeech` 是否成功; +- `client_speech_active` 是否为 true; +- `multimodal_audio_start_uplink()` 是否执行; +- 采集任务和队列是否创建成功。 + +### 14.2 有采集日志,但 PCM 电平为 0 + +重点检查: + +- ES8311 ADC 输入; +- I2S RX 配置; +- 麦克风偏置和硬件连接; +- Codec 输入增益; +- `mic_capture_channel`; +- 原始各通道电平。 + +### 14.3 PCM 电平正常,但没有 Opus 入队 + +重点检查: + +- `opus_enc` 是否有效; +- `uplink_mono_bytes` 是否匹配编码器输入要求; +- `esp_opus_enc_process()` 返回值; +- Opus 输出是否超过最大包限制; +- 上行队列是否持续满载。 + +### 14.4 Opus 已入队,但服务端听不到 + +重点检查: + +- `uplink_send_task` 是否运行; +- 每包发送前的 `allow_uplink` 条件; +- WebSocket 发送锁和发送超时; +- `esp_websocket_client_send_bin()` 返回长度; +- 服务端是否仍处于 Listening; +- 是否意外进入 `remote_responding` 或 `ptt_release_required`。 + +### 14.5 语音延迟越来越大 + +重点检查: + +- `uplink_send_queue` 深度; +- 队列溢出和旧包替换日志; +- Wi-Fi 信号和 TCP 发送拥塞; +- WebSocket 实时发送耗时; +- 是否有其他高优先级任务长时间占用 CPU; +- TLS、LCD 或其他 DMA 用户是否影响音频实时性。 + +## 15. 核心理解总结 + +`uplink_capture_task()` 不是一次性的“录音函数”,而是一个长期存在的 FreeRTOS 生产者任务。 + +它只负责: + +```text +读取 PCM → 提取麦克风通道 → Opus 编码 → 放入发送队列 +``` + +它不负责: + +- 创建多模态 Task; +- 判断服务端是否 Listening; +- 发送 `SendSpeech` 或 `StopSpeech`; +- 直接操作 WebSocket; +- 处理服务端 ASR 结果。 + +会话合法性由 Control Plane 管理,硬件采集和编码由 Platform 管理,网络发送由独立发送任务完成。三部分通过运行状态、回调和队列连接,从而在保证协议正确性的同时,降低网络波动对实时音频采集的影响。 diff --git a/docs/knowledge vault/websocket-and-audio-flow.md b/docs/knowledge vault/websocket-and-audio-flow.md new file mode 100644 index 0000000..2a85aab --- /dev/null +++ b/docs/knowledge vault/websocket-and-audio-flow.md @@ -0,0 +1,1033 @@ +# WebSocket 与音频上下行实现说明 + +> 本文描述 `feature/st7789p3-new-board` 分支、提交 `07219e6` 附近的实际实现。参数取自当前 `sdkconfig`。`sdkconfig.defaults` 是新建配置时的默认值,二者不一致时,应以本次构建实际使用的 `sdkconfig` 为准。 + +## 1. 文档目的 + +本项目通过一个全双工 WebSocket 会话连接云端多模态服务。设备端主要完成以下工作: + +1. 获取设备身份和临时访问 Token。 +2. 建立带 TLS 的 WebSocket 连接。 +3. 通过 WebSocket 文本帧发送会话控制指令。 +4. 采集麦克风 PCM,并编码成 raw Opus。 +5. 通过 WebSocket 二进制帧上传 Opus 音频。 +6. 接收服务端 ASR、对话和 TTS 状态事件。 +7. 接收 WebSocket 二进制音频,解码 Opus 并通过 ES8311 播放。 + +需要首先理解的一点是: + +- ASR、LLM 和 TTS 都在云端完成。 +- ESP32-S3 不运行本地 ASR 或大模型。 +- 设备端负责会话状态管理、音频采集/编解码、数据传输和播放。 + +## 2. 分层和主要文件 + +项目依赖方向为: + +```text +app_composition -> control_plane -> domain -> platform +``` + +WebSocket 与音频链路对应关系如下: + +| 层 | 主要职责 | 关键文件 | +| --- | --- | --- | +| `control_plane` | 鉴权、会话状态机、PTT 策略、协议 JSON、错误恢复 | `components/control_plane/src/multimodal/control_plane_multimodal_auth_service.c` | +| `control_plane` | Start/Stop/SendSpeech、服务端事件解析、音频回调桥接 | `components/control_plane/src/multimodal/control_plane_multimodal_session_service.c` | +| `control_plane` | PTT 会话推进、心跳、超时、上下行桥接 | `components/control_plane/src/multimodal/control_plane_multimodal_runtime.c` | +| `control_plane` | PTT 状态机执行 | `components/control_plane/src/runtime/control_plane_runtime_workers.c` | +| `domain` | 对上层屏蔽 platform 类型,完成契约映射 | `components/domain/src/runtime/domain_runtime_adapters.c` | +| `platform` | ESP WebSocket Client、收发锁、消息队列、分片重组 | `components/platform/src/multimodal/transport_ws.c` 及其子目录 | +| `platform` | ES8311、I2S、Opus 编解码、音频任务与队列 | `components/platform/src/multimodal/audio_pipeline.c` 及其子目录 | + +## 3. 总体链路 + +```mermaid +sequenceDiagram + participant Key as PTT 按键 + participant CP as Control Plane + participant Auth as Token 服务 + participant WS as WebSocket Transport + participant Cloud as 云端多模态服务 + participant Mic as ES8311/I2S 麦克风 + participant Speaker as ES8311/I2S 扬声器 + + Key->>CP: 单击,开始一次 click-to-talk + CP->>Auth: 获取 voice session Token + Auth-->>CP: token/workspace_id/app_id + CP->>WS: 建立 WSS,携带 Bearer 和 Workspace + WS-->>CP: Connected + CP->>Cloud: 文本帧 Start + Cloud-->>CP: DialogStateChanged(Listening) + CP->>Cloud: 文本帧 SendSpeech + CP->>Mic: 启动上行采集 + loop 每个 60 ms 音频帧 + Mic-->>CP: 16 kHz/16 bit/mono PCM + CP->>CP: Opus 编码并进入发送队列 + CP->>Cloud: 二进制帧 raw Opus + end + Cloud-->>CP: SpeechStarted + Cloud-->>CP: SpeechContent(ASR 文本) + CP->>Cloud: 文本帧 StopSpeech + Cloud-->>CP: SpeechEnded + Cloud-->>CP: RespondingStarted + loop TTS 流式音频 + Cloud-->>WS: 二进制帧 raw Opus + WS-->>CP: 下行音频回调 + CP->>CP: Opus 解码为 PCM + CP->>Speaker: I2S 播放 + end + Cloud-->>CP: RespondingEnded + CP->>Cloud: LocalRespondingEnded +``` + +## 4. 当前关键配置参数 + +### 4.1 服务地址和鉴权 + +| 参数 | 当前值 | 含义 | +| --- | --- | --- | +| `CONFIG_ENABLE_MULTIMODAL_INTERACTION` | `y` | 启用多模态交互 | +| `CONFIG_MULTIMODAL_USE_STATIC_CREDENTIALS` | 未启用 | 不直接使用静态 API Key | +| `CONFIG_MULTIMODAL_AUTH_BYPASS_TOKEN_SERVICE` | 未启用 | 不绕过设备 Token 服务 | +| `CONFIG_AUTH_V2_ENABLE` | `y` | 获取业务 Token 时使用设备 JWT 鉴权 | +| `CONFIG_MULTIMODAL_DEVICE_TOKEN_URL` | `https://gz.api.talkingq.com/token/api/device/token` | 设备业务 Token 接口 | +| `CONFIG_MULTIMODAL_WS_URI` | `wss://dashscope.aliyuncs.com/api-ws/v1/inference` | 多模态 WebSocket 地址 | +| `CONFIG_MULTIMODAL_CONNECT_TIMEOUT_MS` | `10000` | 建连网络超时,10 秒 | +| `CONFIG_MULTIMODAL_RECONNECT_DELAY_MS` | `2000` | WebSocket 自动重连间隔,2 秒 | + +Token、JWT、Wi-Fi 密码等敏感信息不应写入文档、日志或提交到 Git。 + +### 4.2 WebSocket 参数 + +| 参数 | 当前值 | 含义 | +| --- | ---: | --- | +| `CONFIG_MULTIMODAL_WEBSOCKET_TASK_STACK_BYTES` | 32768 B | ESP WebSocket Client 任务栈目标值 | +| `CONFIG_MULTIMODAL_WEBSOCKET_TASK_PRIORITY` | 8 | WebSocket Client 任务优先级 | +| `CONFIG_MULTIMODAL_WS_BUFFER_SIZE` | 2048 B | WebSocket Client 收发缓冲尺寸 | +| `CONFIG_MULTIMODAL_WS_PING_INTERVAL_SEC` | 20 s | WebSocket Ping 周期 | +| `CONFIG_MULTIMODAL_WS_PINGPONG_TIMEOUT_SEC` | 120 s | Ping/Pong 超时 | +| `CONFIG_MULTIMODAL_WS_ENABLE_TCP_KEEPALIVE` | `y` | 启用 TCP Keepalive | +| `CONFIG_MULTIMODAL_WS_TCP_KEEPALIVE_IDLE_SEC` | 30 s | TCP 空闲多久后开始探测 | +| `CONFIG_MULTIMODAL_WS_TCP_KEEPALIVE_INTERVAL_SEC` | 10 s | TCP Keepalive 探测间隔 | +| `CONFIG_MULTIMODAL_WS_TCP_KEEPALIVE_COUNT` | 3 | 连续失败次数 | +| `CONFIG_MULTIMODAL_WS_REALTIME_LOCK_WAIT_MS` | 80 ms | 实时发送等待共用发送锁的上限 | +| `CONFIG_MULTIMODAL_WS_REALTIME_SEND_TIMEOUT_MS` | 300 ms | 实时发送单次超时 | +| `CONFIG_MULTIMODAL_WS_DISPATCH_TASK_STACK_BYTES` | 4096 B | 有序收包 worker 的基础栈要求 | +| `CONFIG_MULTIMODAL_WS_JSON_TASK_STACK_BYTES` | 8192 B | 文本处理所需栈配置 | +| `CONFIG_MULTIMODAL_WS_OPUS_TASK_STACK_BYTES` | 12288 B | 二进制音频处理所需栈配置 | +| `CONFIG_MULTIMODAL_WS_MSG_QUEUE_LEN` | 512 | WebSocket 入站消息队列深度 | +| `CONFIG_TQ_NETWORK_TASK_CORE` | 0 | WebSocket 有序收包 worker 固定在 CPU Core 0 | + +当前实现不是分别创建 dispatch、JSON、Opus 三个任务,而是取上述三个栈配置中的最大值,创建一个保持消息顺序的 `mm_ws_ordered` worker。因此当前有序 worker的实际目标栈是 12288 B。 + +代码中的固定限制: + +| 限制 | 数值 | 说明 | +| --- | ---: | --- | +| 单个收到的 WebSocket 数据片段 | 4096 B | 超过 `MULTIMODAL_WS_MSG_MAX_BYTES` 的片段直接丢弃 | +| 文本消息重组上限 | 32768 B | 允许一个完整文本 payload 被拆成多个片段 | +| 二进制消息重组上限 | 4096 B | 完整下行二进制 payload 超过该值会丢弃 | +| 普通文本发送超时 | 1200 ms | 非实时控制指令发送超时 | +| 普通文本发送重试 | 最多 4 次 | 仅对 `WANT_READ/WANT_WRITE/EAGAIN` 等可重试错误 | +| 停止 WS worker 等待 | 1200 ms | 有界停止,超时后进入清理兜底 | + +### 4.3 上行音频参数 + +| 参数 | 当前值 | 含义 | +| --- | ---: | --- | +| `CONFIG_MULTIMODAL_UPSTREAM_MODE` | `push2talk` | 由客户端显式发送 `SendSpeech/StopSpeech` | +| `CONFIG_MULTIMODAL_AUDIO_CODEC_SAMPLE_RATE` | 16000 Hz | 采样率 | +| `CONFIG_MULTIMODAL_AUDIO_CODEC_CHANNELS` | 1 | 单声道 | +| `CONFIG_MULTIMODAL_AUDIO_CODEC_BITS_PER_SAMPLE` | 16 bit | PCM 位深 | +| `CONFIG_MULTIMODAL_AUDIO_CHUNK_SIZE` | 1920 B | 60 ms 单声道 PCM 数据量 | +| `CONFIG_MULTIMODAL_OPUS_FRAME_DURATION_MS` | 60 ms | 每个 Opus 帧的时长 | +| `CONFIG_MULTIMODAL_OPUS_BITRATE_KBPS` | 24 kbit/s | Opus 目标码率 | +| `CONFIG_MULTIMODAL_OPUS_COMPLEXITY` | 0 | Opus 编码复杂度,优先降低 MCU 负载 | +| `CONFIG_MULTIMODAL_AUDIO_IN_GAIN_DB` | 30 dB | ES8311 输入增益 | +| `CONFIG_MULTIMODAL_AUDIO_MIC_CHANNEL` | 0 | 从 codec 数据中选取的麦克风通道 | +| `CONFIG_MULTIMODAL_AUDIO_UPLINK_TASK_STACK` | 32768 B | 采集/编码任务栈 | +| `CONFIG_MULTIMODAL_AUDIO_UPLINK_SEND_TASK_STACK` | 12288 B | 上传发送任务栈 | +| `CONFIG_MULTIMODAL_AUDIO_UPLINK_SEND_QUEUE_LEN` | 24 | Opus 上行队列容量 | +| `CONFIG_MULTIMODAL_AUDIO_UPLINK_SEND_TIMEOUT_MS` | 5 ms | 编码任务向上行队列投递的等待时间 | +| `CONFIG_MULTIMODAL_AUDIO_UPLINK_MAX_PACKET_BYTES` | 1024 B | 单个上行 Opus 包最大尺寸 | +| `CONFIG_MULTIMODAL_AUDIO_PREWARM_OPUS_BEFORE_WS` | `y` | 建立 WS 前预热 Opus,降低开始对话时的内存压力和延迟 | +| `CONFIG_MULTIMODAL_AUDIO_TASK_CORE` | 1 | 音频任务固定在 CPU Core 1 | + +由这些参数可以推导: + +```text +每帧采样点 = 16000 × 60 / 1000 = 960 samples +每帧 PCM = 960 × 16 / 8 × 1 = 1920 bytes +PCM 裸码率 = 16000 × 16 × 1 = 256 kbit/s = 32 kB/s +Opus 目标包大小约 = 24 kbit/s × 60 ms / 8 = 180 bytes/frame +每秒 Opus 帧数约 = 1000 / 60 = 16.67 frame/s +24 格上行队列对应约 = 24 × 60 ms = 1.44 s 音频 +``` + +180 B 是按目标码率计算的平均值,不代表每个实际 Opus 包都固定为 180 B。 + +### 4.4 下行音频参数 + +| 参数 | 当前值 | 含义 | +| --- | ---: | --- | +| 下行协议格式 | `raw-opus` | 服务端通过 WebSocket 二进制帧发送 | +| 下行采样率 | 16000 Hz | Start 中与 codec 采样率保持一致 | +| 下行帧时长 | 60 ms | Start 的 `frame_size` | +| 下行码率 | 24 kbit/s | Start 的 `bit_rate` | +| `CONFIG_MULTIMODAL_DOWNSTREAM_RATE_LIMIT` | 3000 B/s | 请求服务端限制下发速率,约等于 24 kbit/s | +| `CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_MAX_PACKET_BYTES` | 2048 B | 单个播放队列音频包最大尺寸 | +| `CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_QUEUE_LEN` | 1024 | 下行播放队列最大包数 | +| `CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_TASK_STACK` | 12288 B | 解码/播放任务栈 | +| `CONFIG_MULTIMODAL_AUDIO_OUT_VOLUME` | 60 | ES8311 输出音量配置 | +| `CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_IDLE_MUTE_MS` | 250 ms | 最后一个音频包后延时静音,减少爆音 | +| 下行积压告警阈值 | 5000 ms | 估算播放队列积压超过 5 秒时记录告警 | + +理论上,1024 个 60 ms 包可对应约 61.44 秒音频。实际每个 WebSocket 二进制消息不一定严格等于一个 60 ms Opus 帧,因此这是容量估算,不是协议保证。 + +### 4.5 I2S、I2C 和功放引脚 + +| 功能 | 当前值 | +| --- | ---: | +| I2C Port | 0 | +| ES8311 I2C 地址 | `0x18` | +| I2C SCL | GPIO2 | +| I2C SDA | GPIO1 | +| I2S Port | 0 | +| I2S MCLK | GPIO38 | +| I2S BCLK | GPIO39 | +| I2S WS/LRCK | GPIO41 | +| I2S DOUT,ESP32 到 ES8311 | GPIO40 | +| I2S DIN,ES8311 到 ESP32 | GPIO42 | +| 功放 PA 控制 | GPIO11 | +| I2S DMA descriptor 数量 | 4 | +| 每个 I2S DMA descriptor 帧数 | 160 | + +## 5. 鉴权和 WebSocket 建连 + +### 5.1 当前鉴权模式 + +当前不是静态 API Key 模式,而是设备身份模式: + +```text +设备身份/序列号 + -> AUTH_V2 challenge/response + -> 获取或刷新 device JWT + -> POST /token/api/device/token + -> 得到 token、workspace_id、app_id、voice_id + -> 使用 token 建立 DashScope WSS +``` + +入口函数: + +```c +control_plane_multimodal_auth_fetch() +``` + +当前 `CONFIG_AUTH_V2_ENABLE=y`,Token 请求使用设备 JWT 作为 Bearer。若 Token 接口返回 401 或 403,代码会使缓存 JWT 失效,强制刷新后再重试一次。 + +Token 接口请求体包含可用的设备身份字段: + +```json +{ + "deviceId": "...", + "serialNumber": "..." +} +``` + +返回结果会解析为: + +```c +control_plane_multimodal_auth_info_t { + token; + workspace_id; + app_id; + voice_id; + device_id; + serial_number; + valid; +} +``` + +### 5.2 启动调用链 + +```text +control_plane_start_multimodal_client() + -> control_plane_prepare_for_multimodal_start() + -> 注册 control_plane_multimodal_event_bridge() + -> 注册 control_plane_downstream_audio_bridge() + -> 创建 multimodal_watchdog_task + -> control_plane_multimodal_start() + -> control_plane_multimodal_auth_fetch() + -> 注册 downstream_playback_event_bridge() + -> domain_runtime_multimodal_transport_start() + -> platform_multimodal_transport_start() + -> multimodal_ws_start() +``` + +### 5.3 WebSocket 握手头 + +`multimodal_ws_start()` 构造: + +```http +Authorization: Bearer +X-DashScope-WorkSpace: +``` + +`X-DashScope-WorkSpace` 很重要。代码中特别说明:只在 Start JSON 中携带 `workspace_id` 不一定能选择正确工作空间,握手阶段也要带这个 Header,否则可能返回 `Invalid workspace_id`。 + +TLS 使用 ESP-IDF 的证书 Bundle: + +```c +.crt_bundle_attach = esp_crt_bundle_attach +``` + +### 5.4 WebSocket Client 配置 + +底层由 `esp_websocket_client` 实现,关键配置如下: + +```c +esp_websocket_client_config_t cfg = { + .uri = CONFIG_MULTIMODAL_WS_URI, + .headers = s_headers, + .network_timeout_ms = 10000, + .reconnect_timeout_ms = 2000, + .disable_auto_reconnect = false, + .enable_close_reconnect = true, + .task_stack = 32768, + .task_prio = 8, + .buffer_size = 2048, + .ping_interval_sec = 20, + .pingpong_timeout_sec = 120, + .keep_alive_enable = true, + .keep_alive_idle = 30, + .keep_alive_interval = 10, + .keep_alive_count = 3, + .crt_bundle_attach = esp_crt_bundle_attach, +}; +``` + +如果目标 WebSocket 任务栈创建失败,代码会根据可用内部内存尝试多个栈候选值。因为 ESP WebSocket Client 的原生任务栈主要占用内部内存,所以日志中的 `largest_internal` 对排查建连失败很重要。 + +## 6. WebSocket 上承载的两类数据 + +### 6.1 文本帧:协议控制和状态事件 + +客户端发送的 JSON 文本帧包括: + +- `Start` +- `Stop` +- `SendSpeech` +- `StopSpeech` +- `CancelSpeech` +- `RequestToSpeak` +- `RequestToRespond` +- `UpdateInfo` +- `LocalRespondingStarted` +- `LocalRespondingEnded` +- `HeartBeat` + +服务端返回的文本事件包括: + +- `task-started` +- `task-failed` +- `Started` +- `DialogStateChanged` +- `RequestAccepted` +- `SpeechStarted` +- `SpeechContent` +- `SpeechEnded` +- `RespondingStarted` +- `RespondingContent` +- `RespondingEnded` +- `Stopped` +- `Error` + +### 6.2 二进制帧:raw Opus 音频 + +二进制方向由当前会话状态决定: + +- 设备到服务端:麦克风 raw Opus,上行 ASR 音频。 +- 服务端到设备:TTS raw Opus,下行播放音频。 + +二进制帧没有在设备端附加自定义包头。音频格式、采样率、帧长和码率通过最初的 `Start` 参数约定。 + +## 7. Start 消息 + +`control_plane_multimodal_send_start()` 生成 UUID 作为 `task_id`,然后发送类似: + +```json +{ + "header": { + "action": "run-task", + "task_id": "", + "streaming": "duplex" + }, + "payload": { + "task_group": "aigc", + "task": "multimodal-generation", + "function": "generation", + "model": "multimodal-dialog", + "input": { + "directive": "Start", + "workspace_id": "", + "app_id": "" + }, + "parameters": { + "upstream": { + "type": "AudioOnly", + "mode": "push2talk", + "audio_format": "raw-opus", + "sample_rate": 16000 + }, + "downstream": { + "audio_format": "raw-opus", + "sample_rate": 16000, + "frame_size": 60, + "bit_rate": 24, + "speech_rate": 110, + "incremental_response": false, + "intermediate_text": "transcript,dialog", + "transmit_rate_limit": 3000, + "voice": "" + }, + "client_info": { + "user_id": "", + "device": { + "uuid": "" + } + } + } + } +} +``` + +其中 `voice` 只有在解析到有效 voice ID 时才添加。 + +`Start` 发送成功只代表客户端数据成功交给 WebSocket,不代表服务端已经可以接收音频。必须继续等待 `DialogStateChanged(Listening)`。 + +## 8. 通用 Directive 格式 + +大部分会话控制指令由 `send_directive()` 统一构造: + +```json +{ + "header": { + "action": "continue-task", + "task_id": "", + "streaming": "duplex" + }, + "payload": { + "input": { + "directive": "SendSpeech", + "dialog_id": "" + } + } +} +``` + +规则: + +- 没有有效 `task_id` 时拒绝发送。 +- `dialog_id` 只有非空时才加入 JSON。 +- `Stop` 使用 `action=finish-task`。 +- 普通控制指令使用普通文本发送策略,可重试。 +- HeartBeat、LocalRespondingStarted/Ended 等实时指令使用较短锁等待和较短发送超时,不进行长时间重试。 + +## 9. PTT 和会话状态机 + +当前使用 click-to-talk: + +```text +CONFIG_TQ_PTT_MODE_CLICK_TO_TALK=y +CONFIG_TQ_PTT_CLICK_CAPTURE_TIMEOUT_MS=8000 +``` + +短按被确认后: + +```text +control_plane_system_events.c + -> control_plane_set_ptt_active(true) + -> 唤醒 runtime worker + -> control_plane_drive_ptt_state_machine() +``` + +状态推进如下: + +| 状态 | 含义 | 下一步 | +| --- | --- | --- | +| `PTT_STATE_IDLE` | 没有本次语音请求 | 等待短按 | +| `PTT_STATE_WAIT_WIFI` | 等待 STA 网络 | 网络可用后检查 WS | +| `PTT_STATE_WAIT_WS` | 等待 WebSocket | 触发前台连接或后台连接任务 | +| `PTT_STATE_WAIT_START_SENT` | 当前没有活跃 task | 发送 `Start` | +| `PTT_STATE_WAIT_LISTENING` | Start 已发但服务端未 Listening | 等待 `DialogStateChanged(Listening)` | +| `PTT_STATE_WAIT_SPEECH_STARTED` | 准备本轮说话 | 发送 `SendSpeech` | +| `PTT_STATE_STREAMING` | 允许麦克风上传 | 启动或保持 uplink | +| `PTT_STATE_TEARDOWN_LOCAL` | 本地停止 | 停止采集并排空队列 | +| `PTT_STATE_TEARDOWN_REMOTE` | 协议停止 | 发送 `StopSpeech` | +| `PTT_STATE_DENIED` | 等待超时或条件不满足 | 本轮拒绝 | + +相关固定超时: + +| 超时 | 数值 | 行为 | +| --- | ---: | --- | +| 等待 Listening | 3000 ms | 标记传输不可用并尝试恢复 | +| 等待 SpeechStarted 阶段 | 1500 ms | 标记传输不可用并尝试恢复 | +| PTT teardown | 2000 ms | 防止停止流程无限卡住 | +| PTT 总等待拒绝 | 10000 ms | 进入 `PTT_STATE_DENIED` | +| click-to-talk 采集 | 8000 ms | 自动结束本次输入 | +| 下行播放保护间隔 | 220 ms | 刚收到 TTS 音频时不立即启动上行,减少回声/冲突 | + +## 10. 音频上传详细流程 + +### 10.1 协议允许条件 + +服务端进入 Listening 后,`control_plane_multimodal_send_speech_start()` 发送 `SendSpeech`。成功后设置: + +```c +s_client_speech_active = true; +s_ptt_release_required = false; +s_request_to_speak_pending = false; +``` + +真正上传每个二进制包之前,`control_plane_multimodal_send_audio_realtime()` 仍会检查: + +```c +s_task_id[0] != '\0' && +s_server_listening && +s_client_speech_active && +!s_ptt_release_required && +!s_remote_responding && +!s_drop_downstream_audio +``` + +任何一个条件不满足,返回 `ESP_ERR_INVALID_STATE`,音频不会发给 WebSocket。 + +### 10.2 启动调用链 + +```text +control_plane_multimodal_handle_ptt_press_step() + -> control_plane_start_uplink_if_ready() + -> control_plane_multimodal_start_uplink() + -> domain_runtime_multimodal_audio_start_uplink() + -> platform_multimodal_audio_start_uplink() + -> multimodal_audio_start_uplink() +``` + +`multimodal_audio_start_uplink()` 不重复创建整个音频系统,而是: + +1. 注册 `control_plane_audio_uplink_send_cb()`。 +2. 清空/复位上行队列。 +3. 设置 `uplink_recording_active=true`。 +4. 通知已经创建的采集任务开始工作。 + +### 10.3 采集任务 + +`uplink_capture_task()` 的循环: + +```text +esp_codec_dev_read() + -> 从 ES8311/I2S 读取 codec PCM + -> extract_mic_channel_16() + -> 得到 16 kHz、16 bit、单声道、60 ms PCM + -> esp_opus_enc_process() + -> 得到 raw Opus + -> xQueueSend(uplink_send_queue) +``` + +采集和发送分成两个任务的原因: + +- I2S 采集需要按音频节奏持续运行。 +- TLS/WebSocket 发送可能因锁、网络抖动而短暂阻塞。 +- 分离任务可以避免网络阻塞直接打断 I2S 采集。 + +### 10.4 上行队列溢出策略 + +队列容量是 24 个 Opus 包。投递等待 5 ms 仍失败时: + +1. 尝试丢弃队列最旧的一个包。 +2. 再放入最新包。 +3. 若仍失败,则丢弃最新包并记录告警。 + +这是实时语音常用策略:旧音频已经失去实时价值,优先保留最新数据。但如果经常出现: + +```text +Mic uplink queue overflow +Mic uplink queue full, drop packet +``` + +说明网络发送速度长期低于音频生成速度,ASR 会出现缺字、断句或 NoSpeechRecognized。 + +### 10.5 发送任务和跨层回调 + +`uplink_send_task()` 从队列读取一个 Opus 包: + +```text +uplink_send_task() + -> control_plane_audio_uplink_send_cb() + -> control_plane_multimodal_send_audio_realtime() + -> domain_runtime_multimodal_transport_send_binary_realtime() + -> platform_multimodal_transport_send_binary_realtime() + -> multimodal_ws_send_binary_realtime() + -> esp_websocket_client_send_bin() +``` + +上行音频使用 WebSocket 共用发送互斥锁: + +- 等锁最多 80 ms。 +- 单次发送最多 300 ms。 +- 实时二进制包不执行普通文本指令那种多次重试。 + +这样可以避免一个过期音频包长时间占住发送链路,但会在严重网络拥塞时主动丢包。 + +### 10.6 结束上传 + +本地主动结束: + +```text +control_plane_set_ptt_active(false) + -> PTT_STATE_TEARDOWN_LOCAL + -> control_plane_multimodal_stop_uplink() + -> multimodal_audio_stop_uplink(6000 ms) + -> 停止继续采集 + -> 等待队列和 in-flight 包排空 + -> 注销 uplink callback + -> PTT_STATE_TEARDOWN_REMOTE + -> control_plane_multimodal_send_speech_stop() + -> 发送 StopSpeech +``` + +服务端也可能先返回 `SpeechEnded`。收到它时,客户端立即停止上行并设置 `ptt_release_required`,避免当前手势状态导致下一轮音频被错误上传。 + +## 11. ASR 文本事件 + +服务端识别阶段的事件: + +### 11.1 SpeechStarted + +表示服务端检测到语音起点: + +```text +ASR started +``` + +### 11.2 SpeechContent + +`text` 是从本次识别开始到当前时刻的完整累计文本,不应简单地把每次 `text` 追加到旧文本后面。 + +示例: + +```json +{"payload":{"output":{"event":"SpeechContent","text":"你好","finished":false}}} +{"payload":{"output":{"event":"SpeechContent","text":"你好世界","finished":true}}} +``` + +当前代码在 `finished=true` 时打印最终 ASR 文本,但只向上层发送一个不带 payload 的 `CONTROL_PLANE_MULTIMODAL_EVENT_SPEECH_CONTENT` 事件。也就是说: + +- 云端对话可以继续使用识别结果。 +- 本地日志可以看到最终文字。 +- 本地 UI/业务层目前拿不到 `text` 内容。 + +如果后续要在 LCD 显示用户说的话或做本地命令,应增加类似: + +```c +asr_text_callback(const char *text, bool finished, void *user_data) +``` + +不能只扩展现有无参数事件枚举。 + +### 11.3 SpeechEnded + +表示服务端检测到 ASR 语音终点。当前代码会停止麦克风上行并记录: + +```text +ASR ended +``` + +## 12. 音频下发详细流程 + +### 12.1 响应开始 + +服务端先通过文本帧发送 `RespondingStarted`。客户端执行: + +1. 设置 `s_remote_responding=true`。 +2. 停止仍在运行的上行采集,防止边播边上传自身声音。 +3. 清除上一轮响应的播放状态。 +4. 调用 `domain_runtime_multimodal_audio_begin_downstream_response()`。 +5. 复位 Opus 解码器。 +6. 创建或确认播放任务、播放队列、静音定时器和 DMA bounce buffer。 +7. 预热输出路径,但保持静音,等待第一个有效 PCM 帧。 + +### 12.2 WebSocket 收到二进制帧 + +底层事件入口: + +```c +websocket_event_handler(..., WEBSOCKET_EVENT_DATA, ...) +``` + +处理步骤: + +```text +ESP WebSocket Event Task + -> 校验 opcode 是 TEXT 或 BINARY + -> 将当前片段复制到 PSRAM 优先分配的内存 + -> xQueueSend(s_ws_msg_queue) + -> ws_dispatch_task() + -> process_binary_fragment() + -> 必要时按 payload_offset 重组 + -> emit_event(MULTIMODAL_WS_EVENT_BINARY_MESSAGE) +``` + +如果 worker pipeline 因内存不足创建失败,代码会回退到事件回调中直接处理,但这种路径会增加 WebSocket 事件任务的负担。 + +### 12.3 分片重组 + +ESP WebSocket Client 可能把一个完整消息拆成多个 `WEBSOCKET_EVENT_DATA`: + +- `payload_len`:完整消息总长度。 +- `payload_offset`:当前片段在完整消息中的偏移。 +- `data_len`:当前片段长度。 + +代码要求片段连续且按顺序到达。出现 gap、乱序、越界或总长度超过限制时,重组缓存会被清空并丢弃该消息。 + +### 12.4 Control Plane 音频桥接 + +二进制消息被 Domain 映射后进入: + +```text +transport_event_bridge(DOMAIN_MULTIMODAL_TRANSPORT_EVENT_BINARY_MESSAGE) + -> 检查 s_drop_downstream_audio + -> 更新 last_downstream_audio_tick + -> control_plane_downstream_audio_bridge() + -> control_plane_multimodal_play_downstream_audio() + -> domain_runtime_multimodal_audio_play_downstream_opus() + -> platform_multimodal_audio_play_downstream_opus() + -> multimodal_audio_handle_downstream_opus() +``` + +如果用户发起打断,`s_drop_downstream_audio=true`,后续仍在网络中到达的旧 TTS 音频会被直接丢弃,不再送入播放队列。 + +### 12.5 下行播放队列 + +`multimodal_audio_handle_downstream_opus()`: + +1. 检查音频系统、codec 和 Opus decoder 已初始化。 +2. 检查单包不超过 2048 B。 +3. 确保下行播放任务存在。 +4. 在 PSRAM 优先分配一份包缓存并复制数据。 +5. 用最多 20 ms 把包放入播放队列。 +6. 队列满时丢弃当前包并记录告警。 + +上行队列满时会尝试“丢旧保新”,下行队列满时则直接丢弃当前新包,两者策略不同。 + +### 12.6 Opus 解码和 I2S 播放 + +`downstream_playback_task()` 从队列取包,调用: + +```text +handle_downstream_opus_sync() + -> esp_opus_dec_decode() + -> 得到 16 bit PCM + -> 必要时扩展为 codec 的通道布局 + -> prime_downstream_output_path() + -> 解除静音 + -> esp_codec_dev_write() + -> I2S TX + -> ES8311 DAC + -> PA/扬声器 +``` + +当前 codec 配置是单声道,所以解码 PCM 通常可直接写入 codec。如果未来 codec 通道数改为多声道,代码会调用 `expand_mono_to_multichannel_16()` 将单声道复制到目标通道布局。 + +### 12.7 播放完成握手 + +本项目不仅接收云端 `RespondingStarted/Ended`,还会把本地真正的播放状态反馈给服务端: + +- 第一帧实际开始播放后,发送 `LocalRespondingStarted`。 +- 收到 `RespondingEnded` 后,不一定立即发送本地结束。 +- 需要等播放队列中的剩余音频真正播放完,再发送 `LocalRespondingEnded`。 + +这解决了“服务端已经下发完,但扬声器还在播放缓存”的时间差。若用户打断播放,则停止队列后也会走本地结束通知。 + +### 12.8 静音和爆音控制 + +下行开始时先保持静音,输出路径准备完成、首帧即将写入时才解除静音。最后一包播放后由 idle mute timer 延迟 250 ms 再静音。 + +这部分用于减少: + +- PA 打开瞬间的爆音。 +- Opus 包之间短间隔导致的频繁 mute/unmute。 +- 响应切换时残留 PCM 被播放。 + +## 13. 文本响应与音频响应的关系 + +服务端可能同时下发: + +- `RespondingContent.text/spoken`:模型或 TTS 的文字内容。 +- WebSocket Binary:对应的 raw Opus 音频。 + +当前 `RespondingContent` 会通过 responding text callback 交给上层,用于模型命令提取;二进制帧走独立音频回调。因此文本和音频虽然属于同一响应,但设备端是两条并行链路,不能假定“一条文本事件紧跟一个音频包”。 + +## 14. 打断链路 RequestToSpeak + +当服务端正在 TTS 响应,用户再次触发语音输入时: + +```text +PTT + -> 检测 state.remote_responding + -> Send RequestToSpeak + -> s_request_to_speak_pending=true + -> s_drop_downstream_audio=true + -> 停止本地下行播放 + -> 服务端返回 RequestAccepted + -> server_listening=true + -> 再发送 SendSpeech + -> 启动新一轮音频上行 +``` + +`RequestToSpeak` 与 `SendSpeech` 不是同一件事: + +- `RequestToSpeak`:请求打断当前服务端回答并切回 Listening。 +- `SendSpeech`:在已经 Listening 后,声明马上开始上传本轮用户音频。 + +必须等待 `RequestAccepted` 或新的 Listening 状态后再走 `SendSpeech`,否则服务端可能返回协议状态错误。 + +## 15. 心跳、空闲和超时 + +项目有两层保活: + +1. WebSocket Ping/Pong:20 秒 Ping,120 秒 Pong 超时。 +2. 业务 `HeartBeat` Directive:默认 50 秒一次。 + +Watchdog 每秒检查一次运行状态: + +| 参数/常量 | 当前值 | 行为 | +| --- | ---: | --- | +| `CONFIG_MULTIMODAL_HEARTBEAT_INTERVAL_MS` | 50000 ms | 有活跃 task 时发送 HeartBeat | +| `CONFIG_MULTIMODAL_NO_RESPONSE_TIMEOUT_MS` | 60000 ms | 长时间无下行时发送 Stop | +| `CONFIG_MULTIMODAL_DIALOG_IDLE_TIMEOUT_MS` | 60000 ms | 协议配置的对话空闲概念,需结合具体使用点确认 | +| `CONTROL_PLANE_IDLE_TRANSPORT_SHUTDOWN_MS` | 30000 ms | WS 已连但没有 active task,30 秒后停止 client | + +注意:TCP Keepalive、WebSocket Ping/Pong 和业务 HeartBeat 作用不同,不能互相替代。 + +## 16. 错误处理与恢复 + +### 16.1 WebSocket 断开或错误 + +底层产生: + +- `DOMAIN_MULTIMODAL_TRANSPORT_EVENT_DISCONNECTED` +- `DOMAIN_MULTIMODAL_TRANSPORT_EVENT_ERROR` + +Control Plane 会: + +1. 标记 transport unavailable。 +2. 停止音频上行。 +3. 清理或复位当前 session 状态。 +4. 由运行时连接策略决定后续重连。 + +本地主动 stop 导致的 disconnected 不会上报成异常断开,避免停止流程被误判成网络故障。 + +### 16.2 task-failed + +收到 `header.event=task-failed` 时,代码提取: + +- `task_id` +- `error_code` +- `error_name` +- `error_message` +- `request_id` +- `dialog_id` + +旧 task 的迟到失败事件会与当前 `task_id` 比较,不匹配时忽略,避免上一轮错误破坏新会话。 + +`InvalidParameter` 或包含 `Invalid action` 的错误被视为协议错误,会直接把 transport 标为不可用。 + +### 16.3 output Error + +收到 `payload.output.event=Error` 时: + +1. 打印 `code/name/message/request_id/dialog_id`。 +2. 停止上行采集。 +3. 停止本地下行播放。 +4. 标记协议传输不可用。 +5. 触发 `CONTROL_PLANE_MULTIMODAL_EVENT_OUTPUT_ERROR`。 +6. 上层请求 `SERVER_ERROR` LCD 表情。 + +因此日志中 `Expression request id=1 trigger=2` 通常不是 LCD 自身故障,而是 WebSocket 服务端错误触发的错误表情。 + +### 16.4 音频发送失败窗口 + +`control_plane_multimodal_send_audio_realtime()` 会统计一段时间内的连续音频发送失败。达到阈值后会把传输标记为不可用,避免设备在已经坏掉的连接上持续编码和丢包。 + +## 17. 并发与内存模型 + +### 17.1 发送方向 + +文本和二进制共用 `s_ws_send_lock`,因为 ESP WebSocket Client 的同一连接不能被多个任务无保护地并发发送。 + +普通控制指令和实时音频采用不同策略: + +| 类型 | 等锁/发送策略 | 设计目标 | +| --- | --- | --- | +| Start、Stop、SendSpeech 等普通文本 | 最长约 2 秒等锁、1200 ms 发送、可重试 | 尽量保证关键控制指令送达 | +| HeartBeat、本地播放状态等实时文本 | 80 ms 等锁、300 ms 发送、不长重试 | 避免阻塞实时链路 | +| 上行 Opus 二进制 | 80 ms 等锁、300 ms 发送、不长重试 | 过期语音包及时放弃 | + +### 17.2 接收方向 + +WebSocket 事件回调尽量只复制数据并入队。实际文本解析、二进制重组和上层回调由 worker 处理,降低阻塞 WebSocket 内部事件任务的风险。 + +消息内容和重组缓冲优先从 PSRAM 分配,失败后回退到普通 8-bit heap。WebSocket Client 原生任务栈仍对内部内存大块较敏感。 + +### 17.3 关键队列 + +| 队列 | 当前深度 | 元素含义 | 满时策略 | +| --- | ---: | --- | --- | +| WebSocket 入站消息队列 | 512 | 文本/二进制片段描述符与数据指针 | 丢当前片段,完整消息可能因此重组失败 | +| 音频上行发送队列 | 24 | 已编码 Opus 包 | 尝试丢最旧包、保留最新包 | +| 音频下行播放队列 | 1024 | 待解码 Opus 包 | 丢当前新包 | + +## 18. 常见日志与定位方法 + +### 18.1 正常建连 + +```text +Request device token: ... +Token fetched (workspace=..., app=...) +WebSocket starting: wss://... +WebSocket connected +Start sent (upstream_mode=push2talk ...) +``` + +### 18.2 正常上行和 ASR + +```text +Mic uplink capture task started ... +Mic uplink send task started ... +ASR started +ASR ... finished=1 +ASR ended +``` + +若有 `NoSpeechRecognized`,优先检查: + +1. PTT 极性和状态机是否真的进入 STREAMING。 +2. `ASR started` 是否出现。 +3. 麦克风通道是否选对。 +4. 输入增益是否过低或削顶。 +5. 是否出现 uplink queue overflow/send failed。 +6. Start 的 sample rate/frame/audio format 是否与实际编码一致。 + +### 18.3 正常下行 + +```text +TTS responding started +Downstream response begin: sample_rate=16000 frame=60ms ... +Downstream opus queued ... +Downstream opus played ... +TTS responding ended +``` + +只有文字没有声音时,按顺序检查: + +1. 是否收到 WebSocket binary。 +2. `s_drop_downstream_audio` 是否被误设为 true。 +3. 单包是否超过 2048 B 或完整 WS binary 是否超过 4096 B。 +4. 播放队列是否满。 +5. Opus decoder 是否报错。 +6. `esp_codec_dev_write()` 是否成功。 +7. PA GPIO11、输出音量和 mute 状态是否正确。 + +### 18.4 关键异常日志 + +| 日志关键词 | 含义 | +| --- | --- | +| `Invalid workspace_id` | 握手 Workspace、Token 所属空间或 Start workspace 不一致 | +| `WebSocket send text lock timeout` | 音频或其他文本发送长时间占用共用锁 | +| `WebSocket send bin failed` | 上行音频未完整写入 WS | +| `WS ingress queue full` | 收包处理不及,当前片段被丢弃 | +| `WS text/binary fragment gap/out-of-order` | 分片缺失或乱序,完整消息被丢弃 | +| `Mic uplink queue overflow` | 网络发送慢于音频编码速度 | +| `Downstream playback queue full` | 解码/播放速度跟不上下发速度 | +| `Downstream playback backlog high` | 估算积压超过 5 秒,交互延迟明显 | +| `Downstream opus decode failed` | 格式、帧边界或数据完整性异常 | +| `WS output Error` | 服务端业务/协议错误,不是单纯网络断线 | +| `task-failed` | 云端任务级失败,需看 code/name/message | + +## 19. 修改参数时必须成组检查 + +### 19.1 修改采样率 + +不能只改 `CONFIG_MULTIMODAL_AUDIO_CODEC_SAMPLE_RATE`。需要同时确认: + +- ES8311/I2S 实际采样率。 +- Opus encoder/decoder 采样率。 +- Start upstream.sample_rate。 +- Start downstream.sample_rate。 +- PCM frame buffer 大小。 +- 服务端是否支持该采样率。 + +协议代码只接受 8000、16000、24000、48000 Hz。 + +### 19.2 修改 Opus 帧长 + +修改 `CONFIG_MULTIMODAL_OPUS_FRAME_DURATION_MS` 时要同时检查: + +- 每帧 PCM 字节数。 +- `CONFIG_MULTIMODAL_AUDIO_CHUNK_SIZE` 是否匹配。 +- Start downstream.frame_size。 +- 上下行队列换算的缓存时长。 +- 服务端是否支持该帧长。 + +当前 16 kHz/16 bit/mono/60 ms 对应 1920 B PCM。 + +### 19.3 修改码率 + +修改 `CONFIG_MULTIMODAL_OPUS_BITRATE_KBPS` 时要检查: + +- Start downstream.bit_rate。 +- `CONFIG_MULTIMODAL_DOWNSTREAM_RATE_LIMIT` 是否仍合理。 +- `UPLINK_MAX_PACKET_BYTES` 是否能容纳极端帧。 +- 网络带宽、Wi-Fi 稳定性和音质需求。 + +### 19.4 修改队列深度 + +队列越大不一定越好: + +- 上行队列太大:ASR 收到的是越来越旧的声音,实时性变差。 +- 下行队列太大:不会立即丢包,但用户听到的回答延迟越来越大。 +- 每个下行包单独分配内存,超大队列可能造成 PSRAM 分配碎片和额外生命周期成本。 + +## 20. 当前实现需要特别注意的点 + +1. **ASR 文本没有携带到上层。** `SPEECH_CONTENT` 只是无参数事件;本地需要显示/处理识别文本时应补充强类型回调。 +2. **文本和音频共用发送锁。** 高频控制指令或异常重试会影响实时音频,反之亦然。 +3. **WebSocket 收包队列同时承载文本和音频。** 下行音频突发可能延迟关键状态事件。 +4. **下行队列配置很深。** 它提高抗抖动能力,但最大理论缓存超过一分钟,需关注高延迟而非只关注是否丢包。 +5. **二进制完整消息重组上限为 4096 B。** 即使播放层允许 2048 B,传输层上限仍需同步考虑;未来提高服务端单包大小时要成组修改。 +6. **click-to-talk 与双击共用 PTT。** 原始 PRESSED 阶段触发 `RequestToSpeak` 可能与双击木鱼手势冲突,手势确认和协议动作需要明确仲裁。 +7. **服务端 SpeechEnded 可以先于本地 8 秒超时。** 两条结束路径必须保持幂等,避免重复 StopSpeech 或错误复用上一轮状态。 +8. **`sdkconfig` 是本地生成配置。** 正式调整应同步确认 `sdkconfig.defaults` 或板级配置方案,否则 fullclean/set-target 后可能恢复旧值。 + +## 21. 最短代码阅读顺序 + +建议按下面顺序阅读,最容易形成完整心智模型: + +1. `components/control_plane/src/runtime/control_plane_system_events.c` +2. `components/control_plane/src/runtime/control_plane_runtime_workers.c` +3. `components/control_plane/src/multimodal/control_plane_multimodal_runtime.c` +4. `components/control_plane/src/multimodal/control_plane_multimodal_auth_service.c` +5. `components/control_plane/src/multimodal/control_plane_multimodal_session_service.c` +6. `components/domain/src/runtime/domain_runtime_adapters.c` +7. `components/platform/src/multimodal/transport_ws.c` +8. `components/platform/src/multimodal/transport_ws/transport_ws_runtime_impl.c` +9. `components/platform/src/multimodal/transport_ws/transport_ws_workers_impl.c` +10. `components/platform/src/multimodal/audio_pipeline.c` +11. `components/platform/src/multimodal/audio_pipeline/audio_pipeline_runtime_impl.c` +12. `components/platform/src/multimodal/audio_pipeline/audio_pipeline_codec_impl.c` + +## 22. 一句话总结 + +该项目使用一条长期全双工 WSS 同时承载控制 JSON 和 raw Opus:Control Plane 负责鉴权与会话状态,Platform 负责 WebSocket 和 ES8311/I2S/Opus;上行是“PCM 采集 → Opus 编码 → 队列 → WS binary → 云端 ASR”,下行是“云端 TTS WS binary → 分片重组 → 播放队列 → Opus 解码 → PCM → ES8311”,文本事件贯穿两条音频链路,用来保证 Listening、说话、响应、播放完成和异常恢复的顺序正确。 diff --git a/docs/lcd-png-storage-optimization-report.zh-CN.md b/docs/lcd-png-storage-optimization-report.zh-CN.md new file mode 100644 index 0000000..b094993 --- /dev/null +++ b/docs/lcd-png-storage-optimization-report.zh-CN.md @@ -0,0 +1,505 @@ +# LCD PNG 存储优化实施报告 + +## 1. 基本信息 + +- 对应提交:`549f495 perf(assets): optimize LCD PNG storage` +- 目标设备:ESP32-S3 +- LCD 分辨率:320×240 +- LCD 像素格式:RGB565 +- 资源分区:`gifpack` LittleFS,容量 2 MiB +- 优化范围:59 张 PNG 图片 + +本次工作的主要目标是缓解 `gifpack` 分区的存储压力,同时保证 LCD 最终显示的 RGB565 像素不发生变化。 + +本次工作不修改 LCD 驱动、PNG 运行时解码器、帧缓存数量和动画播放接口。 + +## 2. 问题现象 + +优化前,59 张 PNG 的总容量为: + +```text +1,069,465 字节 +``` + +将原始图片、语音资源和 LittleFS 元数据一起打包后,`gifpack` 分区的实际占用为: + +```text +已使用:407 / 512 块 +使用率:79.5% +剩余空间:430,080 字节 +``` + +虽然单张图片的分辨率只有 320×240,但资源分区已经接近容量警戒线,继续增加动画帧会快速消耗剩余空间。 + +## 3. 问题原因 + +### 3.1 图片携带了未使用的 Alpha 通道 + +现有资源中大部分 PNG 是 RGBA 格式,每个像素在红、绿、蓝之外还带有 Alpha 透明度。 + +检查全部资源后发现,所有图片的 Alpha 范围均为: + +```text +255~255 +``` + +这表示所有像素都完全不透明,Alpha 通道虽然存在,但没有实际提供透明效果。 + +PNG 会压缩重复的 Alpha 数据,因此删除 Alpha 不会正好节省 25%,但仍然可以减少文件体积。 + +### 3.2 原始图片保存了 LCD 无法显示的颜色精度 + +普通 RGB PNG 通常使用 RGB888: + +```text +红色 8 位 + 绿色 8 位 + 蓝色 8 位 = 24 位 +``` + +LCD 实际使用 RGB565: + +```text +红色 5 位 + 绿色 6 位 + 蓝色 5 位 = 16 位 +``` + +现有固件在 PNG 解码后,本来就会丢弃 LCD 无法显示的低位颜色精度。运行时转换公式为: + +```c +uint16_t rgb565 = + ((red & 0xF8) << 8) | + ((green & 0xFC) << 3) | + (blue >> 3); +``` + +因此,原始 PNG 中的一部分颜色信息虽然占用 Flash,但最终不会发送到 LCD。 + +### 3.3 RGB565 对齐后,实际颜色数量很少 + +原始图片中的 RGB888 颜色数量为每张 66~1383 种。 + +提前对齐到 RGB565 后,每张图片只剩: + +```text +53~240 种颜色 +``` + +全部低于索引色 PNG 的 256 色上限,因此可以使用一个字节的调色板编号表示像素,而不必为每个像素重复保存完整 RGB 数值。 + +### 3.4 构建系统原来直接打包原始 PNG + +原构建流程直接从 `components/platform/assets/lcd` 复制 PNG 到构建目录,然后制作 `gifpack.bin`,没有区分设计原图和固件优化图,也没有资源同步检查。 + +## 4. 解决方案 + +本次采用“原图和优化图同时提交到 Git”的方案。 + +目录职责调整为: + +```text +components/platform/assets/ +├── lcd_source/ 原始设计图片,不打包进固件 +└── lcd_optimized/ 优化图片,固件实际打包 +``` + +正常固件构建只读取 `lcd_optimized`,不需要安装 Pillow。 + +修改图片时,从 `lcd_source` 重新生成全部优化图片,并通过清单和逐像素验证保证结果可靠。 + +完整处理流程为: + +```text +原始 PNG + ↓ 检查 Alpha +删除未使用的 Alpha + ↓ +颜色精度对齐到 RGB565 + ↓ +统计 RGB565 颜色数量 + ↓ +不超过 256 色时生成索引色 PNG + ↓ +逐像素验证 RGB565 等价性 + ↓ +生成 manifest.json + ↓ +提交优化图片并打包到 gifpack +``` + +## 5. 关键代码 + +### 5.1 RGB888 对齐到 RGB565 精度 + +生成工具先截取 RGB565 有效位,再将其展开成标准 PNG 能保存的 RGB888 数值: + +```python +def expand_rgb565(pixel: tuple[int, int, int]) -> tuple[int, int, int]: + red, green, blue = pixel + red5 = red >> 3 + green6 = green >> 2 + blue5 = blue >> 3 + return ( + (red5 << 3) | (red5 >> 2), + (green6 << 2) | (green6 >> 4), + (blue5 << 3) | (blue5 >> 2), + ) +``` + +展开后的图片仍是标准 PNG,但其中每种颜色都能稳定映射回相同的 RGB565 数值。 + +### 5.2 防止误删有效透明度 + +当前方案不处理透明叠加。如果发现透明或半透明像素,生成过程会立即失败: + +```python +rgba = source_image.convert("RGBA") +alpha_min, alpha_max = rgba.getchannel("A").getextrema() + +if (alpha_min, alpha_max) != (255, 255): + raise AssetError( + f"Transparency is not supported by the RGB565 optimizer: " + f"{source_path.name} has alpha range " + f"({alpha_min}, {alpha_max})" + ) +``` + +这样不会因为资源优化而静默破坏未来新增的透明图片。 + +### 5.3 生成无额外量化损失的索引色 PNG + +脚本根据已经完成 RGB565 对齐的实际颜色建立调色板,不调用普通的自动 256 色量化: + +```python +palette_colors = sorted(set(pixels)) + +if len(palette_colors) > 256: + raise AssetError("Indexed PNG requested with more than 256 colors") + +palette_index = { + color: index + for index, color in enumerate(palette_colors) +} + +indices = bytes(palette_index[pixel] for pixel in pixels) +indexed = image_module.frombytes("P", size, indices) + +flat_palette = [ + channel + for color in palette_colors + for channel in color +] +flat_palette.extend([0] * (768 - len(flat_palette))) + +indexed.putpalette(flat_palette) +indexed.save( + output, + format="PNG", + optimize=True, + compress_level=9, +) +``` + +如果未来某张图片在 RGB565 对齐后仍超过 256 种颜色,工具会保存为普通 RGB PNG,而不是强行合并颜色: + +```python +if color_count <= 256: + output_mode = "indexed" + save_indexed_png( + image_module, + source_rgb.size, + rgb565_pixels, + output_path, + ) +else: + output_mode = "rgb" + optimized_rgb = image_module.new("RGB", source_rgb.size) + optimized_rgb.putdata(rgb565_pixels) + optimized_rgb.save( + output_path, + format="PNG", + optimize=True, + compress_level=9, + ) +``` + +### 5.4 逐像素验证 LCD 输出一致 + +原图和优化图都会重新计算 RGB565 数值,并逐像素比较: + +```python +def rgb565_value(pixel: tuple[int, int, int]) -> int: + red, green, blue = pixel + return ( + ((red & 0xF8) << 8) + | ((green & 0xFC) << 3) + | (blue >> 3) + ) + + +for index, (source_pixel, optimized_pixel) in enumerate( + zip( + source_rgb.get_flattened_data(), + optimized_rgb.get_flattened_data(), + ) +): + if rgb565_value(source_pixel) != rgb565_value(optimized_pixel): + x = index % source_rgb.width + y = index // source_rgb.width + raise AssetError( + f"RGB565 mismatch at ({x}, {y})" + ) +``` + +这里验证的是 LCD 实际接收的 RGB565 数值,而不是电脑上查看时的 RGB888 数值。 + +### 5.5 通过清单检查资源是否过期 + +`manifest.json` 保存原图和优化图的 SHA-256、尺寸、颜色数和文件大小。 + +普通构建只使用 Python 标准库重新计算哈希,不导入 Pillow: + +```python +for name in sorted(source_names & manifest_names): + source_path = SOURCE_DIR / name + if sha256_file(source_path) != assets[name].get("source_sha256"): + errors.append( + f"Source asset changed after optimization: {name}" + ) + +for name in sorted(optimized_names & manifest_names): + optimized_path = OPTIMIZED_DIR / name + if sha256_file(optimized_path) != assets[name].get("optimized_sha256"): + errors.append( + f"Optimized asset changed after generation: {name}" + ) +``` + +原图修改后如果忘记重新生成优化图,固件构建会失败,不会继续打包过期资源。 + +### 5.6 构建系统只打包优化资源 + +CMake 中分别声明原图目录、优化图目录、清单和生成工具: + +```cmake +set( + platform_lcd_asset_source_dir + "${CMAKE_CURRENT_SOURCE_DIR}/assets/lcd_source" +) +set( + platform_lcd_asset_src_dir + "${CMAKE_CURRENT_SOURCE_DIR}/assets/lcd_optimized" +) +set( + platform_lcd_asset_manifest + "${platform_lcd_asset_src_dir}/manifest.json" +) +set( + platform_lcd_asset_optimizer + "${CMAKE_CURRENT_SOURCE_DIR}/../../tools/optimize_lcd_assets.py" +) +``` + +制作资源镜像前执行轻量同步检查: + +```cmake +add_custom_command( + OUTPUT "${platform_asset_stage_stamp}" + COMMAND + ${PYTHON} + "${platform_lcd_asset_optimizer}" + check-current + COMMAND + ${CMAKE_COMMAND} -E rm -rf + "${platform_asset_image_dir}" + COMMAND + ${CMAKE_COMMAND} -E make_directory + "${platform_asset_image_dir}" + ${platform_asset_stage_commands} + COMMAND + ${CMAKE_COMMAND} -E touch + "${platform_asset_stage_stamp}" + DEPENDS + ${platform_audio_prompt_files} + ${platform_lcd_source_png_files} + ${platform_lcd_png_asset_files} + "${platform_lcd_asset_manifest}" + "${platform_lcd_asset_optimizer}" +) +``` + +最终进入设备的路径仍然是: + +```text +/lcd/<图片名称>.png +``` + +运行时代码不需要修改资源路径。 + +## 6. 使用方式 + +### 6.1 普通固件构建 + +正常构建不生成图片,也不需要 Pillow: + +```bash +idf.py build +``` + +### 6.2 准备图片生成环境 + +```bash +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -r tools/requirements-assets.txt +python tools/optimize_lcd_assets.py check-env +``` + +### 6.3 修改图片后重新生成 + +只修改 `components/platform/assets/lcd_source` 中的原图,然后执行: + +```bash +python tools/optimize_lcd_assets.py generate +python tools/optimize_lcd_assets.py verify +idf.py build +``` + +需要同时提交: + +```text +lcd_source 中修改的原图 +lcd_optimized 中重新生成的图片 +lcd_optimized/manifest.json +``` + +### 6.4 只检查资源是否同步 + +以下命令不需要 Pillow: + +```bash +python3 tools/optimize_lcd_assets.py check-current +``` + +## 7. 解决成果 + +### 7.1 PNG 文件容量 + +```text +优化前:1,069,465 字节 +优化后: 381,384 字节 +减少: 688,081 字节 +降幅: 64.3% +``` + +59 张图片全部成功保存为索引色 PNG。 + +### 7.2 LittleFS 分区占用 + +`gifpack` 的块大小为 4096 字节,总计 512 块。 + +| 状态 | 已使用块 | 使用率 | 剩余空间 | +| --- | ---: | ---: | ---: | +| 原始图片模拟打包 | 407 / 512 | 79.5% | 430,080 字节 | +| 优化图片实际构建 | 236 / 512 | 46.1% | 1,130,496 字节 | + +分区内部新增可用空间: + +```text +700,416 字节 = 684 KiB +``` + +`gifpack.bin` 的文件大小仍然固定为 2 MiB,因为它是完整分区镜像。优化释放的是 LittleFS 内部空间,不会缩小分区镜像,也不会自动把空间转移给应用程序分区。 + +### 7.3 显示一致性 + +验证结果: + +```text +LCD assets are current: 59 files +RGB565 display equivalence verified: 59 files +``` + +59 张图片的 RGB565 输出全部逐像素一致。 + +### 7.4 构建验证 + +ESP-IDF v5.5.4 完整构建通过,并确认构建顺序为: + +```text +检查资源清单 +→ 复制 lcd_optimized +→ 制作 gifpack.bin +→ 完成固件构建 +``` + +## 8. 后续影响 + +### 8.1 正面影响 + +- `gifpack` 使用率由 79.5% 降至 46.1%。 +- 增加 684 KiB 可用资源空间。 +- 按当前图片复杂度估算,还可安全增加约 80~100 张全屏帧。 +- Flash 读取的 PNG 数据减少,首次加载可能略有改善。 +- 原图被完整保留,可以随时重新生成固件资源。 +- 构建会阻止过期或被手动篡改的优化图片进入固件。 + +### 8.2 不变的部分 + +- PNG 文件名和设备内路径不变。 +- LCD 驱动和动画播放接口不变。 +- 当前 PNG 解码器仍会先展开为 RGBA,再转换为 RGB565。 +- 解码后的帧缓存大小不变。 +- 帧缓存数量不变。 +- SPI 传输速度不变。 +- `gifpack` 分区大小仍为 2 MiB。 + +因此,本次优化主要解决 Flash 容量问题,不应把 64.3% 的文件缩减直接等同于 64.3% 的帧率提升或运行时内存下降。 + +### 8.3 开发流程影响 + +- 普通固件编译不依赖 Pillow。 +- 修改图片的开发者需要安装 `tools/requirements-assets.txt` 中固定版本的 Pillow。 +- 不应手动修改 `lcd_optimized`。 +- 每次必须从 `lcd_source` 重新生成,不能反复压缩优化图。 +- 添加新图片时,除了生成优化图,还需要把图片名称加入 CMake 资源清单。 +- 删除图片时,需要同时删除原图并更新 CMake 资源清单。 + +### 8.4 仓库容量影响 + +原图和优化图都会提交到 Git,因此代码仓库本身会增加约 381 KiB 当前工作树容量,并产生对应的 Git 历史对象。 + +这是方案 B 的预期取舍:用一定的仓库容量换取普通构建不依赖 Pillow,并确保任何开发机都能直接使用已经验证过的优化资源构建固件。 + +### 8.5 未来透明图片 + +当前工具发现 Alpha 不全为 255 时会直接失败。 + +如果未来需要透明叠加效果,需要先明确: + +- 透明度由运行时软件合成还是预先合成; +- 是否需要支持索引 PNG 的透明表; +- LCD 背景和前景的混合规则; +- 是否需要保留完整 Alpha 精度。 + +在透明处理方案完成前,不应绕过该检查。 + +### 8.6 未来高色彩图片 + +如果未来图片在 RGB565 对齐后超过 256 种颜色,工具会自动使用 RGB PNG,保证显示正确,但文件会比索引 PNG 更大。 + +普通照片、大面积渐变和噪声纹理通常比当前卡通资源更难压缩,因此新增图片数量不能只按当前平均值机械计算,应在每次资源更新后重新检查 `manifest.json` 和 LittleFS 实际块占用。 + +### 8.7 高色深屏幕上的查看差异 + +优化图已经针对 RGB565 精度制作。在电脑等高色深屏幕上直接查看时,可能与原始 RGB888 图片存在轻微颜色差异。 + +这不代表 LCD 显示发生退化。对当前 RGB565 LCD 而言,优化前后的最终像素完全一致。 + +## 9. 后续可选优化 + +如果未来需要继续改善加载速度或运行时内存,可以单独评估: + +1. 让 PNG 解码器输出调色板索引或 RGB 数据,减少 RGBA 中间缓冲。 +2. 增加索引 PNG 到 RGB565 的直接查表路径。 +3. 按行解码并写入 RGB565,降低峰值内存。 +4. 测量首次解码、缓存填充和 SPI 传输分别占用的时间。 + +这些优化会修改运行时解码路径,风险和测试范围明显大于本次 Flash 资源优化,建议作为独立提交实施。 diff --git a/docs/light-standby-power-optimization.zh-CN.md b/docs/light-standby-power-optimization.zh-CN.md new file mode 100644 index 0000000..efb4e27 --- /dev/null +++ b/docs/light-standby-power-optimization.zh-CN.md @@ -0,0 +1,707 @@ +# TQ CatN 轻待机低功耗优化说明 + +## 1. 文档范围 + +本文整理以下五个低功耗相关提交的设计和最终实现: + +| 提交 | 内容 | +| --- | --- | +| `988dc92` | 增加 LCD 背光 PWM 亮度控制 | +| `6a11aca` | 增加轻待机状态协调器和活动事件接入 | +| `baad9dc` | 增加背光、扬声器功放和电机的待机策略 | +| `e773ebc` | 增加 Wi-Fi、远程日志、电池检测和内存监控的待机策略 | +| `cb203a7` | 进入轻待机前播放提示音,并等待真实播放完成 | + +`bba99a0 fix(control-plane): trigger PTT on short press` 是独立的按键行为修复,不属于低功耗改动,因此不在本文范围内。 + +## 2. 需求与问题原因 + +设备需要在 5 分钟无交互后降低功耗,同时满足以下约束: + +1. 基础表情必须持续显示。 +2. 唤醒词检测必须持续工作。 +3. 按键和 SW2 抽签操作必须能够快速唤醒。 +4. 进入待机前必须完整播放 `standby_5min.opus`。 + +这些约束决定了设备不能直接进入 ESP Light Sleep 或 Deep Sleep: + +- 基础表情当前由 LCD 渲染任务循环播放,需要 CPU、LCD 控制器和 SPI 链路继续工作。 +- 唤醒词依赖麦克风采集、I2S RX、AFE 和本地识别任务持续运行。 +- 设备还需要及时响应按键、抽签和业务事件。 + +因此,本次实现的是“轻待机”,即系统仍保持运行,只关闭或降低非必要硬件和周期任务的功耗。优化前存在以下主要问题: + +- LCD 背光只有开关控制,无法在保留画面的同时降低背光功耗。 +- 扬声器功放在无音频播放时仍可能保持供电。 +- 步进电机完成动作后可能继续保持线圈电流。 +- Wi-Fi 始终使用活跃策略。 +- 远程日志持续尝试 HTTP 上传。 +- 电池检测和内存监控仍按活跃周期运行。 +- 缺少统一的待机状态、空闲计时、唤醒入口和失败回滚机制。 +- 如果进入待机时立即关闭功放,待机提示音将无法完整播放。 + +## 3. 总体解决方式 + +系统增加统一的轻待机状态协调器,状态变化如下: + +```text +ACTIVE + | 5 分钟无交互,且当前业务允许待机 + v +ENTERING + | 播放 standby_5min.opus,并等待真实播放完成 + | 再依次应用硬件和运行时低功耗策略 + v +STANDBY + | 唤醒词、按键、抽签、模型命令或语音活动 + v +WAKING + | 恢复背光、功放、Wi-Fi 和周期任务策略 + v +ACTIVE +``` + +进入待机前还会检查远端回复、用户讲话、待发送请求、配网等运行状态。播放期间如果出现新活动,或提示音播放失败、停止、卡死、被其他播放抢占,本次进入待机将被取消。应用低功耗策略中途失败时,已修改的策略会尽量恢复,避免设备停留在半待机状态。 + +## 4. 默认配置 + +当前默认配置如下,均可通过 `menuconfig` 或 `sdkconfig.defaults` 调整: + +```ini +CONFIG_TQ_SCREEN_BACKLIGHT_BRIGHTNESS_PERCENT=100 + +CONFIG_TQ_LIGHT_STANDBY_ENABLE=y +CONFIG_TQ_LIGHT_STANDBY_IDLE_TIMEOUT_MS=300000 +CONFIG_TQ_LIGHT_STANDBY_DIM_BACKLIGHT=y +CONFIG_TQ_LIGHT_STANDBY_BACKLIGHT_PERCENT=10 +CONFIG_TQ_LIGHT_STANDBY_PROMPT_ENABLE=y +CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE="standby_5min.opus" +CONFIG_TQ_LIGHT_STANDBY_DISABLE_SPEAKER_OUTPUT=y +CONFIG_TQ_LIGHT_STANDBY_RELEASE_MOTOR_HOLD=y +CONFIG_TQ_LIGHT_STANDBY_WIFI_POWER_SAVE=y +CONFIG_TQ_LIGHT_STANDBY_PAUSE_REMOTE_LOG_UPLOAD=y +CONFIG_TQ_LIGHT_STANDBY_REMOTE_LOG_POLL_MS=5000 +CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC=300 +CONFIG_TQ_LIGHT_STANDBY_MEMORY_SAMPLE_INTERVAL_MS=10000 +CONFIG_TQ_LIGHT_STANDBY_MEMORY_REPORT_INTERVAL_MS=300000 +CONFIG_TQ_LIGHT_STANDBY_TASK_STACK_BYTES=4096 +CONFIG_TQ_LIGHT_STANDBY_TASK_PRIORITY=3 +``` + +其中: + +- 活跃状态背光默认 100%。 +- 轻待机背光默认 10%,仍保持基础表情可见。 +- 空闲超时默认 300000 ms,即 5 分钟。 +- 协调器任务栈为 4096 字节。原 3072 字节配置在日志压力场景下出现过 `cp_light_standby` 栈溢出,增大后解决该问题。 + +## 5. 各项优化及具体代码 + +### 5.1 LCD 背光 PWM + +#### 问题原因 + +背光 GPIO 原先只能输出固定电平。基础表情必须保留,因此不能直接关闭 LCD;若背光一直保持全亮,LCD 背光会成为待机阶段的主要持续耗电项之一。 + +#### 解决方式 + +使用 ESP-IDF LEDC 输出 20 kHz、10 位 PWM,并兼容背光高电平有效和低电平有效。提供运行时亮度接口,活跃状态使用正常亮度,待机状态切换为配置的 10%。 + +#### 具体代码 + +```c +#define LCD_DIRECT_BACKLIGHT_PWM_FREQUENCY_HZ 20000U +#define LCD_DIRECT_BACKLIGHT_PWM_DUTY_RESOLUTION LEDC_TIMER_10_BIT +#define LCD_DIRECT_BACKLIGHT_PWM_DUTY_MAX ((1U << 10U) - 1U) +#define LCD_DIRECT_BACKLIGHT_PWM_TIMER LEDC_TIMER_0 +#define LCD_DIRECT_BACKLIGHT_PWM_CHANNEL LEDC_CHANNEL_0 + +static uint32_t lcd_direct_backlight_duty(uint8_t brightness_percent) +{ + uint32_t duty = ((uint32_t)brightness_percent * + LCD_DIRECT_BACKLIGHT_PWM_DUTY_MAX + 50U) / 100U; +#if !CONFIG_TQ_SCREEN_BACKLIGHT_ACTIVE_HIGH + duty = LCD_DIRECT_BACKLIGHT_PWM_DUTY_MAX - duty; +#endif + return duty; +} + +static esp_err_t lcd_direct_backlight_init(void) +{ + const ledc_timer_config_t timer_config = { + .speed_mode = LEDC_LOW_SPEED_MODE, + .duty_resolution = LCD_DIRECT_BACKLIGHT_PWM_DUTY_RESOLUTION, + .timer_num = LCD_DIRECT_BACKLIGHT_PWM_TIMER, + .freq_hz = LCD_DIRECT_BACKLIGHT_PWM_FREQUENCY_HZ, + .clk_cfg = LEDC_AUTO_CLK, + }; + ESP_RETURN_ON_ERROR(ledc_timer_config(&timer_config), TAG, + "backlight PWM timer config failed"); + + const ledc_channel_config_t channel_config = { + .gpio_num = CONFIG_TQ_SCREEN_BACKLIGHT_GPIO, + .speed_mode = LEDC_LOW_SPEED_MODE, + .channel = LCD_DIRECT_BACKLIGHT_PWM_CHANNEL, + .intr_type = LEDC_INTR_DISABLE, + .timer_sel = LCD_DIRECT_BACKLIGHT_PWM_TIMER, + .duty = lcd_direct_backlight_duty( + CONFIG_TQ_SCREEN_BACKLIGHT_BRIGHTNESS_PERCENT), + .hpoint = 0, + }; + return ledc_channel_config(&channel_config); +} + +esp_err_t lcd_direct_set_backlight_brightness(uint8_t brightness_percent) +{ + ESP_RETURN_ON_FALSE(brightness_percent <= 100U, + ESP_ERR_INVALID_ARG, TAG, + "backlight brightness must be between 0 and 100 percent"); + + esp_err_t err = ledc_set_duty( + LEDC_LOW_SPEED_MODE, + LCD_DIRECT_BACKLIGHT_PWM_CHANNEL, + lcd_direct_backlight_duty(brightness_percent)); + if (err == ESP_OK) { + err = ledc_update_duty( + LEDC_LOW_SPEED_MODE, + LCD_DIRECT_BACKLIGHT_PWM_CHANNEL); + } + return err; +} +``` + +#### 功耗与影响 + +- 降低 LED 背光平均电流,是本方案中最直接的节电项。 +- LCD 控制器、SPI 传输和基础表情渲染仍在运行,因此并未消除显示链路功耗。 +- 亮度百分比与实际电流通常不是严格线性关系,需要真机测量后确定最佳值。 + +### 5.2 扬声器功放与音频输出链路 + +#### 问题原因 + +待机期间不需要声音输出,但仍要保留麦克风和唤醒词。直接关闭整套音频设备会破坏快速语音唤醒;只停止播放又不能保证外部功放已断电。 + +#### 解决方式 + +待机时先将 Codec 下行输出静音,再关闭 PA GPIO。唤醒时重新打开 PA。麦克风采集、I2S RX、AFE 和唤醒词保持运行。 + +#### 具体代码 + +```c +esp_err_t multimodal_audio_set_speaker_output_enabled(bool enabled) +{ + multimodal_audio_ctx_t *ctx = &s_audio; + if (!ctx->initialized || ctx->codec_dev == NULL || + ctx->gpio_if == NULL || ctx->gpio_if->set == NULL) { + return ESP_ERR_INVALID_STATE; + } + + if (!enabled) { + esp_err_t mute_err = set_downstream_output_mute_if_needed( + ctx, true, "Light standby"); + if (mute_err != ESP_OK) { + return mute_err; + } + } + + int ret = ctx->gpio_if->set(CONFIG_MULTIMODAL_AUDIO_PA_GPIO, enabled); + if (ret != ESP_CODEC_DEV_OK) { + return (esp_err_t)ret; + } + return ESP_OK; +} +``` + +#### 功耗与影响 + +- 降低外部扬声器功放的静态功耗。 +- 不关闭音频输入侧,因此语音唤醒能力不受该策略影响。 +- I2S TX、解码和下行缓冲相关任务未完全停用,仍有进一步优化空间。 +- 唤醒后 PA 恢复存在很短的硬件稳定时间,首段声音需要在真机上确认是否完整。 + +### 5.3 待机提示音完整播放 + +#### 问题原因 + +如果空闲时间到达后立即关闭功放,`standby_5min.opus` 无法播放。仅按音频时长固定延时也不可靠,因为缓冲、解码、播放被打断或管线卡死都会使预计时长与真实播放状态不一致。 + +#### 解决方式 + +提示音发送完成后,以本次播放的 generation 为标识等待真实终止事件: + +- `PLAYBACK_COMPLETED`:允许继续进入待机。 +- `PLAYBACK_STOPPED`:取消进入待机。 +- `PLAYBACK_STALLED` 或等待超时:取消进入待机。 +- 播放期间出现新用户活动:提示音结束后重新检查活动时间,取消进入待机。 + +超时时间只作为故障保护上限,不是用来代替播放完成事件的固定等待。 + +#### 具体代码 + +```c +esp_err_t voice_prompt_play_file(const char *file_name) +{ + /* 省略文件读取和 Opus 包发送过程。 */ + esp_err_t finish_err = multimodal_audio_finish_downstream_response(); + if (err == ESP_OK) { + err = finish_err; + } + + if (err == ESP_OK) { + uint64_t timeout_ms = + (uint64_t)packet_count * VOICE_PROMPT_OPUS_FRAME_MS + + VOICE_PROMPT_COMPLETION_TIMEOUT_MARGIN_MS; + if (timeout_ms > INT32_MAX) { + timeout_ms = INT32_MAX; + } + err = multimodal_audio_wait_downstream_playback_complete( + playback_generation, + (int)timeout_ms); + } + return err; +} + +static esp_err_t downstream_terminal_type_to_error( + multimodal_audio_downstream_playback_event_type_t type) +{ + switch (type) { + case MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_COMPLETED: + return ESP_OK; + case MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_STALLED: + return ESP_ERR_TIMEOUT; + case MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_STOPPED: + default: + return ESP_ERR_INVALID_STATE; + } +} +``` + +待机协调器按“先播报、后关功放”的顺序执行: + +```c +esp_err_t policy_err = control_plane_light_standby_play_entry_prompt(); + +bool entry_current = policy_err == ESP_OK && + control_plane_light_standby_entry_is_current(entry_activity_ms); +bool runtime_eligible = entry_current && + control_plane_light_standby_runtime_is_eligible(); + +if (runtime_eligible) { + policy_applied = true; + policy_err = control_plane_light_standby_apply_entry_policy(); +} +``` + +#### 功耗与影响 + +- 提示音播放期间仍保持正常音频输出功耗。 +- 实际进入轻待机的时间为“5 分钟空闲时间 + 提示音实际播放时间”。 +- 播放异常时宁可取消本次待机,也不会在提示音未完成时关闭功放。 + +### 5.4 电机驱动 + +#### 问题原因 + +步进电机停止移动后,如果线圈仍保持励磁,会持续产生保持电流。轻待机期间没有必要维持该静态扭矩。 + +#### 解决方式 + +进入待机时获取运动互斥锁并释放线圈;后续运动命令会按原有流程重新驱动电机。 + +#### 具体代码 + +```c +esp_err_t platform_stepper_driver_release_hold(int timeout_ms) +{ + if (timeout_ms < 0) { + return ESP_ERR_INVALID_ARG; + } + if (!ensure_stepper_motion_mutex()) { + return ESP_ERR_INVALID_STATE; + } + + TickType_t lock_ticks = pdMS_TO_TICKS(timeout_ms); + if (timeout_ms > 0 && lock_ticks == 0) { + lock_ticks = 1; + } + if (xSemaphoreTake(s_stepper_motion_mutex, lock_ticks) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + portENTER_CRITICAL(&s_stepper_mux); + stepper_release_locked(); + portEXIT_CRITICAL(&s_stepper_mux); + + xSemaphoreGive(s_stepper_motion_mutex); + return ESP_OK; +} +``` + +#### 功耗与影响 + +- 消除电机线圈的待机保持电流。 +- 电机不再提供静态保持扭矩,外力可能改变机构位置。 +- 唤醒本身不重新励磁;下一次实际运动时才恢复驱动,避免无意义耗电。 + +### 5.5 Wi-Fi 省电 + +#### 问题原因 + +设备需要保留网络连接,但活跃 Wi-Fi 策略会使射频和基带更频繁工作。 + +#### 解决方式 + +进入待机后切换为 `WIFI_PS_MIN_MODEM`,保留 STA 和 WebSocket 连接。唤醒后恢复项目配置的正常策略;当前默认正常策略为 `WIFI_PS_NONE`。 + +#### 具体代码 + +```c +esp_err_t platform_wifi_portal_set_light_standby(bool standby) +{ + wifi_ps_type_t ps_type = standby ? WIFI_PS_MIN_MODEM : + (CONFIG_TQ_WIFI_STA_POWER_SAVE ? + WIFI_PS_MIN_MODEM : WIFI_PS_NONE); + return esp_wifi_set_ps(ps_type); +} +``` + +#### 功耗与影响 + +- 保持联网能力的同时降低 Wi-Fi 平均功耗。 +- 可能增加网络首包延迟,实际幅度受 AP 的 DTIM、信号质量和网络负载影响。 +- 不等同于关闭 Wi-Fi,网络任务和 WebSocket 仍会消耗资源。 + +### 5.6 远程日志 + +#### 问题原因 + +待机期间继续发起 HTTP 上传会周期性唤醒网络和 CPU。完全停止日志采集又会丢失现场诊断信息。 + +#### 解决方式 + +待机期间继续接收日志并落盘,但暂停 coredump 和日志文件的 HTTP 上传;轮询间隔放宽至 5 秒。唤醒后恢复上传。 + +#### 具体代码 + +```c +while (!s_stop_requested) { + TickType_t now = xTaskGetTickCount(); + bool light_standby = remote_log_light_standby(); + + if ((now - last_flush) >= + pdMS_TO_TICKS(CONFIG_TQ_REMOTE_LOG_FLUSH_INTERVAL_MS)) { + drain_memory_queue_to_flash(); + last_flush = now; + } + + if (!light_standby && + voice_startup_upload_gate_open(startup_tick) && + (backoff_ms == 0U || now >= next_upload_tick)) { + drain_memory_queue_to_flash(); + upload_coredump_report(); + upload_pending_files(); + } + + uint32_t poll_ms = light_standby ? + CONFIG_TQ_LIGHT_STANDBY_REMOTE_LOG_POLL_MS : + REMOTE_LOG_POLL_MS; + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(poll_ms)); +} +``` + +#### 功耗与影响 + +- 减少待机期间 HTTP、TLS/TCP 和 Wi-Fi 活动。 +- 日志仍会写入 Flash,不会因为轻待机直接丢失。 +- 待机期间服务端无法立即看到新日志;唤醒后再补传。 +- 持续落盘仍有 Flash 写入功耗和寿命影响,但保留了诊断能力。 + +### 5.7 电池检测 + +#### 问题原因 + +电池 ADC 和状态处理在待机期间没有必要保持活跃状态下的采样频率。 + +#### 解决方式 + +普通采样周期在轻待机时切换为 300 秒。充电状态 GPIO 中断仍可立即唤醒电池检测任务并进行防抖后的新采样,满电确认和测量受限时的重试周期也保持原有优先级。 + +#### 具体代码 + +```c +uint32_t delay_ms; +if (status_err == ESP_OK && status.measurement_suppressed) { + delay_ms = BATTERY_SUPPRESSED_RETRY_MS; +} else if (full_hint_candidate) { + delay_ms = BATTERY_FULL_CONFIRM_RETRY_MS; +} else if (battery_light_standby()) { + delay_ms = + (uint32_t)CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC * 1000U; +} else { + delay_ms = + (uint32_t)CONFIG_TQ_BATTERY_SAMPLE_INTERVAL_SEC * 1000U; +} + +uint32_t notifications = ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms)); +if (notifications > 0U && !battery_stop_requested()) { + vTaskDelay(pdMS_TO_TICKS(BATTERY_CHARGE_DEBOUNCE_MS)); + ulTaskNotifyTake(pdTRUE, 0U); +} +``` + +#### 功耗与影响 + +- 减少 ADC 采样、状态计算和相关日志频率。 +- 普通电量百分比更新变慢,最长可能接近配置的 300 秒。 +- 插拔充电等 GPIO 边沿仍会触发及时检测,不受普通采样周期影响。 + +### 5.8 内存监控 + +#### 问题原因 + +内存监控在活跃状态下每 1 秒采样、每 30 秒报告。待机期间这种诊断频率会造成不必要的 CPU 唤醒和日志输出。 + +#### 解决方式 + +待机时改为每 10 秒采样、每 300 秒周期报告。内存分配失败仍会绕过普通采样周期并立即处理。 + +#### 具体代码 + +```c +uint32_t sample_interval_ms = light_standby ? + CONFIG_TQ_LIGHT_STANDBY_MEMORY_SAMPLE_INTERVAL_MS : + CONFIG_TQ_MEMORY_MONITOR_SAMPLE_INTERVAL_MS; +uint32_t report_interval_ms = light_standby ? + CONFIG_TQ_LIGHT_STANDBY_MEMORY_REPORT_INTERVAL_MS : + CONFIG_TQ_MEMORY_MONITOR_REPORT_INTERVAL_MS; + +uint32_t alloc_fail_count = + __atomic_load_n(&s_alloc_fail_count, __ATOMIC_ACQUIRE); +bool unreported_alloc_failure = + alloc_fail_count != s_memory_monitor.reported_alloc_fail_count; + +if (!force && !unreported_alloc_failure && s_memory_monitor.initialized && + (now_tick - s_memory_monitor.last_sample_tick) < sample_ticks) { + return; +} +``` + +#### 功耗与影响 + +- 减少堆信息采集和周期日志带来的 CPU 开销。 +- 常规内存水位变化的可见时间变长。 +- 分配失败仍立即上报,关键故障检测没有被普通周期限制。 + +### 5.9 BLE 广播 + +#### 当前处理 + +本次没有增加轻待机下的 BLE 广播切换逻辑。项目的 BLE 广播只在小程序配网期间使用,而配网期间轻待机资格检查会直接返回不允许进入待机: + +```c +static bool control_plane_light_standby_runtime_is_eligible(void) +{ + if (control_plane_is_reset_in_progress() || + control_plane_is_mini_program_provisioning_running()) { + return false; + } + /* 继续检查语音会话和远端回复状态。 */ + return true; +} +``` + +#### 功耗与影响 + +- 正常业务状态没有持续 BLE 广播,因此没有可应用的待机节电策略。 +- 配网状态优先保证 BLE 配网可用性,不进入轻待机。 +- 如果后续增加常驻 BLE 广播,需要重新设计待机广播间隔和连接参数。 + +## 6. 统一进入、回滚和唤醒策略 + +### 6.1 进入策略 + +进入顺序为:释放电机保持电流、关闭扬声器输出、降低背光、应用运行时策略。任一步骤失败都会阻止状态进入 `STANDBY`,必要时恢复此前已修改的项目。 + +```c +static esp_err_t control_plane_light_standby_apply_entry_policy(void) +{ +#if CONFIG_TQ_LIGHT_STANDBY_RELEASE_MOTOR_HOLD + ESP_RETURN_ON_ERROR( + domain_direction_release_motor_hold(MOTOR_RELEASE_WAIT_MS), + TAG, "release motor hold failed"); +#endif + +#if CONFIG_TQ_LIGHT_STANDBY_DISABLE_SPEAKER_OUTPUT + ESP_RETURN_ON_ERROR( + domain_runtime_multimodal_audio_set_speaker_output_enabled(false), + TAG, "disable speaker output failed"); +#endif + +#if CONFIG_TQ_LIGHT_STANDBY_DIM_BACKLIGHT + esp_err_t backlight_err = domain_runtime_set_lcd_backlight_brightness( + CONFIG_TQ_LIGHT_STANDBY_BACKLIGHT_PERCENT); + if (backlight_err != ESP_OK) { + domain_runtime_multimodal_audio_set_speaker_output_enabled(true); + return backlight_err; + } +#endif + + esp_err_t runtime_err = + control_plane_light_standby_apply_runtime_policy(true); + if (runtime_err != ESP_OK) { + domain_runtime_set_lcd_backlight_brightness( + CONFIG_TQ_SCREEN_BACKLIGHT_BRIGHTNESS_PERCENT); + domain_runtime_multimodal_audio_set_speaker_output_enabled(true); + } + return runtime_err; +} +``` + +以上代码为便于阅读的等价摘录,实际代码还包含每个配置项的条件编译和详细错误日志。 + +### 6.2 运行时策略 + +```c +static esp_err_t control_plane_light_standby_apply_runtime_policy(bool standby) +{ + control_plane_remote_log_set_light_standby(standby); + control_plane_battery_service_set_light_standby(standby); + control_plane_runtime_diagnostics_set_light_standby(standby); + + if (control_plane_is_sta_connected()) { + esp_err_t wifi_err = + domain_runtime_wifi_portal_set_light_standby(standby); + if (wifi_err != ESP_OK && standby) { + control_plane_remote_log_set_light_standby(false); + control_plane_battery_service_set_light_standby(false); + control_plane_runtime_diagnostics_set_light_standby(false); + return wifi_err; + } + } + return ESP_OK; +} +``` + +### 6.3 唤醒来源 + +当前活动类型包括: + +```c +typedef enum { + CONTROL_PLANE_LIGHT_STANDBY_ACTIVITY_UNSPECIFIED = 0, + CONTROL_PLANE_LIGHT_STANDBY_ACTIVITY_KEY, + CONTROL_PLANE_LIGHT_STANDBY_ACTIVITY_LOCAL_WAKE, + CONTROL_PLANE_LIGHT_STANDBY_ACTIVITY_VOICE_INPUT, + CONTROL_PLANE_LIGHT_STANDBY_ACTIVITY_VOICE_OUTPUT, + CONTROL_PLANE_LIGHT_STANDBY_ACTIVITY_MODEL_COMMAND, + CONTROL_PLANE_LIGHT_STANDBY_ACTIVITY_EXTERNAL, +} control_plane_light_standby_activity_t; +``` + +- 唤醒词对应 `LOCAL_WAKE`。 +- 普通按键和 SW2 抽签按键对应 `KEY`。 +- 麦克风语音活动对应 `VOICE_INPUT`。 +- 远端回复对应 `VOICE_OUTPUT`。 +- 本地识别的抽签、方向等命令对应 `MODEL_COMMAND`。 + +记录活动时会立即刷新空闲计时。如果已经处于 `STANDBY`,状态切换为 `WAKING`,恢复运行时策略、正常背光和扬声器功放后返回 `ACTIVE`。 + +```c +esp_err_t control_plane_light_standby_record_activity( + control_plane_light_standby_activity_t activity) +{ + uint64_t now_ms = control_plane_light_standby_now_ms(); + + portENTER_CRITICAL(&s_standby_lock); + s_standby.last_activity = activity; + s_standby.last_activity_ms = now_ms; + bool wake_required = + s_standby.state == CONTROL_PLANE_LIGHT_STANDBY_STATE_STANDBY; + portEXIT_CRITICAL(&s_standby_lock); + + if (!wake_required) { + return ESP_OK; + } + + s_standby.state = CONTROL_PLANE_LIGHT_STANDBY_STATE_WAKING; + esp_err_t wake_err = control_plane_light_standby_apply_wake_policy(); + s_standby.state = CONTROL_PLANE_LIGHT_STANDBY_STATE_ACTIVE; + return wake_err; +} +``` + +以上为突出主流程的等价摘录;实际实现使用临界区和转换互斥锁保护状态,避免进入待机和唤醒同时修改硬件策略。 + +## 7. 解决成果 + +本轮改动已经实现: + +1. 5 分钟无交互后自动进入轻待机。 +2. 基础表情保持显示,待机背光默认降至 10%。 +3. 进入待机前完整播放 `standby_5min.opus`,等待真实播放完成事件后才关闭功放。 +4. 待机时 Codec 输出静音并关闭扬声器 PA,同时保留麦克风、AFE 和唤醒词。 +5. 释放步进电机线圈保持电流。 +6. Wi-Fi 切换为最小 Modem 省电,同时保留连接。 +7. 暂停远程日志 HTTP 上传,日志采集和落盘继续工作。 +8. 电池常规采样周期放宽到 300 秒,充电状态边沿仍及时处理。 +9. 内存监控采样放宽到 10 秒、周期报告放宽到 300 秒,分配失败仍及时处理。 +10. 支持唤醒词、按键、SW2 抽签、模型命令和语音活动快速唤醒。 +11. 唤醒后恢复正常背光、功放、Wi-Fi、日志、电池和内存监控策略。 +12. 增加进入失败回滚和状态竞态保护。 +13. 将轻待机任务栈增至 4096 字节,解决已观察到的任务栈溢出。 + +## 8. 验证情况 + +已完成的软件侧验证: + +- ESP-IDF 5.5.4 完整构建通过。 +- 当前构建生成的应用固件约 2.08 MiB,应用分区剩余约 41%。 +- `standby_5min.opus` 已纳入 LittleFS 音频资源。 +- 源码差异格式检查通过。 +- 已确认提示音等待的是播放管线终止事件,而不是固定时长硬等待。 + +当前尚未完成以下真机量化测试: + +- 活跃状态和轻待机状态的整机电流对比。 +- 各单项策略分别节省的电流。 +- 不同 AP/DTIM 条件下的网络首包延迟。 +- 唤醒词、按键和抽签从触发到完全恢复的耗时。 +- PA 恢复后第一段声音是否存在削头或延迟。 + +因此,目前只能确认节电机制已生效,不能在没有仪器数据的情况下声称降低了具体多少 mA 或百分比。 + +## 9. 后续影响与剩余优化空间 + +### 9.1 明确的行为影响 + +| 项目 | 后续影响 | +| --- | --- | +| LCD | 画面继续刷新,背光变暗,但 LCD 控制器、SPI 和渲染功耗仍存在 | +| 扬声器 | 待机无声音输出;唤醒后 PA 需要短暂恢复时间 | +| 麦克风 | 保持运行,以满足唤醒词快速唤醒要求 | +| 电机 | 待机不保持静态扭矩,机构可能被外力移动 | +| Wi-Fi | 保持连接,但首包延迟可能增加 | +| 远程日志 | 继续采集和落盘,服务端接收会延迟到唤醒后 | +| 电池检测 | 普通电量更新变慢,充电状态边沿仍及时检测 | +| 内存监控 | 普通水位报告变慢,分配失败仍及时处理 | +| 提示音 | 真正进入待机的时间会比 5 分钟多出提示音播放时间 | +| BLE | 配网期间禁止进入轻待机,因此本次不修改 BLE 广播 | + +### 9.2 仍可继续优化的项目 + +在“不关闭基础表情”和“不关闭唤醒词”的约束下,仍有以下优化空间: + +1. 避免 LCD 重复传输完全相同的画面。 +2. 轻待机时降低基础表情动画帧率,或停在静态基础帧。 +3. 在确保提示音和唤醒后首包完整的前提下,进一步暂停 I2S TX、解码器和下行音频任务。 +4. 继续检查任务监控、网络保活、传感器和其他周期诊断任务的唤醒频率。 +5. 通过功耗仪逐项关闭策略,建立各模块的实际 mA 收益表,再决定优化优先级。 + +受持续显示和持续唤醒词约束,ESP Light Sleep/Deep Sleep 目前不适合直接使用。若未来允许基础表情静止、LCD 使用自刷新能力,或增加独立低功耗唤醒芯片,才有机会进入更深的睡眠状态并获得数量级更高的节电收益。 + +## 10. 结论 + +本次改动已经完成当前产品约束下的主要轻待机优化:保留基础表情、唤醒词和按键响应,同时降低背光、功放、电机、Wi-Fi、远程日志、电池检测和内存监控的持续功耗。实现具备统一状态管理、真实音频完成等待、进入失败回滚和快速唤醒恢复。 + +下一步重点不是继续盲目关闭模块,而是进行真机电流和唤醒延迟测试。测试数据可以确认主要耗电来源,并判断 LCD 刷新和音频下行链路是否值得进入下一轮优化。 diff --git a/docs/network-outage-voice-feedback.zh-CN.md b/docs/network-outage-voice-feedback.zh-CN.md new file mode 100644 index 0000000..27a6e31 --- /dev/null +++ b/docs/network-outage-voice-feedback.zh-CN.md @@ -0,0 +1,557 @@ +# 网络异常语音反馈与业务快速失败改造说明 + +## 1. 提交概况 + +```text +提交:ceb07804254a257b17db291c8198567f5d265c04 +标题:feat(control-plane): add network outage voice feedback +日期:2026-08-25 +``` + +本次提交为设备增加网络异常语音反馈,并调整断网时的对话和云端抽签行为。改造后的核心原则是: + +1. 被动断网时只提示一次,避免网络抖动造成连续播报。 +2. 用户在网络不可用时发起对话或抽签,立即得到语音反馈,不再无提示等待。 +3. Wi-Fi 恢复不等于业务恢复,只有服务器连接恢复后才播放业务恢复提示。 +4. 保留后台自动重连、Wi-Fi 多凭据遍历以及空闲 WebSocket 唤醒机制。 + +## 2. 问题原因 + +### 2.1 Wi-Fi 断开后只有视觉反馈 + +原有运行时能够检测 Wi-Fi STA 断开,并在断开持续超过 3 秒后显示 Wi-Fi 断开表情、停止旧多模态客户端并启动重连。 + +但该路径没有接入提示音。用户没有看屏幕时,无法知道设备已经断网以及设备正在自动修复网络。 + +### 2.2 离线对话会进入无效等待 + +原有对话入口会先进入聆听状态,再等待 Wi-Fi 和 WebSocket 可用。在点击对话模式下,整个捕获窗口约为 8 秒;如果网络始终不可用,操作最终结束,但没有明确的“网络不可用”语音反馈。 + +用户感知是:设备似乎开始聆听,但没有回复,也不知道失败原因。 + +### 2.3 离线抽签会排队后静默失败 + +原有云端抽签请求可以在服务器未连接时先写入待处理状态,然后等待多模态会话建立。准备阶段最多等待约 10 秒,超时后清除请求。 + +如果设备已经明确断网,继续排队没有实际价值,而且请求被清除时没有用户可感知的反馈。 + +### 2.4 Wi-Fi 断开和服务器断开没有统一反馈状态 + +项目中存在以下不同状态: + +- Wi-Fi 与路由器断开; +- Wi-Fi 已关联,但公网、DNS、HTTPS 或服务器不可用; +- WebSocket 空闲超时后主动关闭; +- WebSocket 异常断开; +- Wi-Fi 已恢复,但服务器尚未完成重新鉴权; +- Wi-Fi 与服务器均已恢复。 + +原有代码分别处理这些状态,但没有统一记录“是否发生过故障、是否已经播报、是否允许播放恢复提示”。因此直接在多个回调中增加提示音,容易出现重复播报、恢复音过早播放或把正常节能关闭误判为故障。 + +### 2.5 提示音播放是同步阻塞操作 + +本地 Opus 提示音接口会读取文件、提交音频帧并等待播放完成。如果直接在 WebSocket 回调中播放,会阻塞网络事件处理线程,扩大断线期间的并发风险。 + +因此服务器回调只能记录状态,提示音需要由现有运行时任务在安全上下文中消费。 + +## 3. 解决方式 + +### 3.1 增加统一网络反馈状态机 + +新增控制层网络反馈模块,集中维护网络服务状态、待播放提示、故障是否已经播报、提示冷却时间和服务器断开时间。 + +```c +typedef enum { + NETWORK_FEEDBACK_STATE_BOOTING = 0, + NETWORK_FEEDBACK_STATE_READY, + NETWORK_FEEDBACK_STATE_WIFI_DOWN, + NETWORK_FEEDBACK_STATE_SERVER_DOWN, +} network_feedback_state_t; + +typedef enum { + NETWORK_FEEDBACK_PROMPT_NONE = 0, + NETWORK_FEEDBACK_PROMPT_WIFI_INTERRUPTED, + NETWORK_FEEDBACK_PROMPT_SERVER_INTERRUPTED, + NETWORK_FEEDBACK_PROMPT_RESTORED, +} network_feedback_prompt_t; + +typedef struct { + network_feedback_state_t state; + network_feedback_prompt_t pending_prompt; + bool service_ever_ready; + bool outage_active; + bool outage_announced; + bool passive_prompt_attempted; + bool prompt_in_progress; + int64_t server_down_since_us; + int64_t last_fault_prompt_us; +} network_feedback_runtime_t; + +static network_feedback_runtime_t s_feedback; +static portMUX_TYPE s_feedback_lock = portMUX_INITIALIZER_UNLOCKED; +``` + +状态由自旋锁保护,可以安全地在 Wi-Fi 监控任务、按键任务和多模态回调之间共享。模块没有增加新的后台任务,而是复用现有 Wi-Fi 状态监控任务定期处理待播放提示。 + +### 3.2 复用现有音频资源 + +本次没有新增录音文件,直接复用 LittleFS 中已有的五个 Opus 资源: + +```c +#define CONTROL_PLANE_WIFI_INTERRUPTED_PROMPT_FILE \ + "/littlefs/audio_prompts/network_interrupted_repairing.opus" +#define CONTROL_PLANE_WIFI_ACTION_BLOCKED_PROMPT_FILE \ + "/littlefs/audio_prompts/network_unstable.opus" +#define CONTROL_PLANE_SERVER_INTERRUPTED_PROMPT_FILE \ + "/littlefs/audio_prompts/server_abnormal_interrupted.opus" +#define CONTROL_PLANE_SERVER_ACTION_BLOCKED_PROMPT_FILE \ + "/littlefs/audio_prompts/server_repair_wait_reopen.opus" +#define CONTROL_PLANE_NETWORK_RESTORED_PROMPT_FILE \ + "/littlefs/audio_prompts/signal_restored_business_resume.opus" +``` + +对应关系如下: + +| 场景 | 提示音资源 | +| --- | --- | +| Wi-Fi 持续断开 | `network_interrupted_repairing.opus` | +| 用户在 Wi-Fi 断开时操作 | `network_unstable.opus` | +| 服务器持续断开 | `server_abnormal_interrupted.opus` | +| 用户在服务器断开时操作 | `server_repair_wait_reopen.opus` | +| 网络与服务器均恢复 | `signal_restored_business_resume.opus` | + +### 3.3 Wi-Fi 持续断开时播放一次提示 + +Wi-Fi 原有 3 秒断开保护期保持不变。只有断开持续超过保护期、准备停止旧业务客户端时,才将反馈状态标记为 `WIFI_DOWN`。 + +```c +if (!disconnected_expression_active) { + ESP_LOGI(TAG, "Wi-Fi STA disconnect persisted; stopping multimodal client"); + control_plane_network_feedback_mark_wifi_down(); + (void)control_plane_stop_multimodal_client(); + control_plane_stop_user_config_server(); + control_plane_network_feedback_service(); +} +``` + +标记 Wi-Fi 断开时,只在本次故障尚未播报的情况下生成一个待播放提示: + +```c +void control_plane_network_feedback_mark_wifi_down(void) +{ + portENTER_CRITICAL(&s_feedback_lock); + control_plane_network_feedback_begin_outage_locked(); + s_feedback.state = NETWORK_FEEDBACK_STATE_WIFI_DOWN; + s_feedback.server_down_since_us = 0; + if (!s_feedback.outage_announced) { + s_feedback.pending_prompt = NETWORK_FEEDBACK_PROMPT_WIFI_INTERRUPTED; + s_feedback.passive_prompt_attempted = true; + } + portEXIT_CRITICAL(&s_feedback_lock); +} +``` + +Wi-Fi 断开表情仍可按原逻辑刷新,但提示音不会随表情刷新重复播放。 + +### 3.4 服务器异常保留 3 秒自恢复时间 + +WebSocket 连接成功、断开和错误事件接入统一反馈状态: + +```c +switch (event) { + case CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_CONNECTED: + control_plane_network_feedback_mark_service_ready(); + control_plane_schedule_server_success_prompt(); + break; + + case CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_DISCONNECTED: + control_plane_network_feedback_mark_server_down(); + control_plane_clear_downstream_stall_recovery(); + control_plane_clear_cloud_chouqian_pending("transport disconnected"); + control_plane_multimodal_stop_uplink(); + (void)control_plane_schedule_websocket_wait_task(); + break; + + case CONTROL_PLANE_MULTIMODAL_EVENT_TRANSPORT_ERROR: + control_plane_network_feedback_mark_server_down(); + control_plane_clear_downstream_stall_recovery(); + control_plane_clear_cloud_chouqian_pending("transport error"); + control_plane_multimodal_stop_uplink(); + (void)control_plane_schedule_websocket_wait_task(); + break; +} +``` + +服务器断开不会立即播报,而是记录断开时间。超过 3 秒仍未恢复时,才安排服务器异常提示: + +```c +#define CONTROL_PLANE_SERVER_DISCONNECT_GRACE_MS 3000U + +if (s_feedback.state == NETWORK_FEEDBACK_STATE_SERVER_DOWN && + s_feedback.service_ever_ready && + s_feedback.outage_active && + !s_feedback.outage_announced && + !s_feedback.passive_prompt_attempted && + s_feedback.server_down_since_us > 0 && + now_us - s_feedback.server_down_since_us >= + (int64_t)CONTROL_PLANE_SERVER_DISCONNECT_GRACE_MS * 1000LL) { + s_feedback.pending_prompt = NETWORK_FEEDBACK_PROMPT_SERVER_INTERRUPTED; + s_feedback.passive_prompt_attempted = true; +} +``` + +这样可以过滤短暂 WebSocket 抖动,避免服务器在几秒内自行恢复时仍播放异常提示。 + +### 3.5 对话和抽签统一执行网络可用性检查 + +新增两类需要网络的用户操作: + +```c +typedef enum { + CONTROL_PLANE_NETWORK_ACTION_CONVERSATION = 0, + CONTROL_PLANE_NETWORK_ACTION_CLOUD_CHOUQIAN, +} control_plane_network_action_t; +``` + +对话入口在显示聆听表情和创建语音会话之前检查网络: + +```c +control_plane_local_wake_pause_for_foreground(); +if (control_plane_network_feedback_require_ready( + CONTROL_PLANE_NETWORK_ACTION_CONVERSATION) != ESP_OK) { + ESP_LOGI(TAG, "PTT activation rejected while network service is unavailable"); + control_plane_local_wake_resume_after_foreground(); + return; +} +``` + +云端抽签入口在写入待处理请求之前检查网络: + +```c +esp_err_t network_err = control_plane_network_feedback_require_ready( + CONTROL_PLANE_NETWORK_ACTION_CLOUD_CHOUQIAN); +if (network_err != ESP_OK) { + return network_err; +} +``` + +统一检查函数根据故障层级选择提示音,并决定触发 Wi-Fi 重连还是服务器重连: + +```c +bool wifi_connected = control_plane_is_sta_connected(); +bool server_connected = wifi_connected && control_plane_multimodal_is_connected(); +if (server_connected) { + return ESP_OK; +} + +portENTER_CRITICAL(&s_feedback_lock); +bool confirmed_outage = s_feedback.state == NETWORK_FEEDBACK_STATE_WIFI_DOWN || + s_feedback.state == NETWORK_FEEDBACK_STATE_SERVER_DOWN; +portEXIT_CRITICAL(&s_feedback_lock); + +if (wifi_connected && !confirmed_outage) { + return ESP_OK; +} + +if (!wifi_connected) { + prompt_path = CONTROL_PLANE_WIFI_ACTION_BLOCKED_PROMPT_FILE; +} else { + prompt_path = CONTROL_PLANE_SERVER_ACTION_BLOCKED_PROMPT_FILE; +} + +if (should_play) { + (void)control_plane_network_feedback_play(prompt_path, + control_plane_network_action_name(action)); +} + +if (wifi_connected) { + (void)control_plane_schedule_websocket_wait_task(); +} else { + control_plane_request_wifi_reconnect_now(); +} +return ESP_ERR_INVALID_STATE; +``` + +本次操作会明确结束,不会继续排队。用户需要在网络恢复后重新发起对话或抽签。 + +### 3.6 用户操作可加速 Wi-Fi 重连 + +新增一个受锁保护的立即重连请求标志。离线操作提示播放完成后,唤醒 Wi-Fi 状态任务: + +```c +void control_plane_request_wifi_reconnect_now(void) +{ + portENTER_CRITICAL(&s_runtime_lock); + if (!s_runtime_stop_requested) { + s_wifi_reconnect_now_requested = true; + } + portEXIT_CRITICAL(&s_runtime_lock); + control_plane_notify_wifi_state_task(); +} +``` + +Wi-Fi 状态任务消费该标志,并允许本轮跳过剩余保护期或退避等待: + +```c +bool reconnect_now_requested = + control_plane_consume_wifi_reconnect_now_request(); + +bool disconnect_grace_elapsed = + reconnect_now_requested || + ptt_foreground_disconnect || + disconnected_expression_active || + (now_tick - disconnect_started_tick) >= disconnect_grace_ticks; + +bool reconnect_due = ptt_foreground || + reconnect_now_requested || + !reconnect_attempted || + (now_tick - last_reconnect_attempt_tick) >= reconnect_delay_ticks; +``` + +这里的“立即”表示立即唤醒并允许开始下一轮连接,不代表 Wi-Fi 可以瞬间恢复。扫描、认证、关联和 DHCP 仍受原有 20 秒连接周期约束。 + +### 3.7 增加 5 秒提示冷却和播放互斥 + +所有网络故障提示共享 5 秒冷却时间,并通过 `prompt_in_progress` 防止并发播放: + +```c +#define CONTROL_PLANE_NETWORK_PROMPT_COOLDOWN_MS 5000U + +static bool control_plane_network_feedback_begin_prompt_locked(int64_t now_us) +{ + if (s_feedback.prompt_in_progress) { + return false; + } + if (s_feedback.last_fault_prompt_us > 0 && + now_us - s_feedback.last_fault_prompt_us < + (int64_t)CONTROL_PLANE_NETWORK_PROMPT_COOLDOWN_MS * 1000LL) { + return false; + } + + s_feedback.prompt_in_progress = true; + s_feedback.last_fault_prompt_us = now_us; + s_feedback.outage_announced = true; + return true; +} +``` + +用户连续点击对话和抽签时,第一次操作可以播放提示,冷却期内后续操作仍会被快速拒绝和触发恢复,但不会重复播音。 + +### 3.8 只有业务真正恢复后才播放恢复提示 + +Wi-Fi 获得 IP 后,只把状态从 `WIFI_DOWN` 转换为 `SERVER_DOWN`,不会立刻播放恢复音: + +```c +void control_plane_network_feedback_mark_wifi_recovered(void) +{ + int64_t now_us = control_plane_network_feedback_now_us(); + portENTER_CRITICAL(&s_feedback_lock); + if (s_feedback.state == NETWORK_FEEDBACK_STATE_WIFI_DOWN) { + s_feedback.state = NETWORK_FEEDBACK_STATE_SERVER_DOWN; + s_feedback.server_down_since_us = now_us; + s_feedback.pending_prompt = NETWORK_FEEDBACK_PROMPT_NONE; + } + portEXIT_CRITICAL(&s_feedback_lock); +} +``` + +只有 WebSocket 再次连接成功,并且设备此前确实完成过服务器连接、当前故障也已经对用户播报,才安排业务恢复提示: + +```c +void control_plane_network_feedback_mark_service_ready(void) +{ + portENTER_CRITICAL(&s_feedback_lock); + bool should_announce_recovery = s_feedback.service_ever_ready && + s_feedback.outage_active && + s_feedback.outage_announced; + s_feedback.service_ever_ready = true; + s_feedback.state = NETWORK_FEEDBACK_STATE_READY; + s_feedback.outage_active = false; + s_feedback.passive_prompt_attempted = false; + s_feedback.server_down_since_us = 0; + s_feedback.pending_prompt = should_announce_recovery ? + NETWORK_FEEDBACK_PROMPT_RESTORED : + NETWORK_FEEDBACK_PROMPT_NONE; + portEXIT_CRITICAL(&s_feedback_lock); +} +``` + +开机首次联网不会播放“业务恢复”,仍使用原有服务器连接成功提示。 + +### 3.9 保留空闲 WebSocket 唤醒行为 + +设备会在多模态连接空闲约 30 秒后主动关闭 WebSocket以节省资源。这属于正常状态,不是服务器故障。 + +网络检查只在状态机已经确认 `WIFI_DOWN` 或 `SERVER_DOWN` 时快速拒绝操作。如果 Wi-Fi 已连接、WebSocket 只是正常空闲关闭,则允许原有对话流程继续,并由首次操作唤醒服务器连接: + +```c +if (wifi_connected && !confirmed_outage) { + return ESP_OK; +} +``` + +该判断避免把正常节能行为误判为服务器异常。 + +### 3.10 OTA 和 BLE 配网期间抑制被动网络提示 + +网络反馈服务在 OTA 不允许语音服务或小程序 BLE 配网运行时直接返回: + +```c +if (!control_plane_ota_voice_services_allowed() || + control_plane_is_mini_program_provisioning_running()) { + return; +} +``` + +这样可以避免网络提示音打断系统升级、配网引导和相应的专用提示音。 + +## 4. 解决后的完整体验 + +### 4.1 Wi-Fi 真正断开 + +```text +Wi-Fi 链路断开 +-> 等待原有 3 秒保护期 +-> 3 秒内恢复:不显示断网状态,不播放提示 +-> 超过 3 秒:停止旧业务连接并显示断网表情 +-> 播放一次“网络中断,正在修复” +-> 遍历可见的已保存 Wi-Fi 并自动重连 +``` + +### 4.2 用户在 Wi-Fi 断开时操作 + +```text +用户发起对话或云端抽签 +-> 播放“网络不稳定”提示,5 秒内不重复播放 +-> 不进入聆听或抽签排队状态 +-> 本次操作立即结束 +-> 唤醒 Wi-Fi 状态任务并加速下一轮重连 +``` + +### 4.3 Wi-Fi 正常但服务器异常 + +```text +WebSocket 断开或服务器连接失败 +-> 记录服务器异常并重新鉴权 +-> 3 秒内恢复:不播放异常提示 +-> 超过 3 秒:播放一次服务器异常提示 +-> 用户继续操作:播放服务器修复提示并结束本次操作 +-> 立即安排新的服务器连接 +``` + +### 4.4 网络恢复 + +```text +Wi-Fi 获得 IP +-> 恢复局域网状态,但暂不播放业务恢复提示 +-> 重新鉴权并建立 WebSocket +-> WebSocket 连接成功 +-> 若本次故障此前已经播报,则播放一次“信号恢复,业务恢复” +-> 用户可以重新发起对话或抽签 +``` + +## 5. 解决成果 + +### 5.1 用户体验成果 + +- 持续断网不再只有屏幕表情,用户可以通过语音获知设备正在修复网络。 +- 离线对话不再先进入假聆听状态后静默结束。 +- 离线云端抽签不再排队约 10 秒后静默失败。 +- Wi-Fi 故障和服务器故障使用不同语音,用户能区分问题层级。 +- 连续点击不会重复播音,避免提示音轰炸。 +- Wi-Fi 恢复后不会提前宣布业务恢复,恢复提示以 WebSocket 真正可用为准。 +- 正常的空闲 WebSocket 关闭仍能由首次业务操作唤醒,不受快速失败策略影响。 + +### 5.2 工程成果 + +- 网络反馈策略集中在控制层单一模块中,没有把状态分散到按键、Wi-Fi 和 WebSocket 实现。 +- 多任务共享状态由自旋锁保护。 +- WebSocket 回调只更新状态,不执行同步音频播放。 +- 没有新增长期运行任务、定时器或跨层依赖。 +- 没有新增音频资产,直接复用已打包资源。 +- 原有 Wi-Fi 多凭据、20 秒连接周期、指数退避、BLE 重新配网和服务器鉴权机制保持不变。 + +### 5.3 构建验证 + +使用 ESP-IDF 5.5.4 完成完整固件构建: + +```text +Project build complete +TQ_Cat_Code.bin binary size 0x217d50 bytes +Smallest app partition: 0x380000 bytes +Free space: 0x1682b0 bytes(40%) +``` + +构建阶段确认: + +- 新增网络反馈模块编译通过; +- 固件链接通过; +- 五个提示音均进入 LittleFS 镜像; +- 应用分区容量检查通过; +- Markdown 格式和空白错误检查通过。 + +## 6. 后续影响与限制 + +### 6.1 提示音会推迟重连开始时间 + +提示音接口会同步等待播放完成。Wi-Fi 被动断开和用户主动离线操作路径会先完成提示播放,再继续或加速重连。 + +这样可以避免提示音与多模态会话销毁、音频初始化同时运行,但相应重连会比完全静默时晚一个提示音时长开始。该延迟不计入 Wi-Fi 单轮 20 秒连接预算。 + +### 6.2 离线操作不会自动补执行 + +断网时发起的对话和云端抽签会立即结束,不会在网络恢复后自动执行。 + +这是有意设计:自动补执行可能在几十秒后突然触发已经失去上下文的抽签;对话音频也无法在长时间断网后可靠重放。恢复后需要用户重新操作。 + +### 6.3 所有网络故障提示共享冷却时间 + +Wi-Fi 异常、服务器异常、离线对话和离线抽签共用 5 秒故障提示冷却。冷却期内状态处理和重连仍会执行,但不会播放第二条提示。 + +因此,如果 Wi-Fi 故障刚播报后立即转变为服务器故障,服务器提示可能被冷却时间抑制。这是为了优先保证设备安静和避免连续播报。 + +### 6.4 恢复提示有严格前置条件 + +只有同时满足以下条件才会播放业务恢复提示: + +1. 设备本次运行中曾成功连接过服务器。 +2. 当前故障已经对用户播放过异常提示。 +3. WebSocket 已重新连接成功。 + +如果故障很短、异常提示被 OTA 或 BLE 配网抑制,恢复后不会额外播放恢复音。这可以避免用户只听到“恢复”却不知道之前发生过什么。 + +### 6.5 服务器异常识别依赖现有事件 + +服务器故障主要由 WebSocket 断开、WebSocket 错误或多模态客户端启动失败进行标记。如果某种网络异常没有触发这些事件,也没有产生现有 HTTPS 网络错误,则可能延迟进入 `SERVER_DOWN` 状态。 + +后续可以结合心跳超时、DNS 错误分类和 HTTPS 错误统计进一步增强服务器可用性判定,但不建议仅凭 Wi-Fi 已关联状态判断业务可用。 + +### 6.6 尚需真机验证音频与时序 + +当前已经完成编译、链接、资源打包和静态检查,但仍需要真机覆盖以下场景: + +1. 路由器断电超过 3 秒,确认断网提示只播放一次。 +2. Wi-Fi 在 3 秒内恢复,确认不播放断网提示。 +3. 断网时连续点击对话和抽签,确认立即结束且 5 秒内不重复播音。 +4. Wi-Fi 恢复但 WebSocket 尚未恢复,确认不提前播放业务恢复提示。 +5. WebSocket 短暂断开少于 3 秒,确认不播放服务器异常提示。 +6. WebSocket 持续断开超过 3 秒,确认播放一次服务器异常提示。 +7. 服务器异常时发起业务操作,确认播放服务器修复提示并重新鉴权。 +8. WebSocket 最终恢复,确认业务恢复提示只播放一次。 +9. 空闲 WebSocket 主动关闭后发起对话,确认正常唤醒连接而不是被拒绝。 +10. OTA 和 BLE 配网过程中制造网络变化,确认不会插入普通网络提示音。 +11. 提示音播放过程中恢复 Wi-Fi,确认不会出现音频冲突或重复恢复播报。 + +### 6.7 固件容量变化 + +新增的是状态管理和接线代码,没有新增音频文件。相比前一版构建,应用二进制由 `0x217430` 增长到 `0x217d50`,增加 `0x920` 字节,即 2336 字节。 + +当前最小应用分区仍有 `0x1682b0` 字节可用,约占分区的 40%,不存在近期容量风险。 + +## 7. 结论 + +本次提交解决了网络异常状态下“用户不知道设备发生了什么”和“网络业务静默等待后失败”两个主要体验问题。 + +设备现在能够区分 Wi-Fi 断开与服务器断开,在合适的时机播放一次提示,并在用户主动操作时快速反馈和触发恢复。恢复提示以服务器业务连接真正可用为准,同时保留正常的空闲 WebSocket 唤醒、Wi-Fi 多凭据重连、OTA 和 BLE 配网行为。 + +后续主要风险不在编译或容量,而在真实网络抖动、音频播放时长和多种恢复时序组合。应通过上述真机测试确认提示音频率、恢复延迟和用户感知是否符合产品预期。 diff --git a/tools/firmware_info.sh b/tools/firmware_info.sh new file mode 100755 index 0000000..bc4219f --- /dev/null +++ b/tools/firmware_info.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIRMWARE_PATH="${1:-${ROOT_DIR}/build/TQ_Cat_Code.bin}" +VERSION_FILE="${ROOT_DIR}/version.txt" + +fail() { + echo "Error: $1" >&2 + exit 1 +} + +if [[ ! -f "${FIRMWARE_PATH}" ]]; then + fail "firmware binary not found: ${FIRMWARE_PATH}" +fi + +# ESP-IDF places esp_app_desc_t at image offset 0x20. Its magic word is +# 0xABCD5432 and its 32-byte application version starts at offset 0x30. +APP_DESC_MAGIC="$(od -An -tx1 -j 32 -N 4 "${FIRMWARE_PATH}" | tr -d '[:space:]')" +if [[ "${APP_DESC_MAGIC}" != "3254cdab" ]]; then + fail "not a supported ESP-IDF application image: ${FIRMWARE_PATH}" +fi + +FIRMWARE_VERSION="$( + dd if="${FIRMWARE_PATH}" bs=1 skip=48 count=32 2>/dev/null | + LC_ALL=C tr -d '\000' +)" +if [[ -z "${FIRMWARE_VERSION}" ]]; then + fail "firmware version is empty: ${FIRMWARE_PATH}" +fi + +SIZE_BYTES="$(wc -c < "${FIRMWARE_PATH}" | tr -d '[:space:]')" +if command -v sha256sum >/dev/null 2>&1; then + SHA256="$(sha256sum "${FIRMWARE_PATH}" | awk '{print $1}')" +elif command -v shasum >/dev/null 2>&1; then + SHA256="$(shasum -a 256 "${FIRMWARE_PATH}" | awk '{print $1}')" +else + fail "neither sha256sum nor shasum is available" +fi + +echo "version=${FIRMWARE_VERSION}" +echo "sizeBytes=${SIZE_BYTES}" +echo "sha256=${SHA256}" + +if [[ -f "${VERSION_FILE}" ]]; then + SOURCE_VERSION="$(tr -d '\r\n' < "${VERSION_FILE}")" + if [[ -n "${SOURCE_VERSION}" && "${SOURCE_VERSION}" != "${FIRMWARE_VERSION}" ]]; then + echo "Warning: version.txt=${SOURCE_VERSION}, but the binary contains ${FIRMWARE_VERSION}; rebuild the firmware." >&2 + fi +fi diff --git a/tools/play_prompt_opus.command b/tools/play_prompt_opus.command new file mode 100755 index 0000000..fbb4b4b --- /dev/null +++ b/tools/play_prompt_opus.command @@ -0,0 +1,33 @@ +#!/bin/zsh + +set -u + +SCRIPT_DIR="${0:A:h}" +PLAYER="$SCRIPT_DIR/play_prompt_opus.py" + +if [[ $# -gt 0 ]]; then + PROMPT_FILE="$1" +else + PROMPT_FILE="$(osascript <<'APPLESCRIPT' +try + POSIX path of (choose file with prompt "Choose a TQ_CatN prompt Opus file") +on error number -128 + return "" +end try +APPLESCRIPT +)" +fi + +if [[ -z "$PROMPT_FILE" ]]; then + exit 0 +fi + +python3 "$PLAYER" "$PROMPT_FILE" +RESULT=$? +if [[ $RESULT -ne 0 ]]; then + echo + echo "Playback failed. Install FFmpeg with: brew install ffmpeg" + echo "Press Enter to close." + read -r +fi +exit $RESULT diff --git a/tools/play_prompt_opus.py b/tools/play_prompt_opus.py new file mode 100755 index 0000000..d2f04f3 --- /dev/null +++ b/tools/play_prompt_opus.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Play or convert the firmware's length-prefixed raw Opus prompt files.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tempfile + + +MAX_PACKET_BYTES = 2_048 +OGG_SIGNATURE = b"OggS" +OPUS_CLOCK_HZ = 48_000 +PROMPT_FRAME_MS = 60 +OGG_STREAM_SERIAL = 0x54514341 + + +class PromptError(RuntimeError): + """Raised when a prompt file does not match the firmware format.""" + + +def read_prompt_packets(path: Path) -> list[bytes]: + packets: list[bytes] = [] + with path.open("rb") as stream: + while True: + header = stream.read(4) + if not header: + break + if len(header) != 4: + raise PromptError("truncated 4-byte packet header") + packet_size = struct.unpack(">I", header)[0] + if packet_size == 0 or packet_size > MAX_PACKET_BYTES: + raise PromptError(f"invalid Opus packet size: {packet_size}") + packet = stream.read(packet_size) + if len(packet) != packet_size: + raise PromptError("truncated Opus packet payload") + packets.append(packet) + if not packets: + raise PromptError("prompt contains no Opus packets") + return packets + + +def ogg_crc(data: bytes) -> int: + crc = 0 + for value in data: + crc ^= value << 24 + for _ in range(8): + crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF if crc & 0x80000000 else (crc << 1) & 0xFFFFFFFF + return crc + + +def packet_lacing(packet_size: int) -> bytes: + segments = bytearray() + remaining = packet_size + while remaining >= 255: + segments.append(255) + remaining -= 255 + segments.append(remaining) + return bytes(segments) + + +def make_ogg_page(packet: bytes, flags: int, granule: int, sequence: int) -> bytes: + lacing = packet_lacing(len(packet)) + if len(lacing) > 255: + raise PromptError("Opus packet requires too many Ogg segments") + header = bytearray() + header.extend(OGG_SIGNATURE) + header.extend((0, flags)) + header.extend(struct.pack(" None: + opus_head = b"OpusHead" + struct.pack(" None: + if not input_path.is_file(): + raise PromptError(f"file not found: {input_path}") + if input_path.read_bytes()[:4] == OGG_SIGNATURE: + shutil.copyfile(input_path, output_path) + return + write_ogg(output_path, read_prompt_packets(input_path)) + + +def play_prompt(input_path: Path, ffplay: str) -> None: + executable = shutil.which(ffplay) + if executable is None: + raise PromptError("ffplay is not installed; run: brew install ffmpeg") + with tempfile.TemporaryDirectory(prefix="tq_prompt_player_") as temp_dir: + ogg_path = Path(temp_dir) / f"{input_path.stem}.ogg" + convert_prompt(input_path, ogg_path) + subprocess.run( + [executable, "-hide_banner", "-loglevel", "warning", "-nodisp", "-autoexit", str(ogg_path)], + check=True, + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input", type=Path, help="firmware prompt .opus file") + parser.add_argument("--output", type=Path, help="write standard Ogg Opus instead of playing") + parser.add_argument("--ffplay", default="ffplay", help="ffplay executable name or path") + args = parser.parse_args() + + try: + if args.output is not None: + convert_prompt(args.input, args.output) + print(f"Created standard Ogg Opus: {args.output}") + else: + play_prompt(args.input, args.ffplay) + except (PromptError, OSError, subprocess.CalledProcessError) as error: + print(f"Error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/view_flash_logs.command b/tools/view_flash_logs.command new file mode 100755 index 0000000..5a70355 --- /dev/null +++ b/tools/view_flash_logs.command @@ -0,0 +1,198 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +LOGFS_OFFSET="0x210000" +LOGFS_SIZE="0x40000" +LOGFS_BLOCK_SIZE="4096" + +pause_before_exit() { + if [[ -t 0 ]]; then + printf '\n按回车键关闭窗口...' + read -r _ + fi +} + +fail() { + printf '\n错误:%s\n' "$1" >&2 + pause_before_exit + exit 1 +} + +find_serial_port() { + local requested_port="${1:-}" + if [[ -n "$requested_port" ]]; then + [[ -e "$requested_port" ]] || fail "串口不存在:$requested_port" + printf '%s' "$requested_port" + return + fi + + local ports=() + local candidate + for candidate in /dev/cu.usbmodem* /dev/cu.usbserial* \ + /dev/cu.SLAB_USBtoUART* /dev/cu.wchusbserial*; do + [[ -e "$candidate" ]] && ports+=("$candidate") + done + + if [[ ${#ports[@]} -eq 0 ]]; then + fail "没有检测到设备串口。请连接设备后重试。" + fi + if [[ ${#ports[@]} -eq 1 ]]; then + printf '%s' "${ports[0]}" + return + fi + + printf '检测到多个串口:\n' >&2 + local index + for ((index = 0; index < ${#ports[@]}; index++)); do + printf ' %d) %s\n' "$((index + 1))" "${ports[index]}" >&2 + done + printf '请选择序号:' >&2 + local selection + read -r selection + [[ "$selection" =~ ^[0-9]+$ ]] || fail "输入的序号无效。" + ((selection >= 1 && selection <= ${#ports[@]})) || fail "输入的序号超出范围。" + printf '%s' "${ports[selection - 1]}" +} + +find_build_tool() { + local relative_path="$1" + local candidate="$PROJECT_DIR/$relative_path" + if [[ -x "$candidate" ]]; then + printf '%s' "$candidate" + return + fi + return 1 +} + +find_idf_value() { + local key="$1" + local cache="$PROJECT_DIR/build/CMakeCache.txt" + [[ -f "$cache" ]] || return 1 + sed -n "s|^${key}:[^=]*=||p" "$cache" | head -n 1 +} + +find_idf_python() { + local candidate + candidate="$(find_idf_value PYTHON || true)" + if [[ -n "$candidate" && -x "$candidate" ]]; then + printf '%s' "$candidate" + return + fi + + for candidate in "$HOME"/.espressif/tools/python/*/venv/bin/python; do + if [[ -x "$candidate" ]]; then + printf '%s' "$candidate" + return + fi + done + return 1 +} + +find_esptool() { + local idf_path + idf_path="$(find_idf_value esp-idf_SOURCE_DIR || true)" + local candidate="$idf_path/components/esptool_py/esptool/esptool.py" + if [[ -n "$idf_path" && -f "$candidate" ]]; then + printf '%s' "$candidate" + return + fi + + for candidate in "$HOME"/.espressif/v*/esp-idf/components/esptool_py/esptool/esptool.py; do + if [[ -f "$candidate" ]]; then + printf '%s' "$candidate" + return + fi + done + return 1 +} + +PORT="$(find_serial_port "${1:-}")" +IDF_PYTHON="$(find_idf_python || true)" +ESPTOOL="$(find_esptool || true)" +LITTLEFS_TOOL="$(find_build_tool build/littlefs_py_venv/bin/littlefs-python || true)" + +[[ -n "$IDF_PYTHON" ]] || fail "找不到 ESP-IDF Python 环境,请先成功构建一次工程。" +[[ -n "$ESPTOOL" ]] || fail "找不到 esptool.py,请检查 ESP-IDF 是否已安装。" +[[ -n "$LITTLEFS_TOOL" ]] || fail "找不到 LittleFS 解包工具,请先成功构建一次工程。" + +TIMESTAMP="$(date '+%Y%m%d-%H%M%S')" +OUTPUT_DIR="$PROJECT_DIR/flash_log_dump/$TIMESTAMP" +IMAGE_PATH="$OUTPUT_DIR/logfs.bin" +EXTRACT_DIR="$OUTPUT_DIR/extracted" +REPORT_PATH="$OUTPUT_DIR/remote_logs.txt" +mkdir -p "$EXTRACT_DIR" + +printf '设备串口:%s\n' "$PORT" +printf '输出目录:%s\n' "$OUTPUT_DIR" +printf '\n注意:请先关闭正在使用该串口的 monitor。\n' +printf '读取 Flash 会让设备进入下载模式,完成后设备会自动重启。\n' +printf '正在读取 logfs 分区,请勿拔线...\n\n' + +if ! "$IDF_PYTHON" "$ESPTOOL" \ + --chip esp32s3 \ + --port "$PORT" \ + --baud 460800 \ + --before default_reset \ + --after hard_reset \ + read_flash "$LOGFS_OFFSET" "$LOGFS_SIZE" "$IMAGE_PATH"; then + fail "读取 Flash 失败。请关闭 monitor、检查 USB 连接后重试。" +fi + +printf '\n正在解包 LittleFS...\n' +if ! "$LITTLEFS_TOOL" extract \ + --block-size "$LOGFS_BLOCK_SIZE" \ + "$IMAGE_PATH" "$EXTRACT_DIR"; then + fail "LittleFS 解包失败,请确认设备使用的是当前 16m.csv 分区表。" +fi + +if ! "$IDF_PYTHON" - "$EXTRACT_DIR" "$REPORT_PATH" <<'PY' +import json +import sys +from pathlib import Path + +extract_dir = Path(sys.argv[1]) +report_path = Path(sys.argv[2]) +log_dir = extract_dir / "remote_logs" +log_files = sorted(log_dir.glob("log_*.jsonl")) if log_dir.is_dir() else [] + +with report_path.open("w", encoding="utf-8") as report: + report.write("TalkingQ cached remote logs\n") + report.write(f"Source: {log_dir}\n") + report.write(f"Files: {len(log_files)}\n\n") + + if not log_files: + report.write("No pending remote log files were found.\n") + + for log_file in log_files: + report.write(f"===== {log_file.name} =====\n") + with log_file.open("r", encoding="utf-8", errors="replace") as source: + for raw_line in source: + line = raw_line.strip() + if not line: + continue + try: + entry = json.loads(line) + timestamp = entry.get("ts_ms", "?") + level = entry.get("level", "?") + truncated = " [truncated]" if entry.get("truncated") else "" + message = entry.get("message", "") + report.write(f"[{timestamp:>10} ms] {level}{truncated} {message}\n") + except (json.JSONDecodeError, TypeError, ValueError): + report.write(f"[invalid JSON] {line}\n") + report.write("\n") +PY +then + fail "生成可读日志文件失败。" +fi + +printf '\n读取完成:%s\n' "$REPORT_PATH" +if command -v open >/dev/null 2>&1; then + open "$REPORT_PATH" +else + printf '当前系统无法自动打开文件,请手动查看上述路径。\n' +fi + +pause_before_exit