From b7184743a4c8c87fd01067ba4b6739c6ed5320c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E5=B0=91=E7=90=BC?= Date: Wed, 21 Oct 2026 23:15:56 +0800 Subject: [PATCH] feat(tuya-lan)!: add optional LAN 3.5 local control Devices need local discovery and authenticated DP control using the same schema, values and callbacks as cloud controls, with application ownership of scheduling, lifecycle and every cloud report. Add an application-driven LAN 3.5 service with UDP discovery, bounded TCP Sessions, nonce authentication, per-Session keys, AES-GCM framing, replay checks, heartbeat replies, DP control/query, explicit status reports and an authenticated extension callback for P2P signaling. Applications own P2P negotiation and media transport. Share transactional local DP ingress through iot_client_apply_local_dps(): validate and stage the complete batch, commit atomically, mark dirty and invoke save/DP callbacks outside the lock. LAN never publishes MQTT or clears cloud dirty bits. Keep all platform capabilities in pal_t. Add optional IPv4 server/datagram callbacks directly in pal_posix.c and pal_freertos.c; accepted and connected TCP reuse send/receive/poll/close with one handle contract and matching allocator cleanup. Preserve nonblocking timeout-zero I/O and bounded positive timeouts. LAN takes the shared config.pal and checks its optional network group; base PAL validation keeps its existing callback requirements. Share length-bounded JSON lexical checks before cJSON normalization so LAN and local DP ingress enforce one set of UTF-8, escape, surrogate and number rules. Keep structure and domain validation with their owning modules. Share bounded C-string checks while preserving each API's existing limits. Use socket handle names that cover TCP, listeners and UDP; log handle allocation failures explicitly while retaining the existing PAL error codes. Route business-service logs through BIZ_LOG* and the lower-only AGENTIC_KIT_BIZ_LOG_LEVEL ceiling. Share private typed-list decoding and failure cleanup across role, language and timbre queries, retaining their result contracts. Cover allocation failures, partial lists, string boundaries, Unicode inputs and log configurations with registered regression checks. Make LAN opt-in with AGENTIC_KIT_ENABLE_LAN and the ESP-IDF Kconfig option, both disabled by default. Keep pal_t layout independent of the switch; exclude optional networking code and leave those callbacks NULL when off. Align host/ESP-IDF source lists, POSIX demo, CI LAN-off builds, serial test registration, GitLab leak checks and pinned FreeRTOS header compilation. Document architecture, shared DP flows, callback ownership, queue semantics, resource budgets, lifecycle, build options and the P2P boundary in bilingual reference/architecture/porting guides. Register domain language, CONTEXT-MAP, ADR 0001, navigation, AGENTS.md and release notes linked to PR #48. BREAKING CHANGE: pal_t gains optional server/datagram members, extending its layout. Rebuild the SDK and all PAL consumers. Custom ports using LAN must implement the complete optional network group; other ports may leave it NULL. Verified: serial CTest LAN enabled 37/37 and disabled 32/32; full ASan/UBSan 37/37 suites; IoT DP 24/24, business roles 16/16, business log compile matrix 38/38, shared JSON lexical cases 82/82 (4 groups), bounded string groups 3/3, LAN protocol 12/12, service 7/7, independent encrypted peer 10/10 and POSIX/FreeRTOS network cases 7/7 each; pinned FreeRTOS C99 checks 5/5; ESP32-S3 translation-unit checks 3/3; standalone POSIX lan_demo and role_management_demo build; docs-site 2/2 locales, i18n parity 35/35 pages and document-link checks 2/2; git diff --check clean. Co-Authored-By: OpenAI Codex --- .github/workflows/ci.yml | 10 +- .gitlab-ci.yml | 10 +- AGENTS.md | 18 +- CHANGELOG.md | 7 + CMakeLists.txt | 87 +++ CONTEXT-MAP.md | 6 + common/bounded_string.h | 17 + common/json_lexical.c | 134 ++++ common/json_lexical.h | 22 + common/log.h | 13 +- common/test/test_bounded_string.c | 51 ++ common/test/test_json_lexical.c | 117 ++++ docs-site/docs/guides/compile-time-knobs.md | 14 +- .../docs/guides/porting-to-new-platform.md | 16 +- .../docs/guides/tuya-lan-architecture.md | 206 ++++++ docs-site/docs/reference/biz-client.md | 4 + docs-site/docs/reference/tuya-lan.md | 152 +++++ .../current/guides/compile-time-knobs.md | 14 +- .../current/guides/porting-to-new-platform.md | 18 +- .../current/guides/tuya-lan-architecture.md | 206 ++++++ .../current/reference/biz-client.md | 7 + .../current/reference/tuya-lan.md | 182 ++++++ docs-site/sidebars.ts | 2 + .../components/agentic_kit/CMakeLists.txt | 21 + .../esp-idf/components/agentic_kit/Kconfig | 13 + examples/posix/CMakeLists.txt | 6 + examples/posix/lan/lan_demo.c | 131 ++++ modules/biz-client/CONTEXT.md | 2 + .../0001-business-services-use-public-atop.md | 4 + .../include/biz_client_config_defaults.h | 17 + modules/biz-client/src/ai_role.c | 169 ++--- modules/biz-client/src/biz_client.c | 16 +- modules/biz-client/src/service_internal.h | 23 +- modules/biz-client/test/ai_role_test.c | 145 ++++- modules/biz-client/test/test_log_compile.py | 123 ++++ modules/iot-client/CONTEXT.md | 12 + modules/iot-client/include/iot_client.h | 32 + modules/iot-client/include/iot_dp.h | 25 +- modules/iot-client/src/iot_dp.c | 170 ++++- modules/iot-client/test/iot_dp_test.c | 282 ++++++++- modules/tuya-lan/CONTEXT.md | 87 +++ modules/tuya-lan/README.md | 18 + ...-application-driven-and-shares-dp-state.md | 52 ++ modules/tuya-lan/include/tuya_lan.h | 104 ++++ .../include/tuya_lan_config_defaults.h | 17 + modules/tuya-lan/src/tuya_lan.c | 323 ++++++++++ modules/tuya-lan/src/tuya_lan_protocol.c | 557 +++++++++++++++++ modules/tuya-lan/src/tuya_lan_protocol.h | 26 + modules/tuya-lan/test/test_peer.py | 269 ++++++++ modules/tuya-lan/test/test_protocol.c | 586 ++++++++++++++++++ modules/tuya-lan/test/test_service.c | 400 ++++++++++++ pal/pal.h | 60 +- pal/pal_freertos.c | 228 ++++++- pal/pal_posix.c | 227 ++++++- pal/test/lwip/inet.h | 7 + pal/test/test_freertos_time.c | 6 + pal/test/test_net.c | 216 +++++++ pal/test/test_posix_time.c | 5 + tools/check_freertos_pal.py | 2 +- 59 files changed, 5522 insertions(+), 172 deletions(-) create mode 100644 common/bounded_string.h create mode 100644 common/json_lexical.c create mode 100644 common/json_lexical.h create mode 100644 common/test/test_bounded_string.c create mode 100644 common/test/test_json_lexical.c create mode 100644 docs-site/docs/guides/tuya-lan-architecture.md create mode 100644 docs-site/docs/reference/tuya-lan.md create mode 100644 docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/tuya-lan-architecture.md create mode 100644 docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/tuya-lan.md create mode 100644 examples/esp-idf/components/agentic_kit/Kconfig create mode 100644 examples/posix/lan/lan_demo.c create mode 100644 modules/biz-client/include/biz_client_config_defaults.h create mode 100644 modules/biz-client/test/test_log_compile.py create mode 100644 modules/tuya-lan/CONTEXT.md create mode 100644 modules/tuya-lan/README.md create mode 100644 modules/tuya-lan/docs/adr/0001-lan-is-application-driven-and-shares-dp-state.md create mode 100644 modules/tuya-lan/include/tuya_lan.h create mode 100644 modules/tuya-lan/include/tuya_lan_config_defaults.h create mode 100644 modules/tuya-lan/src/tuya_lan.c create mode 100644 modules/tuya-lan/src/tuya_lan_protocol.c create mode 100644 modules/tuya-lan/src/tuya_lan_protocol.h create mode 100644 modules/tuya-lan/test/test_peer.py create mode 100644 modules/tuya-lan/test/test_protocol.c create mode 100644 modules/tuya-lan/test/test_service.c create mode 100644 pal/test/lwip/inet.h create mode 100644 pal/test/test_net.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cefd566..e9d47c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,11 +47,16 @@ jobs: run: pip install $PIP_DEPS - name: Configure - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo -DPython3_EXECUTABLE=$(which python3) + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo -DPython3_EXECUTABLE=$(which python3) -DAGENTIC_KIT_ENABLE_LAN=ON - name: Build run: cmake --build build -j 4 + - name: Build without LAN + run: | + cmake -S . -B build-no-lan -DCMAKE_BUILD_TYPE=RelWithDebInfo -DPython3_EXECUTABLE=$(which python3) -DAGENTIC_KIT_ENABLE_LAN=OFF -DAGENTIC_KIT_BUILD_TESTS=OFF + cmake --build build-no-lan --target tuya_iot_client tuya_ai_tcp -j 4 + - name: FreeRTOS PAL header compatibility run: cmake --build build --target check_freertos_pal @@ -79,6 +84,7 @@ jobs: run: > cmake -S . -B build-asan -DCMAKE_BUILD_TYPE=Debug + -DAGENTIC_KIT_ENABLE_LAN=ON -DPython3_EXECUTABLE=$(which python3) -DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -g -O1" -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" @@ -121,7 +127,7 @@ jobs: # examples/posix fetches qrcodegen / quirc / stb via FetchContent at # configure time, and links the prebuilt rtc-client lib (linux-gnu-amd64). - name: Configure - run: cmake -S examples/posix -B build-examples -DPython3_EXECUTABLE=$(which python3) + run: cmake -S examples/posix -B build-examples -DPython3_EXECUTABLE=$(which python3) -DAGENTIC_KIT_ENABLE_LAN=ON - name: Build run: cmake --build build-examples -j 4 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a46efff..61f4da5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -34,8 +34,10 @@ build: - pip install -q ${PIP_DEPS} script: - cmake --version - - cmake -B ${CMAKE_BUILD_DIR} -DCMAKE_BUILD_TYPE=RelWithDebInfo -DPython3_EXECUTABLE=$(which python3) + - cmake -B ${CMAKE_BUILD_DIR} -DCMAKE_BUILD_TYPE=RelWithDebInfo -DPython3_EXECUTABLE=$(which python3) -DAGENTIC_KIT_ENABLE_LAN=ON - cmake --build ${CMAKE_BUILD_DIR} -j$(nproc) + - cmake -S . -B build-no-lan -DCMAKE_BUILD_TYPE=RelWithDebInfo -DPython3_EXECUTABLE=$(which python3) -DAGENTIC_KIT_ENABLE_LAN=OFF -DAGENTIC_KIT_BUILD_TESTS=OFF + - cmake --build build-no-lan --target tuya_iot_client tuya_ai_tcp -j$(nproc) artifacts: paths: - ${CMAKE_BUILD_DIR}/ @@ -90,17 +92,19 @@ test:memory-leak: VG=modules/iot-client/test/run_valgrind_check.sh chmod +x "$VG" # Leak-check the pure (no-subprocess) binaries, grouped by module: - # pal : pal_freertos_time_*_test, pal_posix_time_test + # common : json_lexical_test, bounded_string_test + # pal : pal_freertos_time_*_test, pal_posix_time_test, pal_net_test, pal_freertos_net_test # rtc-tcp-client : tai_unit_tests, tai_log_level_zero_tests, # tai_log_module_zero_tests, tai_integration_tests # iot-client : iot_cipher_test, iot_ai_ctrl_test # tuya-ble : tuya_ble_test # biz-client : biz_ai_role_test (public ATOP boundary faked) + # tuya-lan : tuya_lan_protocol_test, tuya_lan_service_test # rtc-client is a prebuilt closed lib (headers + libstm.a) with no offline # test, so there is nothing to leak-check for it here. The mock-driven iot # tests are covered by the `test` job; under valgrind their Python # handshakes would time out. - for t in pal_freertos_time_16_1000_test pal_freertos_time_32_1000_test pal_freertos_time_32_100_test pal_freertos_time_32_128_test pal_freertos_time_32_2000_test pal_freertos_time_64_1000_test pal_posix_time_test biz_ai_role_test tai_unit_tests tai_log_level_zero_tests tai_log_module_zero_tests tai_integration_tests iot_cipher_test iot_ai_ctrl_test tuya_ble_test; do + for t in json_lexical_test bounded_string_test pal_freertos_time_16_1000_test pal_freertos_time_32_1000_test pal_freertos_time_32_100_test pal_freertos_time_32_128_test pal_freertos_time_32_2000_test pal_freertos_time_64_1000_test pal_posix_time_test pal_net_test pal_freertos_net_test biz_ai_role_test tai_unit_tests tai_log_level_zero_tests tai_log_module_zero_tests tai_integration_tests iot_cipher_test iot_ai_ctrl_test tuya_ble_test tuya_lan_protocol_test tuya_lan_service_test; do echo "==================== valgrind: ${t} ====================" "$VG" "./${CMAKE_BUILD_DIR}/${t}" 2>&1 | tee "valgrind-${t}.log" rc=${PIPESTATUS[0]} diff --git a/AGENTS.md b/AGENTS.md index 9af6903..937ddce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,11 @@ # AGENTS.md Instructions for AI coding agents working in this repository: a device-side C SDK for Tuya -hardware, spanning three bounded contexts — an IoT client (device ↔ cloud over MQTT/ATOP), an -RTC/TAI client (device ↔ AI Foundation, real-time audio over a custom binary framing), and BLE -provisioning — over a Platform Abstraction Layer, with mbedTLS / cJSON / coreMQTT / coreHTTP -vendored under `third_party/`. Sections follow the team outline. +hardware, spanning five bounded contexts — an IoT client (device ↔ cloud over MQTT/ATOP), an +RTC/TAI client (device ↔ AI Foundation, real-time audio over a custom binary framing), BLE +provisioning, Tuya LAN local control, and cloud business services — over a Platform Abstraction +Layer, with mbedTLS / cJSON / coreMQTT / coreHTTP vendored under `third_party/`. Contexts and +relationships are registered in `CONTEXT-MAP.md`. Sections follow the team outline. ## Commands @@ -46,7 +47,8 @@ ctest --test-dir build --output-on-failure --no-tests=error --timeout 180 # ne reset callback fires on that thread. rtc-tcp-client: `tai_connect()` spawns a worker via `pal->thread_create`, and every receive callback fires there, concurrently with the app. tuya-ble: no thread — it runs on whatever context the port's BLE stack calls `tuya_ble_recv()` - from. Never move work between these worlds. + from. tuya-lan: single-threaded and app-driven by `tuya_lan_process()`; all LAN calls and + callbacks stay on that owner thread. Never move work between these worlds. - **The SDK does no background work.** It never auto-reconnects MQTT, and the DP layer never publishes on its own — no report-on-connect, no cloud-query-triggered report (`modules/iot-client/docs/adr/0001-dp-layer-never-initiates-uplink.md`). The app owns the @@ -96,7 +98,7 @@ ctest --test-dir build --output-on-failure --no-tests=error --timeout 180 # ne `pal_freertos.c` IDF-only — so never blind-sync them. 5. **Module code goes through the PAL**: `pal->malloc`/`pal->free` for memory, the `log_tag_*` macros in `common/log.h` for output (via each module's own family: `IOT_LOG*`, - `TAI_LOG*`, `TUYA_BLE_HAL_LOG*`). A direct `malloc` or + `TAI_LOG*`, `TUYA_BLE_HAL_LOG*`, `LAN_LOG*`, `BIZ_LOG*`). A direct `malloc` or `printf` is a porting bug even where it links on the host -- and the `test` CI job greps for raw `log_emit(`/`printf(` call sites outside `common/log.{h,c}` and the test trees, so one fails the pipeline. `pal_t.time_ms` supplies monotonic elapsed time; @@ -307,9 +309,9 @@ ordinary changes. `main`, which does not exist here (only its `workflow_dispatch` fires it). GitLab's `pages` job is the only thing that actually catches it, so build the site locally after touching it. - **`AGENTIC_KIT_LOG_LEVEL` is the SDK-wide log gate, compile-time only.** Every SDK log - macro (log_tag_* in `common/log.h`; iot-client's IOT_LOG*, TAI_LOG*, TUYA_BLE_HAL_LOG* + macro (log_tag_* in `common/log.h`; iot-client's IOT_LOG*, TAI_LOG*, TUYA_BLE_HAL_LOG*, LAN_LOG*, BIZ_LOG* re-tagged on top) expands to nothing above it. Optional per-module ceilings - (`AGENTIC_KIT_{IOT,TAI,TUYA_BLE}_LOG_LEVEL`, defaults = the global one, in each module's + (`AGENTIC_KIT_{IOT,TAI,TUYA_BLE,LAN,BIZ}_LOG_LEVEL`, defaults = the global one, in each module's config file) lower a single module further -- lower-only: the facade gate still applies, so they can never resurrect what the global ceiling compiled out, and a value above it is a no-op (clamped once in each module's config file, so block-level gates see the effective ceiling too). diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b37fb..295d2e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- biz-client — `AGENTIC_KIT_BIZ_LOG_LEVEL` lowers business-service logs below the SDK-wide ceiling (#48). - biz-client — typed AI role management, language/timbre queries, POSIX and ESP-IDF examples, and bilingual API documentation (#47). +- tuya-lan — application-driven LAN 3.5 discovery, authenticated Sessions and local DP control (#48). + - Opt in with CMake `AGENTIC_KIT_ENABLE_LAN=ON` or ESP-IDF `CONFIG_AGENTIC_KIT_ENABLE_LAN=y`; both default off. + - Server/datagram callbacks are optional members of `pal_t`; custom LAN ports must implement them. Rebuild consumers after the layout change and pass only `config.pal`. +- iot-client — `iot_client_apply_local_dps()` validates local DP controls, marks changes dirty and invokes existing callbacks (#48). + - iot-client — `iot_get_binding_status()` queries `tuya.device.info.sync` with saved credentials, without an IoT client (#44). - `iot_client_get_binding_status()` reuses an existing client's endpoint and TLS settings. - Examples — `unbind_demo` checks cloud binding status before client initialization/reconnect and after a removal notice (#44). @@ -26,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- pal — socket handle allocation failures emit an explicit allocation diagnostic (#48). - pal — FreeRTOS `time_ms` preserves tick wraps and converts non-integral tick periods correctly (#45). - biz-client — role request timestamps and the clock gate read PAL UTC instead of libc time, so ports without a synchronized libc clock work (#45). diff --git a/CMakeLists.txt b/CMakeLists.txt index f3d2aca..8f1d3b7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,6 +10,7 @@ set(CMAKE_C_EXTENSIONS ON) option(AGENTIC_KIT_BUILD_TESTS "Build tests" ON) option(AGENTIC_KIT_BUILD_EXAMPLES "Build posix examples" OFF) +option(AGENTIC_KIT_ENABLE_LAN "Build Tuya LAN 3.5 local control" OFF) include(CTest) include(GNUInstallDirs) @@ -142,12 +143,15 @@ add_library(agentic_kit_common STATIC "${COMMON_DIR}/log.c" "${COMMON_DIR}/tls.c" "${COMMON_DIR}/rng.c" + "${COMMON_DIR}/json_lexical.c" ) target_include_directories(agentic_kit_common PUBLIC "${COMMON_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/pal" # tls.c / tls.h need pal.h ) target_link_libraries(agentic_kit_common PUBLIC ${AGENTIC_KIT_MBEDTLS_TARGETS}) +target_compile_definitions(agentic_kit_common PUBLIC + AGENTIC_KIT_ENABLE_LAN=$) # ----------------------------------------------------------------------------- # PAL (Platform Abstraction Layer) @@ -285,10 +289,43 @@ target_link_libraries(tuya_ble PUBLIC ${AGENTIC_KIT_MBEDTLS_TARGETS} ) +# ----------------------------------------------------------------------------- +# tuya-lan module (application-driven Tuya LAN 3.5 service) +# ----------------------------------------------------------------------------- +if(AGENTIC_KIT_ENABLE_LAN) + set(TUYA_LAN_DIR "${CMAKE_CURRENT_SOURCE_DIR}/modules/tuya-lan") + add_library(tuya_lan STATIC + "${TUYA_LAN_DIR}/src/tuya_lan.c" + "${TUYA_LAN_DIR}/src/tuya_lan_protocol.c" + ) + target_include_directories(tuya_lan PUBLIC "${TUYA_LAN_DIR}/include" + PRIVATE "${TUYA_LAN_DIR}/src" "${cjson_source_dir}") + target_link_libraries(tuya_lan PUBLIC agentic_kit_pal agentic_kit_common + ${AGENTIC_KIT_CJSON_TARGET}) + if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(tuya_lan PRIVATE -Wall -Wextra -Werror) + endif() +endif() + # ----------------------------------------------------------------------------- # Tests # ----------------------------------------------------------------------------- if(AGENTIC_KIT_BUILD_TESTS AND AGENTIC_KIT_ENABLE_PROJECT_TESTS) + add_executable(json_lexical_test + "${COMMON_DIR}/json_lexical.c" "${COMMON_DIR}/test/test_json_lexical.c") + target_include_directories(json_lexical_test PRIVATE "${COMMON_DIR}") + if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(json_lexical_test PRIVATE -Wall -Wextra -Werror) + endif() + add_test(NAME json_lexical_test COMMAND json_lexical_test) + + add_executable(bounded_string_test "${COMMON_DIR}/test/test_bounded_string.c") + target_include_directories(bounded_string_test PRIVATE "${COMMON_DIR}") + if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(bounded_string_test PRIVATE -Wall -Wextra -Werror) + endif() + add_test(NAME bounded_string_test COMMAND bounded_string_test) + # Fake only the public ATOP boundary, so service parsing/ownership can be # checked without network credentials or a subprocess. Register in ctest. add_executable(biz_ai_role_test ${BIZ_CLIENT_SOURCES} @@ -304,6 +341,10 @@ if(AGENTIC_KIT_BUILD_TESTS AND AGENTIC_KIT_ENABLE_PROJECT_TESTS) endif() add_test(NAME biz_ai_role_test COMMAND biz_ai_role_test) + add_test(NAME biz_log_compile_matrix + COMMAND "${Python3_EXECUTABLE}" "${BIZ_CLIENT_DIR}/test/test_log_compile.py" + --cc "${CMAKE_C_COMPILER}") + set(AI_TCP_TESTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/modules/rtc-tcp-client/test") set(IOT_CLIENT_TESTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/modules/iot-client/test") set(TUYA_BLE_TESTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/modules/tuya-ble/test") @@ -573,6 +614,52 @@ if(AGENTIC_KIT_BUILD_TESTS AND AGENTIC_KIT_ENABLE_PROJECT_TESTS) ) target_link_libraries(tuya_ble_test PRIVATE tuya_iot_client_testlog) add_test(NAME tuya_ble_test COMMAND tuya_ble_test) + + if(AGENTIC_KIT_ENABLE_LAN) + add_executable(tuya_lan_service_test "${TUYA_LAN_DIR}/test/test_service.c") + target_include_directories(tuya_lan_service_test PRIVATE "${TUYA_LAN_DIR}/src") + target_link_libraries(tuya_lan_service_test PRIVATE tuya_lan) + if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(tuya_lan_service_test PRIVATE -Wall -Wextra -Werror) + endif() + add_test(NAME tuya_lan_service_test COMMAND tuya_lan_service_test) + # The independent peer harness supplies a deterministic/failing RNG; do not + # link iot-client/rng.c into this target or rebind a process-wide allocator. + add_executable(tuya_lan_protocol_test + "${TUYA_LAN_DIR}/src/tuya_lan_protocol.c" + "${TUYA_LAN_DIR}/test/test_protocol.c") + target_include_directories(tuya_lan_protocol_test PRIVATE + "${TUYA_LAN_DIR}/include" "${TUYA_LAN_DIR}/src" "${cjson_source_dir}") + target_link_libraries(tuya_lan_protocol_test PRIVATE agentic_kit_common + ${AGENTIC_KIT_CJSON_TARGET}) + if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(tuya_lan_protocol_test PRIVATE -Wall -Wextra -Werror) + endif() + add_test(NAME tuya_lan_protocol_test COMMAND tuya_lan_protocol_test) + add_executable(tuya_lan_peer_device "${CMAKE_CURRENT_SOURCE_DIR}/examples/posix/lan/lan_demo.c") + target_link_libraries(tuya_lan_peer_device PRIVATE tuya_lan tuya_iot_client) + add_test(NAME tuya_lan_peer_test COMMAND "${Python3_EXECUTABLE}" + "${TUYA_LAN_DIR}/test/test_peer.py" "$") + add_executable(pal_net_test "${PAL_DIR}/test/test_net.c") + target_link_libraries(pal_net_test PRIVATE agentic_kit_pal) + if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(pal_net_test PRIVATE -Wall -Wextra -Werror) + endif() + add_test(NAME pal_net_test COMMAND pal_net_test) + # Exercise the same unified network contract in the actual FreeRTOS + # port against host kernel/lwIP declarations, not a second net adapter. + add_executable(pal_freertos_net_test + "${PAL_DIR}/pal_freertos.c" "${PAL_DIR}/test/test_net.c") + target_include_directories(pal_freertos_net_test PRIVATE + "${PAL_DIR}/test" "${PAL_DIR}" "${COMMON_DIR}") + target_compile_definitions(pal_freertos_net_test PRIVATE + PAL_TEST_FREERTOS=1 TEST_TICK_BITS=32 TEST_TICK_HZ=1000 + AGENTIC_KIT_ENABLE_LAN=1 AGENTIC_KIT_LOG_LEVEL=0) + if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(pal_freertos_net_test PRIVATE -Wall -Wextra -Werror) + endif() + add_test(NAME pal_freertos_net_test COMMAND pal_freertos_net_test) + endif() endif() # ----------------------------------------------------------------------------- diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index bd1485b..05adb71 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -15,6 +15,8 @@ and decisions. block in `modules/rtc-tcp-client/include/tuya_ai.h`. - [Tuya BLE](./modules/tuya-ble/CONTEXT.md) — device ↔ app BLE provisioning: advertising, the pairing handshake, and WiFi-credential delivery. +- [Tuya LAN](./modules/tuya-lan/CONTEXT.md) — activated device ↔ app local control: + discovery, authenticated LAN 3.5 Sessions, DP requests and status reports. ## Relationships @@ -33,3 +35,7 @@ and decisions. against the cloud. - All contexts share the Platform Abstraction Layer (`pal/pal.h`) for sockets, memory, mutex, threads, and time. +- **Tuya LAN → IoT Client**: the application supplies Activation credentials and + connects LAN callbacks to the IoT Client's public local-DP input and DP state. + LAN reports do not publish MQTT or clear MQTT dirty bits; the application owns + channel reporting. Optional server/datagram operations share `pal_t` in `pal/pal.h`. diff --git a/common/bounded_string.h b/common/bounded_string.h new file mode 100644 index 0000000..d917fac --- /dev/null +++ b/common/bounded_string.h @@ -0,0 +1,17 @@ +#ifndef AGENTIC_KIT_BOUNDED_STRING_H +#define AGENTIC_KIT_BOUNDED_STRING_H + +#include +#include + +/* Require a nonempty string terminated within capacity bytes, including its + * NUL. Read at most capacity bytes, stopping at the first NUL. NULL, zero + * capacity, empty and unterminated strings are invalid. */ +static inline bool bounded_string_nonempty(const char *text, size_t capacity) +{ + if (!text || !capacity || !text[0]) return false; + for (size_t i = 1; i < capacity; ++i) if (!text[i]) return true; + return false; +} + +#endif diff --git a/common/json_lexical.c b/common/json_lexical.c new file mode 100644 index 0000000..6c6e7fe --- /dev/null +++ b/common/json_lexical.c @@ -0,0 +1,134 @@ +#include "json_lexical.h" + +#include + +static bool unicode_escape(const char *data, size_t len, size_t *offset, unsigned *code) +{ + unsigned value = 0; + if (len - *offset < 4) return false; + for (unsigned i = 0; i < 4; ++i) { + unsigned char c = (unsigned char)data[(*offset)++]; + int digit = c >= '0' && c <= '9' ? c - '0' : + c >= 'a' && c <= 'f' ? c - 'a' + 10 : + c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1; + if (digit < 0) return false; + value = (value << 4) | (unsigned)digit; + } + *code = value; + return true; +} + +/* The lead byte restricts the first continuation byte as well as the length: + * exclude overlong encodings, UTF-16 surrogates and scalars above U+10FFFF. */ +static bool utf8_tail(const char *data, size_t len, size_t *offset, unsigned char lead) +{ + unsigned extra; + unsigned char lo = 0x80, hi = 0xbf; + if (lead >= 0xc2 && lead <= 0xdf) extra = 1; + else if (lead >= 0xe0 && lead <= 0xef) { + extra = 2; + if (lead == 0xe0) lo = 0xa0; + if (lead == 0xed) hi = 0x9f; + } else if (lead >= 0xf0 && lead <= 0xf4) { + extra = 3; + if (lead == 0xf0) lo = 0x90; + if (lead == 0xf4) hi = 0x8f; + } else return false; + if (len - *offset < extra || (unsigned char)data[*offset] < lo || + (unsigned char)data[*offset] > hi) return false; + ++*offset; + while (--extra > 0) { + unsigned char c = (unsigned char)data[(*offset)++]; + if (c < 0x80 || c > 0xbf) return false; + } + return true; +} + +bool json_lexical_scan_string(const char *data, size_t len, size_t *offset) +{ + if (!data || !offset || *offset >= len || data[*offset] != '"') return false; + size_t i = *offset + 1; + while (i < len) { + unsigned char c = (unsigned char)data[i++]; + if (c == '"') { *offset = i; return true; } + if (c < 0x20) return false; + if (c == '\\') { + unsigned code, low; + if (i == len) return false; + c = (unsigned char)data[i++]; + if (c == 'u') { + if (!unicode_escape(data, len, &i, &code) || !code || + (code >= 0xdc00 && code <= 0xdfff)) return false; + if (code >= 0xd800 && code <= 0xdbff) { + if (len - i < 6 || data[i] != '\\' || data[i + 1] != 'u') return false; + i += 2; + if (!unicode_escape(data, len, &i, &low) || low < 0xdc00 || low > 0xdfff) + return false; + } + } else if (c != '"' && c != '\\' && c != '/' && c != 'b' && + c != 'f' && c != 'n' && c != 'r' && c != 't') return false; + } else if (c >= 0x80 && !utf8_tail(data, len, &i, c)) return false; + } + return false; +} + +static bool delimiter(char c) +{ + return c == ',' || c == ']' || c == '}' || c == ' ' || + c == '\t' || c == '\n' || c == '\r'; +} + +bool json_lexical_scan_number(const char *data, size_t len, size_t *offset) +{ + if (!data || !offset || *offset >= len) return false; + size_t i = *offset; + if (data[i] == '-') ++i; + if (i == len) return false; + if (data[i] == '0') ++i; + else { + if (data[i] < '1' || data[i] > '9') return false; + do { ++i; } while (i < len && data[i] >= '0' && data[i] <= '9'); + } + if (i < len && data[i] == '.') { + ++i; + if (i == len || data[i] < '0' || data[i] > '9') return false; + do { ++i; } while (i < len && data[i] >= '0' && data[i] <= '9'); + } + if (i < len && (data[i] == 'e' || data[i] == 'E')) { + ++i; + if (i < len && (data[i] == '+' || data[i] == '-')) ++i; + if (i == len || data[i] < '0' || data[i] > '9') return false; + do { ++i; } while (i < len && data[i] >= '0' && data[i] <= '9'); + } + if (i < len && !delimiter(data[i])) return false; + *offset = i; + return true; +} + +bool json_lexical_validate(const char *data, size_t len) +{ + if (!data || !len) return false; + size_t i = 0; + unsigned depth = 0; + while (i < len) { + unsigned char c = (unsigned char)data[i]; + if (c == '"') { if (!json_lexical_scan_string(data, len, &i)) return false; } + else if (c == '-' || (c >= '0' && c <= '9')) { + if (!json_lexical_scan_number(data, len, &i)) return false; + } else if (c == '{' || c == '[') { if (++depth > JSON_LEXICAL_MAX_DEPTH) return false; ++i; } + else if (c == '}' || c == ']') { if (!depth) return false; --depth; ++i; } + else if (c == ':' || c == ',' || c == ' ' || c == '\t' || c == '\n' || c == '\r') ++i; + else { + size_t word_len; + const char *word; + if (c == 't') { word = "true"; word_len = 4; } + else if (c == 'f') { word = "false"; word_len = 5; } + else if (c == 'n') { word = "null"; word_len = 4; } + else return false; + if (len - i < word_len || memcmp(data + i, word, word_len)) return false; + i += word_len; + if (i < len && !delimiter(data[i])) return false; + } + } + return depth == 0; +} diff --git a/common/json_lexical.h b/common/json_lexical.h new file mode 100644 index 0000000..9d5c39a --- /dev/null +++ b/common/json_lexical.h @@ -0,0 +1,22 @@ +/* Internal, allocation-free lexical checks before cJSON normalization. + * This is not a JSON parser: callers still validate document structure with + * cJSON, and apply their own domain constraints. Strings must be UTF-8 and may + * not decode to NUL, because cJSON exposes NUL-terminated C strings. */ +#ifndef AGENTIC_KIT_JSON_LEXICAL_H +#define AGENTIC_KIT_JSON_LEXICAL_H + +#include +#include + +#define JSON_LEXICAL_MAX_DEPTH 32u + +/* Scan one token at *offset without reading outside data[0..len). Advance the + * offset only on success; failure includes a missing or malformed token. */ +bool json_lexical_scan_string(const char *data, size_t len, size_t *offset); +bool json_lexical_scan_number(const char *data, size_t len, size_t *offset); + +/* Check all lexical forms and cap nesting before invoking cJSON's recursive + * parser. This accepts punctuation sequences that only cJSON can reject. */ +bool json_lexical_validate(const char *data, size_t len); + +#endif diff --git a/common/log.h b/common/log.h index f90d629..e56e244 100644 --- a/common/log.h +++ b/common/log.h @@ -2,7 +2,7 @@ * log.h -- Process-wide logging facade for agentic-kit. * * One compile-time log ceiling, one tagged dispatch, one destination - * decided at build time — shared by ai-tcp, iot-client, tuya-ble, and + * decided at build time — shared by ai-tcp, iot-client, tuya-ble, tuya-lan, biz-client, and * any caller that wants to plug in. Where lines land is not runtime * state: define AGENTIC_KIT_LOG (below) and the dispatch lands in your * own macro — no function, no va_list round trip; the default expansion @@ -79,8 +79,8 @@ #endif /* The single compile-time log ceiling for the whole SDK: every log macro - * (log_tag_* below; iot-client's IOT_LOG*, TAI_LOG* and - * TUYA_BLE_HAL_LOG* re-tagged on top of those) compiles out above it -- + * (log_tag_* below; iot-client's IOT_LOG*, TAI_LOG*, TUYA_BLE_HAL_LOG*, + * LAN_LOG* and BIZ_LOG* re-tagged on top of those) compiles out above it -- * no call, no argument evaluation, no format string in the image. * 0 = none, 1 = error, 2 = +warn, 3 = +info, 4 = +debug (default). * This ceiling is the only SDK-wide level filter: nothing is held back @@ -89,8 +89,9 @@ * is redirecting or dropping lines wholesale -- define AGENTIC_KIT_LOG * (below) and the dispatch lands in your own macro. Optional per-module * ceilings (AGENTIC_KIT_IOT_LOG_LEVEL, AGENTIC_KIT_TAI_LOG_LEVEL, - * AGENTIC_KIT_TUYA_BLE_LOG_LEVEL; defaults = this one, in each module's - * config file) lower a single module further: they ride the same pickup, + * AGENTIC_KIT_TUYA_BLE_LOG_LEVEL, AGENTIC_KIT_LAN_LOG_LEVEL, + * AGENTIC_KIT_BIZ_LOG_LEVEL; defaults = this one, + * in each module's config file) lower a single module further: they ride the same pickup, * gate the module vocabulary where it is defined, and can never raise a * line above this ceiling. */ #ifndef AGENTIC_KIT_LOG_LEVEL @@ -139,7 +140,7 @@ extern "C" { * AGENTIC_KIT_LOG_LEVEL (default 4 = debug) is the maximum level compiled * into the SDK. Every log macro the SDK itself uses dispatches through one * of the four below — the per-module families (iot-client's IOT_LOG*, - * TAI_LOG*, TUYA_BLE_HAL_LOG*) are thin re-tags of these — and expands to + * TAI_LOG*, TUYA_BLE_HAL_LOG*, LAN_LOG*, BIZ_LOG*) are thin re-tags of these — and expands to * ((void)0) above the ceiling: no call, no argument evaluation, no format * string in the image. Below the ceiling the line emits unconditionally — * there is no runtime level, so what compiles in is what prints. diff --git a/common/test/test_bounded_string.c b/common/test/test_bounded_string.c new file mode 100644 index 0000000..bf6c562 --- /dev/null +++ b/common/test/test_bounded_string.c @@ -0,0 +1,51 @@ +#include "bounded_string.h" + +#include + +static unsigned passed, failed, total; +#define CHECK(expr) do { if (!(expr)) { \ + printf("%s:%d: %s\n", __FILE__, __LINE__, #expr); return -1; \ +} } while (0) +#define RUN(fn) do { ++total; if (fn() == 0) ++passed; else ++failed; } while (0) + +static int test_invalid_arguments(void) +{ + CHECK(!bounded_string_nonempty(NULL, 0)); + CHECK(!bounded_string_nonempty(NULL, 4)); + CHECK(!bounded_string_nonempty("x", 0)); + CHECK(!bounded_string_nonempty("", 1)); + CHECK(!bounded_string_nonempty("x", 1)); + return 0; +} + +static int test_terminated_boundaries(void) +{ + const char exact[] = {'a', 'b', '\0'}; + const char early[] = {'a', '\0', 'x'}; + CHECK(bounded_string_nonempty(exact, sizeof(exact))); + CHECK(!bounded_string_nonempty(exact, sizeof(exact) - 1)); + CHECK(bounded_string_nonempty(early, sizeof(early))); + CHECK(bounded_string_nonempty(early, 2)); + return 0; +} + +static int test_unterminated_slices(void) +{ + /* No sentinel follows either complete object; sanitizers catch overreads. */ + const char one[] = {'x'}; + const char raw[] = {'a', 'b', 'c'}; + CHECK(!bounded_string_nonempty(one, sizeof(one))); + CHECK(!bounded_string_nonempty(raw, sizeof(raw))); + CHECK(!bounded_string_nonempty(raw, 0)); + return 0; +} + +int main(void) +{ + RUN(test_invalid_arguments); + RUN(test_terminated_boundaries); + RUN(test_unterminated_slices); + printf("=== Bounded string: %u passed, %u failed (%u total) ===\n", + passed, failed, total); + return failed ? 1 : 0; +} diff --git a/common/test/test_json_lexical.c b/common/test/test_json_lexical.c new file mode 100644 index 0000000..3f3c6b9 --- /dev/null +++ b/common/test/test_json_lexical.c @@ -0,0 +1,117 @@ +#include "json_lexical.h" + +#include +#include + +static unsigned passed, failed, total, cases; +#define CHECK(expr) do { if (!(expr)) { \ + printf("%s:%d: %s\n", __FILE__, __LINE__, #expr); return -1; \ +} } while (0) +#define RUN(fn) do { ++total; if (fn() == 0) ++passed; else ++failed; } while (0) + +static int test_string_scalars_and_escapes(void) +{ + const struct { const char *text; bool valid; } inputs[] = { + {"\"\"", true}, {"\"ascii\"", true}, + {"\"\xc2\x80\"", true}, {"\"\xdf\xbf\"", true}, + {"\"\xe0\xa0\x80\"", true}, {"\"\xed\x9f\xbf\"", true}, + {"\"\xee\x80\x80\"", true}, {"\"\xef\xbf\xbf\"", true}, + {"\"\xf0\x90\x80\x80\"", true}, {"\"\xf4\x8f\xbf\xbf\"", true}, + {"\"\\u0001\\u007f\\u00A2\\u4e2d\"", true}, + {"\"\\ud800\\udc00\\uDBFF\\uDFFF\"", true}, + {"\"\\\"\\\\\\/\\b\\f\\n\\r\\t\"", true}, + {"\"literal \\\\u0000\"", true}, + {"\"\x80\"", false}, {"\"\xbf\"", false}, + {"\"\xc0\x80\"", false}, {"\"\xc1\xbf\"", false}, + {"\"\xe0\x9f\xbf\"", false}, {"\"\xf0\x8f\xbf\xbf\"", false}, + {"\"\xed\xa0\x80\"", false}, {"\"\xed\xbf\xbf\"", false}, + {"\"\xf4\x90\x80\x80\"", false}, {"\"\xf5\x80\x80\x80\"", false}, + {"\"\xc2x\"", false}, {"\"\xe1\x80x\"", false}, + {"\"\xf1\x80\x80x\"", false}, {"\"\xff\"", false}, + {"\"\xc2", false}, {"\"\xe0\xa0", false}, {"\"\xf0\x90\x80", false}, + {"\"\\u0000\"", false}, {"\"\\ud800\"", false}, {"\"\\udc00\"", false}, + {"\"\\ud800\\u0041\"", false}, {"\"\\ud800\\ud800\"", false}, + {"\"\\ud800x\"", false}, {"\"\\u00x0\"", false}, {"\"\\u123\"", false}, + {"\"\\x20\"", false}, {"\"\\v\"", false}, {"\"\\", false}, + {"\"\n\"", false}, {"\"unterminated", false}, + }; + for (size_t i = 0; i < sizeof(inputs) / sizeof(inputs[0]); ++i) { + size_t offset = 0; + ++cases; + CHECK(json_lexical_scan_string(inputs[i].text, strlen(inputs[i].text), &offset) == inputs[i].valid); + CHECK(offset == (inputs[i].valid ? strlen(inputs[i].text) : 0)); + CHECK(json_lexical_validate(inputs[i].text, strlen(inputs[i].text)) == inputs[i].valid); + } + return 0; +} + +static int test_number_grammar(void) +{ + const struct { const char *text; bool valid; } inputs[] = { + {"0", true}, {"-0", true}, {"123", true}, {"-123", true}, + {"0.125", true}, {"-0.125e+2", true}, {"1E-3", true}, {"1e0", true}, + {"01", false}, {"-01", false}, {"+1", false}, {".1", false}, + {"1.", false}, {"1e", false}, {"1e+", false}, {"1e-", false}, + {"--1", false}, {"1x", false}, {"1\v", false}, {"NaN", false}, + }; + for (size_t i = 0; i < sizeof(inputs) / sizeof(inputs[0]); ++i) { + size_t offset = 0; + ++cases; + CHECK(json_lexical_scan_number(inputs[i].text, strlen(inputs[i].text), &offset) == inputs[i].valid); + CHECK(offset == (inputs[i].valid ? strlen(inputs[i].text) : 0)); + CHECK(json_lexical_validate(inputs[i].text, strlen(inputs[i].text)) == inputs[i].valid); + } + return 0; +} + +static int test_complete_bounded_slice(void) +{ + const char string[] = {'x', '"', 'o', 'k', '"', 'x'}; + const char number[] = {'x', '-', '2', '.', '5', ',', 'x'}; + const char raw_nul[] = {'"', 'a', '\0', 'b', '"'}; + const char nul_tail[] = {'{', '}', '\0', 'x'}; + size_t offset = 1; + ++cases; CHECK(json_lexical_scan_string(string, 5, &offset) && offset == 5); + offset = 1; + ++cases; CHECK(!json_lexical_scan_string(string, 4, &offset) && offset == 1); + offset = 1; + ++cases; CHECK(json_lexical_scan_number(number, 6, &offset) && offset == 5); + offset = 1; + ++cases; CHECK(!json_lexical_scan_number(number, 4, &offset) && offset == 1); + ++cases; CHECK(!json_lexical_validate(raw_nul, sizeof(raw_nul))); + ++cases; CHECK(!json_lexical_validate(nul_tail, sizeof(nul_tail))); + ++cases; CHECK(json_lexical_validate(nul_tail, 2)); + ++cases; CHECK(!json_lexical_validate(NULL, 1)); + ++cases; CHECK(!json_lexical_validate("", 0)); + offset = sizeof(string); + ++cases; CHECK(!json_lexical_scan_string(string, sizeof(string), &offset)); + ++cases; CHECK(!json_lexical_scan_number(number, sizeof(number), NULL)); + return 0; +} + +static int test_depth_and_literal_boundaries(void) +{ + char nested[JSON_LEXICAL_MAX_DEPTH * 2 + 3]; + size_t n = JSON_LEXICAL_MAX_DEPTH; + memset(nested, '[', n); nested[n] = '0'; memset(nested + n + 1, ']', n); + ++cases; CHECK(json_lexical_validate(nested, n * 2 + 1)); + memmove(nested + 1, nested, n * 2 + 1); nested[0] = '['; nested[n * 2 + 2] = ']'; + ++cases; CHECK(!json_lexical_validate(nested, sizeof(nested))); + ++cases; CHECK(!json_lexical_validate("{", 1)); + ++cases; CHECK(!json_lexical_validate("}", 1)); + ++cases; CHECK(!json_lexical_validate("[truefalse]", 11)); + ++cases; CHECK(!json_lexical_validate("[null0]", 7)); + ++cases; CHECK(json_lexical_validate("[true,false,null]", 17)); + return 0; +} + +int main(void) +{ + RUN(test_string_scalars_and_escapes); + RUN(test_number_grammar); + RUN(test_complete_bounded_slice); + RUN(test_depth_and_literal_boundaries); + printf("=== JSON lexical: %u passed, %u failed (%u total, %u cases) ===\n", + passed, failed, total, cases); + return failed ? 1 : 0; +} diff --git a/docs-site/docs/guides/compile-time-knobs.md b/docs-site/docs/guides/compile-time-knobs.md index 8166045..41f5cf9 100644 --- a/docs-site/docs/guides/compile-time-knobs.md +++ b/docs-site/docs/guides/compile-time-knobs.md @@ -16,7 +16,7 @@ toc_labels: # 编译期配置 -SDK 提供 **22 个编译期配置项**,用于调整缓冲大小、超时、任务调度与日志级别。本页介绍如何按产品覆盖默认值,并提供设计说明和配置项速查表。 +SDK 提供 **24 个编译期配置项**,用于调整缓冲大小、超时、任务调度与日志级别。本页介绍如何按产品覆盖默认值,并提供设计说明和配置项速查表。 **推荐方式:** 将需要修改的配置项统一写入 `agentic_kit_config.h`,无需修改 SDK 源文件,也无需按模块拆分覆盖文件。 @@ -39,6 +39,12 @@ SDK 提供 **22 个编译期配置项**,用于调整缓冲大小、超时、 - **tuya-ble** — 模块日志上限,目前仅此一个配置项。 文件:`modules/tuya-ble/include/tuya_ble_config_defaults.h` +- **tuya-lan** — 帧大小与模块日志上限。 + + 文件:`modules/tuya-lan/include/tuya_lan_config_defaults.h` +- **biz-client** — 模块日志上限。 + + 文件:`modules/biz-client/include/biz_client_config_defaults.h` 统一加载入口的原因见[设计说明](#为什么这样设计);BLE 布局常量为何不属于配置项,见[配置范围](#这些名字故意不在-config-文件里)。 @@ -101,7 +107,7 @@ target_compile_definitions(my_sdk_target PRIVATE **为什么默认值分散在各子系统、覆盖挂载点却只有一个。** 这些默认值原本散落在各调用点,缓冲大小实际是**产品属性**(schema 多大、有没有 PSRAM、音频帧长多少),选型时需要按内存预算逐项审;生产事故复盘时也要能一眼回答"这块内存是哪个配置项、为什么是这个值"。现在默认值跟着所属子系统走——日志在 `common/log.h`、FreeRTOS 任务在 `pal/pal_config_defaults.h`、模块配置项在各自 include/——审预算、做评审时对着所属文件即可。 -集成方覆盖的**捡起逻辑**只存在于 `common/log.h` 一处:SDK 每个编译单元都包含这个头,各 `*_config_defaults.h` 也都先包含它,因此任何 `#ifndef` 默认值生效前,你的覆盖一定已经就位——一份 `agentic_kit_config.h` 统一覆盖全部 22 个配置项,不需要按子系统拆多个覆盖文件。 +集成方覆盖的**捡起逻辑**只存在于 `common/log.h` 一处:SDK 每个编译单元都包含这个头,各 `*_config_defaults.h` 也都先包含它,因此任何 `#ifndef` 默认值生效前,你的覆盖一定已经就位——一份 `agentic_kit_config.h` 统一覆盖全部 24 个配置项,不需要按子系统拆多个覆盖文件。 **为什么都加 `AGENTIC_KIT_` 前缀。** 撞名不是假设出来的风险:coreMQTT 自带的 `core_mqtt_config_defaults.h` 定义了同名 `MQTT_SEND_TIMEOUT_MS`(默认 20000U),与 SDK 的 2000U 谁生效取决于包含顺序;`LOG_LEVEL` 也被多个平台 SDK 占用。前缀把这些名字搬进 SDK 自己的命名空间——你的 `-D` 不会再打到别人的宏,别人的也不会打到你的。 @@ -125,7 +131,7 @@ target_compile_definitions(my_sdk_target PRIVATE **没有运行时级别,编译进什么就出什么。** 上限以内的日志无条件输出。 -需要按模块区分级别时(比如 iot-client 只要 info、rtc-tcp-client 要 debug),用**每模块上限**:`AGENTIC_KIT_IOT_LOG_LEVEL`、`AGENTIC_KIT_TAI_LOG_LEVEL`、`AGENTIC_KIT_TUYA_BLE_LOG_LEVEL`,默认都等于全局上限,且**只能把单个模块再压低**——有效上限 = 两者取小,设得比全局高没有效果(超出部分会在模块的 config 文件里被钳回全局值,块级门控看到的就是有效上限)。它们门控在各自模块词汇表的定义处(`IOT_LOG*` / `TAI_LOG*` / `TUYA_BLE_HAL_LOG*`,外加 rtc-tcp-client 的包日志格式化器),与全局上限走同一条覆盖捡起通道。 +需要按模块区分级别时(比如 iot-client 只要 info、rtc-tcp-client 要 debug),用**每模块上限**:`AGENTIC_KIT_IOT_LOG_LEVEL`、`AGENTIC_KIT_TAI_LOG_LEVEL`、`AGENTIC_KIT_TUYA_BLE_LOG_LEVEL`、`AGENTIC_KIT_LAN_LOG_LEVEL`、`AGENTIC_KIT_BIZ_LOG_LEVEL`,默认都等于全局上限,且**只能把单个模块再压低**——有效上限 = 两者取小,设得比全局高没有效果(超出部分会在模块的 config 文件里被钳回全局值,块级门控看到的就是有效上限)。它们门控在各自模块词汇表的定义处(`IOT_LOG*` / `TAI_LOG*` / `TUYA_BLE_HAL_LOG*` / `LAN_LOG*` / `BIZ_LOG*`,外加 rtc-tcp-client 的包日志格式化器),与全局上限走同一条覆盖捡起通道。 上面那个"iot 只要 info、rtc 要 debug"就是:全局保持默认 4,再加一行 `-DAGENTIC_KIT_IOT_LOG_LEVEL=3`。注意上限按**行实际输出的级别**判断:tuya-ble 的 `TUYA_BLE_HAL_LOGI` 派发在 debug,想保留它得给 4 而不是 3。 @@ -191,6 +197,8 @@ void my_sink(log_level_t level, const char *fmt, ...) | `AGENTIC_KIT_IOT_LOG_LEVEL` | = `AGENTIC_KIT_LOG_LEVEL` | 只压低 iot-client 一个模块(只降不升,见上文"日志"节) | | `AGENTIC_KIT_TAI_LOG_LEVEL` | = `AGENTIC_KIT_LOG_LEVEL` | 只压低 rtc-tcp-client;低于 3 时连包日志格式化器一起去掉 | | `AGENTIC_KIT_TUYA_BLE_LOG_LEVEL` | = `AGENTIC_KIT_LOG_LEVEL` | 只压低 tuya-ble;`TUYA_BLE_HAL_LOGI` 派发在 debug,保留它需 4 | +| `AGENTIC_KIT_LAN_LOG_LEVEL` | = `AGENTIC_KIT_LOG_LEVEL` | 只压低 tuya-lan;仅在启用 LAN 时使用 | +| `AGENTIC_KIT_BIZ_LOG_LEVEL` | = `AGENTIC_KIT_LOG_LEVEL` | 只压低 biz-client,包括角色请求警告 | ### iot-client:MQTT {#iot-client-mqtt} diff --git a/docs-site/docs/guides/porting-to-new-platform.md b/docs-site/docs/guides/porting-to-new-platform.md index 93f3dee..e79a154 100644 --- a/docs-site/docs/guides/porting-to-new-platform.md +++ b/docs-site/docs/guides/porting-to-new-platform.md @@ -28,7 +28,7 @@ RTC TCP Client 通过 PAL(Platform Abstraction Layer)实现跨平台移植 ### 1. 实现 PAL 接口 {#1-实现-pal-接口} -创建一个 `pal_xxx.c` 文件,实现所有 PAL 函数并填充 `pal_t` 结构体: +创建一个 `pal_xxx.c` 文件,实现基础 PAL 函数并填充 `pal_t` 结构体: ```c #include "pal.h" @@ -112,6 +112,20 @@ tai_config_t cfg = { TCP 部分取决于具体的网络协议栈(lwIP、AT 指令等)。 +### LAN 可选网络能力 {#lan-network} + +所有平台能力统一由 `pal_t` 提供。LAN 另需实现 `tcp_listen`、`tcp_accept`、 +`udp_bind`、`udp_sendto`、`udp_recvfrom`、`socket_local_port` 和 `udp_close`。 +监听和 UDP 操作必须非阻塞;接受的 TCP 句柄必须兼容已有的 +`tcp_send/recv/poll/close`,并保留零超时立即返回、正超时有界等待的语义。 +LAN 使用零超时驱动 TCP 收发。监听、接受及 UDP 句柄使用传入 PAL 的分配器, +关闭时必须使用对应分配器释放。 + +`pal_is_valid()` 只检查基础回调,LAN 用 `pal_has_server_network()` 检查完整的 +可选能力组。内置 POSIX/FreeRTOS 移植在 `AGENTIC_KIT_ENABLE_LAN=1` 时提供它们, +关闭时这些回调为 NULL。字段布局不受编译开关影响;扩展 `pal_t` 后必须重新编译 +SDK 与调用方。自定义移植在不使用 LAN 时可以省略这些可选回调。 + ### FreeRTOS 单调计时 {#freertos-tick-clock} FreeRTOS 计时实现位于 `pal/pal_freertos.c` 内部。应用通过 `pal_t` 的回调读取时间,无需包含独立的计时头文件: diff --git a/docs-site/docs/guides/tuya-lan-architecture.md b/docs-site/docs/guides/tuya-lan-architecture.md new file mode 100644 index 0000000..b92dd16 --- /dev/null +++ b/docs-site/docs/guides/tuya-lan-architecture.md @@ -0,0 +1,206 @@ +--- +title: Tuya LAN 架构与功能说明 +sidebar_label: Tuya LAN 架构与功能 +--- + +# Tuya LAN 架构与功能说明 + +`tuya_lan` 为已激活设备提供 Tuya LAN 3.5 本地控制服务。局域网 App 可以发现设备、建立鉴权会话、查询或下发 DP;设备应用可以向已鉴权会话显式上报状态。 + +本文说明当前实现的模块划分、数据流和运行约束。接口参数与接入代码见 [Tuya LAN 接口参考](../reference/tuya-lan.md)。协议参考基线与设计决策分别记录在 `modules/tuya-lan/CONTEXT.md` 和 `modules/tuya-lan/docs/adr/0001-lan-is-application-driven-and-shares-dp-state.md`。 + +## 一、架构与模块关系 {#architecture} + +```mermaid +flowchart LR + Peer["局域网 App"] <-->|"UDP 发现 / TCP LAN 3.5"| LAN["tuya_lan"] + LAN <-->|"on_dp / on_query / 显式 report"| App["设备应用"] + App -->|"激活资料 / 共享 DP registry"| IoT["iot-client"] + IoT <-->|"MQTT / ATOP"| Cloud["Tuya 云"] + BLE["tuya-ble:BLE 配网"] -->|"WiFi 凭据 / token"| App + App -->|"独立 AI 连接"| RTC["rtc-tcp-client / rtc-client"] + LAN --> PAL["pal_t:网络 / 分配器 / 时钟"] + PAL --> Ports["pal_posix.c / pal_freertos.c"] + LAN --> Shared["cJSON / RNG / mbedTLS"] +``` + +图中的应用负责组合各模块,箭头不表示它们共用所有回调线程。 + +- **`tuya-lan`**:管理 Discovery、TCP Session、鉴权、命令解析和发送队列;通过回调把业务请求交给应用。 +- **`iot-client`**:提供已有激活流程和 DP registry。应用将 LAN 回调连接到公开 DP 接口,使 LAN 与 MQTT 使用同一份 schema 和 DP state。 +- **`tuya-ble`**:当前负责 BLE 配网。LAN 使用激活后的设备资料,BLE 配网和 LAN 服务有各自的生命周期。 +- **RTC/TAI 模块**:负责独立的 AI Connection/Session 或音视频能力。LAN Session 与 AI Foundation Session 的协议、密钥和生命周期不同。 + +`tuya_lan` 的构建目标链接 PAL、共享基础库和 cJSON,不链接 IoT、BLE 或 RTC 库。应用可以使用 IoT DP registry,也可以实现自己的 DP 回调;LAN 协议层无需了解产品业务。 + +### 内部职责划分 {#layers} + +- **公共接口** `include/tuya_lan.h`:配置、生命周期、服务驱动、查询/控制回调、显式报告和扩展命令发送。 +- **服务层** `src/tuya_lan.c`:TCP 监听、连接容量、UDP 发现调度、部分发送、发送队列及单会话故障回收。 +- **协议层** `src/tuya_lan_protocol.c`:LAN 3.5 Frame、流式拆包、三步鉴权、Session key、序列校验和命令派发。其头文件属于模块私有接口。 +- **平台能力** `pal/pal.h`:所有平台回调统一在 `pal_t` 中。LAN 使用可选监听、accept 和 UDP 回调;接受的 TCP 连接复用已有 `tcp_send/recv/poll/close`,收发传入 `timeout_ms=0`。实现分别位于 `pal/pal_posix.c`(BSD sockets)和 `pal/pal_freertos.c`(lwIP)。 +- **共享能力**:同一份 `pal_t` 提供分配器和时钟,cJSON 处理 JSON,共享 RNG 与 mbedTLS 提供随机数和密码运算。`pal_is_valid()` 只要求基础回调,LAN 另用 `pal_has_server_network()` 检查完整的服务端/UDP 能力组。 + +## 二、已实现的功能 {#features} + +- **设备发现**:发送 UDP 周期公告,响应经过校验的发现请求。 +- **会话接入**:TCP 多连接、独立鉴权与密钥、握手/接收活跃超时、拆包和粘包处理。 +- **本地控制**:处理 `0x07` / `0x0d` DP 控制,把裸 `dps` 对象交给 `on_dp`,根据回调结果回复成功或错误。 +- **状态查询**:处理 `0x0a` / `0x10` 查询,通过 `on_query` 取得完整 `{"dps":{...}}` 文档后回复。 +- **心跳回复**:已鉴权会话收到 `0x09` 后返回成功;设备不会自行创建心跳发送任务。 +- **显式报告**:应用调用 `tuya_lan_report()`,向已鉴权会话发送 `0x08` 状态报告。 +- **扩展命令**:其余已鉴权命令交给 `on_command`,应用通过 `tuya_lan_send()` 向指定 Session 回复。 + +设备的 `device_id`、`uuid`、`product_key` 与 `local_key` 来自已有设备资料。`local_key` 必须是恰好 16 字节的字符串;MQTT 的 `secret_key`、工厂 `auth_key` 和 AI session token 不能替代它。 + +## 三、Discovery 与鉴权 {#sessions} + +### Discovery {#discovery} + +默认 TCP 监听端口为 **6668**,UDP 接收端口为 **7000**,周期公告发往 **255.255.255.255:6667**。第一次 `tuya_lan_process()` 尝试公告,后续默认间隔为 5 秒。 + +公告携带设备标识、产品信息、IPv4 和协议版本。发现请求中的 IPv4 必须与实际 UDP 来源 IP 一致,响应发送到实际来源地址及端口。公开的 Discovery key 用于发现报文格式,控制权限由后续 `local_key` 鉴权建立。 + +`discovery_interval_ms = 0` 或 `broadcast_ip = NULL` 可以停止周期公告,发现请求响应仍然工作。设备 IPv4 改变后,应使用新配置重新创建服务,更新发现报文。 + +### 三步鉴权与 Session key {#authentication} + +每个接受的 TCP 连接建立独立 Session。只有鉴权完成后才接受 DP、查询、心跳和扩展命令。 + +```mermaid +sequenceDiagram + participant Peer as 局域网 App + participant LAN as LAN Session + Peer->>LAN: 建立 TCP 连接 + Note over Peer,LAN: 握手消息使用 local_key 加密 + Peer->>LAN: 命令 3:16 字节 rand_a + LAN->>Peer: 命令 4:rand_b + HMAC(local_key, rand_a) + Peer->>LAN: 命令 5:HMAC(local_key, rand_b) + Note over LAN: 验证通过,派生 Session key 并置为已鉴权 + Note over Peer,LAN: Finish 成功没有额外 ACK;后续使用 Session key + Peer->>LAN: DP 控制 / 查询 / 心跳 / 扩展命令 +``` + +HMAC 使用 SHA-256。Session key 按当前 LAN 3.5 实现,由两端随机数与 `local_key` 派生;连接之间分别保存密钥、序列号、收包缓冲和发送队列。默认握手超时 10 秒,已鉴权后的接收活跃超时为 30 秒。任意通过认证及序列检查的入站命令都会刷新活跃时间,半帧数据不会刷新。 + +LAN 3.5 Frame 使用 AES-128-GCM,帧头参与附加认证数据校验。只有 GCM 验证通过后才提交入站序列号;序列号必须严格递增。鉴权失败、重放、乱序握手、异常帧长度、随机数失败或超时都会关闭对应 Session,其他 Session 继续工作。 + +## 四、DP 数据流与云端关系 {#dp-flow} + +### 本地控制 {#local-control} + +推荐使用公开的 `iot_client_apply_local_dps()` 接入现有产品逻辑,以下为成功路径: + +```mermaid +sequenceDiagram + participant Peer as 局域网 App + participant LAN as tuya_lan + participant App as 设备应用回调 + participant DP as IoT DP registry + Peer->>LAN: 已鉴权控制命令,包含 data.dps + LAN->>App: on_dp,裸对象如 {"1":true} + App->>DP: iot_client_apply_local_dps() + DP->>DP: 校验整批输入、准备值与通知快照 + DP->>DP: 整批提交,设置 dirty + DP-->>App: 锁外调用 save callback,再逐项 DP callback + Note over App: 持久化及硬件动作由产品实现 + DP-->>App: 返回处理结果 + App-->>LAN: 返回成功或错误 + LAN-->>Peer: 将对应 ACK 加入发送队列 + Note over App,DP: 云端上报由应用之后显式发起 +``` + +批次失败时不修改 DP 值或 dirty 标记,也不发出该批次的回调。成功后先通知持久化回调,再依次调用 DP 回调,均在内部锁之外。成功 ACK 表示应用回调接受了请求;具体硬件执行过程由产品定义。 + +该入口与 MQTT 下行有不同的 dirty 语义:本地控制置 dirty,以便应用稍后向云端同步。应用根据产品权限决定是否接受请求;当前 DP 层没有强制执行 schema 的 ro/wr/rw access mode。 + +### 查询与两条上报通道 {#query-report} + +`on_query` 写入完整 `{"dps":{...}}` 文档。应用可通过 `iot_dp_dump_json()` 获取共享快照;该接口按现有持久化规则省略 raw DP,需要包含 raw 状态时由应用构造文档。 + +状态通知有两个独立入口: + +- **向局域网 App 通知**:应用构造 `{"dps":{...}}`,调用 `tuya_lan_report()`。协议层包装为包含 `protocol:4`、`t` 和 `data.dps` 的报告。 +- **向云端同步**:应用在 IoT 所属线程调用 `iot_dp_report_all_dirty()` 等云上报接口。每次云端重连后仍应调用 `iot_dp_report_all()`,恢复前的数据没有持久化 dirty 标记。 + +LAN 查询、控制 ACK 和 LAN 报告均不会自动发起 MQTT 上报,也不清除云端 dirty 标记。LAN 服务本身不会在连接成功、控制成功或收到查询后额外推送状态。具备保存的凭据与有效 schema 时,本地控制可以在 MQTT 断开期间运行。 + +LAN 报告的 `t` 使用 `pal->wal_clock_time() / 1000` 的 UTC 秒数;当前路径在 UTC 返回 0 时会携带 `t=0`,应用应在发送报告前完成校时。握手和活跃超时使用单调 `time_ms()`,UTC 校正不影响这些期限。 + +## 五、线程、队列与返回值 {#execution} + +所有 LAN API 和回调由同一个应用线程执行。`tuya_lan_process()` 不创建后台线程,调用一次进行以下工作: + +1. 接受新连接,最多尝试 3 次;容量不足时关闭新接入连接。 +2. 逐个检查 Session 超时,尝试刷新发送队列。 +3. 每个连接最多读取 4 次,每次最多 1024 字节,处理完整 Frame 后再次尝试发送。 +4. 根据周期发送发现公告,再最多处理 4 个 UDP 数据报。 + +每次发送队列刷新最多调用 4 次发送。这些预算限制 I/O 次数,单次 pump 耗时还取决于密码运算、JSON 解析和应用回调。应用需要频繁驱动服务。 + +**共享线程时的调度**:LAN 与 IoT 可以由同一应用线程串行驱动;MQTT 操作必须保持一个线程所有者。`iot_client_process()` 的 `timeout_ms` 参数不控制实际阻塞预算,即使传入 0,接收超时仍由 `AGENTIC_KIT_MQTT_RECV_TIMEOUT_MS` 决定。应结合该配置安排 LAN 调用频率。`rtc-tcp-client`(TAI)接收回调来自其工作线程,BLE 回调来自平台 BLE 上下文;从这些上下文触发 LAN 操作时,应交回 LAN 所属线程执行。 + +**回调重入**:`on_dp`、`on_query`、`on_command` 内允许调用 `tuya_lan_report()` 和 `tuya_lan_send()`。递归 `process()`、停止、断开全部连接和销毁都应等当前回调返回后执行。 + +### 队列和错误语义 {#backpressure} + +每个 Session 有独立且有界的发送队列。部分写与 would-block 保留尚未发送的字节,后续 `process()` 继续刷新;队列溢出使对应 Session 失败并关闭。 + +- `report()` / `send()` 返回 `LAN_OK` 表示帧已排队,不能据此认定 socket 已发送完成或 App 已收到。后续发送需要继续 pump。 +- `report()` 面向全部已鉴权会话,可能出现部分会话排队成功、部分失败;没有已鉴权会话时,有效报告仍可以返回 `LAN_OK`。 +- 正常启动后的 `process()` 完成 pump 返回 `LAN_OK`。单会话网络或协议错误在内部关闭该会话,不从这个返回值逐一向外报告。 +- `tuya_lan_client_count()` 统计仍有效的 TCP 连接,包含尚未鉴权的连接,不能作为已鉴权会话数使用。 +- 控制回调错误和查询输出错误通常回复错误码 1 并保留会话。`on_command` 返回非 `LAN_OK` 则关闭会话;应用提供该回调时,需要通过 `send()` 自行回复扩展命令。 + +## 六、配置与资源预算 {#resources} + +运行默认值由 `tuya_lan_config_init()` 填写:最多 **3 个连接**、握手 **10 秒**、接收活跃 **30 秒**、发现公告间隔 **5 秒**。连接数量可设为 1–3;TCP/UDP 监听端口可设为 0 申请临时端口,通过端口 getter 取得实际值,发现目的端口须为非零值。 + +编译期 `AGENTIC_KIT_LAN_MAX_FRAME_SIZE` 默认 **4096 字节**,支持 128–65536。它是整帧上限,JSON 或扩展数据还需预留 Frame、返回码及相应内层头的开销。`AGENTIC_KIT_LAN_LOG_LEVEL` 默认跟随 SDK 全局日志上限,只能进一步降低日志级别。 + +设最大帧长度为 `F`,每个接入 Session 的接收缓冲为 `F`,发送队列为 `2F`;服务另有两个 `F` 缓冲。默认 3 个连接均接入时,仅这些缓冲合计 **44 KiB**。结构体、JSON、密码运算临时内存和 socket 还需要额外空间,44 KiB 不是总内存峰值。 + +## 七、接入与生命周期 {#integration} + +1. 在启动阶段调用一次 `iot_init()` 或 `iot_init_default()`,建立共享 PAL/RNG/cJSON 分配器绑定。`tuya_lan_config_init()` 只填写配置默认值。 +2. 取得已激活设备的资料、当前 IPv4 和产品 schema,初始化应用使用的 DP registry。 +3. 填写 `pal`、设备资料以及业务回调,调用 `tuya_lan_create()` 分配服务。`config.pal` 必须复用第 1 步初始化的同一份长期有效 PAL;各 SDK 模块共享 PAL/cJSON 分配器,不能为 LAN 另行绑定一套。此时还没有监听 socket。 +4. 调用 `tuya_lan_start()` 打开 TCP/UDP,并在应用循环中调用 `tuya_lan_process()`。 +5. 按产品策略显式进行 LAN 报告和云端上报。停止时先 `tuya_lan_stop()`,不再使用时 `tuya_lan_destroy()`。 + +`stop()` 后可重新 `start()`。配置采用浅拷贝:PAL、设备字符串及回调的 `user` 数据必须在使用期间保持有效。解绑、复位、换密钥、变更 IPv4 或释放凭据来源对象前,先停止并销毁 LAN,再用新配置重建。 + +### 编译开关 {#build-options} + +LAN 默认关闭。主机通过 CMake 开启: + +```bash +cmake -S . -B build -DAGENTIC_KIT_ENABLE_LAN=ON -DPython3_EXECUTABLE=$(which python3) +cmake --build build -j 4 +``` + +ESP-IDF 在 **Agentic Kit** 菜单启用 LAN,对应 `CONFIG_AGENTIC_KIT_ENABLE_LAN=y`。构建系统向应用导出 `AGENTIC_KIT_ENABLE_LAN=0/1`,可用于条件编译。 + +关闭时移除 LAN 实现、LAN 测试和示例,并裁剪平台 PAL 中的可选服务端/UDP 实现,使相应回调为 NULL;通用 `iot_client_apply_local_dps()` 入口仍然可用。`pal_t` 的字段布局不随开关改变,新增字段后所有使用方须重新编译。自定义 PAL 仅在使用 LAN 时需要实现可选能力组。运行期 `start()` / `stop()` 则控制已编译服务的监听状态。 + +完整配置、回调示例与 POSIX `lan_demo` 的启动方式见 [接口参考](../reference/tuya-lan.md#build)。 + +## 八、能力边界与验证 {#limits-validation} + +当前提供 LAN 3.5 Discovery、鉴权和本地 DP 服务。旧版 LAN 与 AP 配网属于其他能力。 + +`TUYA_LAN_CMD_P2P = 0x20` 可以作为已鉴权的扩展命令交给应用。它提供信令承载入口;当前模块没有实现 P2P 协商状态机、NAT 穿透、媒体传输或 RTC 桥接。这些功能需要应用接入其他实现,AI session token 也不用于 LAN 鉴权。 + +现有验证分为以下几层: + +- `test_protocol.c`:鉴权、Session key、Frame 校验、序列和重放、JSON 以及密码运算失败路径。 +- `test_service.c`:生命周期、连接隔离、发现调度、部分发送和背压。 +- `test_peer.py`:独立加密 peer 通过真实回环 socket 验证协议与共享 DP registry。 +- `pal/test/test_net.c`:同一组回环网络用例覆盖 POSIX 与真实 FreeRTOS PAL 源码(主机内核声明 shim),验证监听、UDP、连接/接受共用 TCP 接口、零/正超时和资源回收。 +- `iot_dp_test.c`:本地 DP 批次原子性、分配失败回滚、回调生命周期以及 UTC/空上报回归。 + +```bash +ctest --test-dir build -R '^(tuya_lan_.*|pal_net_test|pal_freertos_net_test|iot_dp_test)$' --output-on-failure --no-tests=error +``` + +测试保持串行。自动化用例无需云端凭据;真实 App 互通、目标硬件及无线网络行为仍需在产品环境验证。 diff --git a/docs-site/docs/reference/biz-client.md b/docs-site/docs/reference/biz-client.md index 265dc2b..519f6c2 100644 --- a/docs-site/docs/reference/biz-client.md +++ b/docs-site/docs/reference/biz-client.md @@ -17,6 +17,10 @@ void tuya_biz_client_destroy(tuya_biz_client_t *client); 借用 IoT Client 及其 PAL。`request_lang=NULL` 默认 `zh`,非空值最多 31 字节。创建失败时 `*out=NULL`。先 `iot_init`,再创建;所有结果必须在销毁客户端前释放,所有调用必须在销毁 IoT Client 前完成。多实例可独立持有客户端和展示语言;调用方负责串行调度。 +## 日志 {#logging} + +`AGENTIC_KIT_BIZ_LOG_LEVEL` 默认等于 SDK 全局 `AGENTIC_KIT_LOG_LEVEL`,只能进一步降低 biz-client 的编译期日志上限。设为 `0` 可关闭本模块日志;设得高于全局上限会被钳回全局值。覆盖方式见[编译期配置](../guides/compile-time-knobs.md)。 + ## 角色 API {#role-api} 下表省略共同首参 `tuya_biz_client_t *` 和末尾可选参数 `iot_atop_rejection_t *`,完整声明以公开头文件为准。 diff --git a/docs-site/docs/reference/tuya-lan.md b/docs-site/docs/reference/tuya-lan.md new file mode 100644 index 0000000..6519b74 --- /dev/null +++ b/docs-site/docs/reference/tuya-lan.md @@ -0,0 +1,152 @@ +--- +title: Tuya LAN 本地控制 +sidebar_label: Tuya LAN +--- + +# Tuya LAN 本地控制 + +模块关系、功能边界与数据流见[Tuya LAN 架构与功能说明](../guides/tuya-lan-architecture.md)。 +本页说明接口、配置与接入方法。 + +## 职责 {#overview} + +`modules/tuya-lan`(CMake 目标 `tuya_lan`)为已激活设备提供 Tuya LAN 3.5 +本地控制。它负责 UDP 发现、TCP 会话、三步鉴权、心跳、DP 查询、DP 下发和显式状态上报。 +设备身份与 `local_key` 来自已有激活流程;schema、DP state 和产品业务继续由应用持有。 + +模块由应用调用 `tuya_lan_process()` 驱动,没有后台线程。LAN 与 MQTT 可独立连接; +持有有效凭据和 schema 后,LAN 控制不需要保持 MQTT 在线。 + +协议参考 [TuyaOpen 固定版本](https://github.com/tuya/TuyaOpen/tree/68f76c966ef6154c319ce122506d4e0934b2979b/src/tuya_cloud_service/lan)。 +本实现只提供 LAN 3.5。旧版 LAN、AP 配网和 P2P 媒体属于其他能力。 + +## 构建与示例 {#build} + +公共头为 `tuya_lan.h` 和统一平台接口 `pal.h`。网络实现分别位于 +`pal_posix.c`(BSD sockets)和 `pal_freertos.c`(lwIP)。`pal_t` 末尾新增可选 +服务端/UDP 回调,所有使用方须重新编译;自定义移植仅在使用 LAN 时需要实现这组回调。 + +LAN 默认不参与构建。主机使用 CMake 选项 `AGENTIC_KIT_ENABLE_LAN` 显式开启: + +```bash +cmake -S . -B build -DAGENTIC_KIT_ENABLE_LAN=ON -DPython3_EXECUTABLE=$(which python3) +cmake --build build -j 4 +``` + +关闭时重新配置 `cmake -S . -B build -DAGENTIC_KIT_ENABLE_LAN=OFF`。关闭后不构建 +`tuya_lan`、LAN 测试、`pal_net_test`、`pal_freertos_net_test` 和 `lan_demo`; +平台 PAL 中的可选服务端/UDP 实现也被裁剪,其回调为 NULL,基础 PAL 仍然可用; +`iot_client_apply_local_dps()` 仍可供其他本地控制通道使用。 + +独立示例工程也需显式开启: + +```bash +cmake -S examples/posix -B build-examples -DAGENTIC_KIT_ENABLE_LAN=ON -DPython3_EXECUTABLE=$(which python3) +cmake --build build-examples --target lan_demo -j 4 +./build-examples/lan_demo 192.168.1.20 +``` + +ESP-IDF 在 `idf.py menuconfig` 的 **Agentic Kit** 菜单中开启 LAN,或在应用的 +`sdkconfig.defaults` 中设置 `CONFIG_AGENTIC_KIT_ENABLE_LAN=y`;默认关闭。 +构建系统向 SDK 使用方导出 `AGENTIC_KIT_ENABLE_LAN=0` 或 `1`,应用可用 +`#if AGENTIC_KIT_ENABLE_LAN` 条件编译 LAN 集成代码。该宏由构建选项决定。 + +运行前,在环境中设置 `TUYA_DEVICE_ID`、`TUYA_LOCAL_KEY`、`TUYA_UUID` 和 +`TUYA_PRODUCT_KEY`,使用同一设备保存的激活资料。示例不输出密钥。 +`local_key` 必须为 16 字节字符串。 + +示例展示一个 bool DP(ID 1),使用离线 DP registry,不连接云端;生产应用应复用 +已有 IoT client 及真实产品 schema。示例可额外传入 TCP/UDP 端口,端口 0 使用系统 +分配的临时端口;默认监听 TCP 6668、UDP 7000,发现公告发送到 UDP 6667。 + +## 接口与配置 {#api} + +- `tuya_lan_config_init()`:设置默认端口、最多 3 个会话、10 秒握手超时、30 秒 + 心跳超时和 5 秒发现公告间隔。`discovery_interval_ms = 0` 关闭周期公告。 +- `tuya_lan_create()` / `start()` / `stop()` / `destroy()`:分配服务、开始监听、 + 关闭连接和释放资源。停止后可以重新启动;凭据改变时销毁并重新创建。 +- `tuya_lan_process()`:非阻塞处理有界的接收、发送、发现和超时工作,应用应频繁调用。 +- `tuya_lan_report()`:显式向已鉴权会话上报完整 `{"dps":{...}}` 文档。 +- `tuya_lan_send()`:向指定会话发送扩展命令回复。 +- `tuya_lan_disconnect_all()`:关闭所有会话;端口 getter 可取得实际监听端口。 + +`start()` / `stop()` 控制已编译 LAN 服务的运行状态;是否编译 LAN 由上述构建选项控制。 + +`on_dp` 接收裸 DP 对象,例如 `{"1":true}`;返回 0 表示应用接受,非 0 产生错误回复。 +`on_query` 向调用方的有界缓冲写入完整 `{"dps":{...}}` 文档,并设置实际长度。 +输入与输出长度都不含 NUL 终止符。回调收到的数据只在回调期间有效。 + +## 复用 IoT DP {#integration} + +```c +static int on_lan_dp(const char *json, size_t len, void *user) +{ + return iot_client_apply_local_dps(user, json, len); +} + +static int on_lan_query(char *out, size_t cap, size_t *len, void *user) +{ + iot_client_t *client = user; + char *state = NULL; + if (iot_dp_dump_json(client, &state) != OPRT_OK) return LAN_ERR_MEMORY; + size_t n = strlen(state); + int rc = n <= cap ? LAN_OK : LAN_ERR_LIMIT; + if (rc == LAN_OK) { memcpy(out, state, n); *len = n; } + client->pal->free(state); + return rc; +} +``` + +`iot_client_apply_local_dps()` 验证完整非空批次并准备通知快照,全部成功后才更新状态。 +错误不修改值或 dirty 标记。成功将本地变更标记 dirty,在锁外先调用持久化回调, +再逐项调用现有 DP 回调。裸 DP ID 必须是无前导零、无重复的十进制 1–255。 + +LAN 查询和上报不清除 MQTT dirty 标记,也不发起云端上报。应用在 IoT 所属线程 +调用 `iot_dp_report_all_dirty()`;每次云端重连后仍需 `iot_dp_report_all()`。 +`iot_dp_dump_json()` 按现有规则省略 raw DP;需要 raw 状态通知时由应用显式构造报告。 +schema 的 access mode 当前没有强制执行,产品授权策略应在应用接受 DP 前完成。 + +## 生命周期与平台 {#lifecycle} + +启动前调用一次 `iot_init()` 或 `iot_init_default()`,建立共享 PAL/RNG/cJSON 分配器。 +配置字符串必须活到服务销毁。设备解绑、复位、换密钥或释放 IoT client 前先停止 LAN, +然后用新凭据和当前 IPv4 重新创建服务。 + +所有 LAN 接口和回调使用同一个应用线程;回调可发送回复或报告,销毁、停止、断开全部 +连接和递归 `process()` 要等回调返回后执行。与 MQTT 共用线程时也要保证 LAN 获得 +足够调用频率;`iot_client_process()` 的阻塞预算由编译期配置决定。 + +移植通过同一份 `pal_t` 提供可选的监听、接受连接和 UDP 操作。 +`tuya_lan_create()` 使用 `pal_has_server_network()` 检查完整能力组;基础 +`pal_is_valid()` 仍只检查原有必填回调。接受的 TCP 连接复用已有 +`tcp_send/recv/poll/close`,LAN 收发传入 `timeout_ms=0`,不会等待网络数据。 +配置只需填写 `config.pal`,并复用 SDK 初始化时的共享 PAL。 +模块处理部分发送和 would-block;每会话缓冲和每次 pump 工作有界。 +不要在模块源文件直接调用平台 socket。 + +## 协议边界 {#limits} + +LAN 3.5 使用 AES-GCM 帧与 HMAC 随机数握手。鉴权前不接受 DP、心跳或扩展命令; +坏标签、重放、异常长度和超时关闭对应会话。发现密钥是公开协议常量,不能授权控制。 +发现回复校验请求内 IP 与实际来源一致,并回复实际来源地址和端口。 + +LAN 与本地 DP 输入共用有界 JSON 字符串和数字词法校验,拒绝无效 UTF-8、 +错误转义、不成对代理项及解码后的 NUL;JSON 结构仍由 cJSON 校验。 + +`on_command` 可接收已鉴权的扩展数据,包括 `0x20` P2P 信令;P2P 协商和媒体传输 +仍由另外的模块实现。AI session token 不用于 LAN 会话。 + +`AGENTIC_KIT_LAN_MAX_FRAME_SIZE` 默认 4096(允许 128–65536)。 +`AGENTIC_KIT_LAN_LOG_LEVEL` 默认跟随全局日志上限,且只能降低日志级别。 + +## 验证 {#verification} + +```bash +cmake -S . -B build -DAGENTIC_KIT_ENABLE_LAN=ON -DPython3_EXECUTABLE=$(which python3) +cmake --build build -j 4 +ctest --test-dir build -R '^(tuya_lan_.*|pal_net_test|pal_freertos_net_test|iot_dp_test)$' --output-on-failure +``` + +协议测试使用独立加密端验证握手、重放、错误帧及资源失败;服务测试验证背压和生命周期; +Python peer 使用临时回环端口贯穿真实网络服务和 IoT DP registry,无需云凭据。 +完整 ctest 保持串行。真实 App 联调和硬件无线网络验证应在目标设备上完成。 diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/compile-time-knobs.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/compile-time-knobs.md index 6bc9f95..2e31db1 100644 --- a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/compile-time-knobs.md +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/compile-time-knobs.md @@ -16,7 +16,7 @@ toc_labels: # Compile-Time Configuration -The SDK provides **22 compile-time configuration options** for buffers, timeouts, task scheduling, and log levels. This page explains how to override defaults per product, describes the design, and provides quick-reference tables. +The SDK provides **24 compile-time configuration options** for buffers, timeouts, task scheduling, and log levels. This page explains how to override defaults per product, describes the design, and provides quick-reference tables. **Recommended approach:** put the configuration options you want to change in one `agentic_kit_config.h`. There is no need to edit SDK sources or create separate override files per module. @@ -39,6 +39,12 @@ Defaults live with their owning subsystem. Each file's comments document the uni - **tuya-ble** — the module log ceiling, currently its only configuration option. File: `modules/tuya-ble/include/tuya_ble_config_defaults.h` +- **tuya-lan** — frame size and the module log ceiling. + + File: `modules/tuya-lan/include/tuya_lan_config_defaults.h` +- **biz-client** — the module log ceiling. + + File: `modules/biz-client/include/biz_client_config_defaults.h` See the [design rationale](#为什么这样设计) for the shared configuration entry point, and [configuration scope](#这些名字故意不在-config-文件里) for why BLE layout constants are not configuration options. @@ -101,7 +107,7 @@ Configuration options are compile-time constants that directly determine struct **Why the defaults are distributed across subsystems while the override pickup is in one place.** These defaults used to be scattered at their call sites, and buffer sizes are really **product properties** (how large the schema is, whether there is PSRAM, the audio frame length) — choosing them means going over the memory budget item by item, and a production incident review needs to answer at a glance "which configuration option owns this memory and why that value". Now each default lives with its owning subsystem — logs in `common/log.h`, the FreeRTOS task in `pal/pal_config_defaults.h`, module configuration options in each module's include/ — so budget reviews and code reviews look at the owning file. -The **pickup logic** for integrator overrides, though, exists in exactly one place, `common/log.h`: every SDK translation unit includes that header, and every `*_config_defaults.h` includes it first, so by the time any `#ifndef` default takes effect your override is already in place — one `agentic_kit_config.h` overrides all 22 configuration options, with no need to split overrides per subsystem. +The **pickup logic** for integrator overrides, though, exists in exactly one place, `common/log.h`: every SDK translation unit includes that header, and every `*_config_defaults.h` includes it first, so by the time any `#ifndef` default takes effect your override is already in place — one `agentic_kit_config.h` overrides all 24 configuration options, with no need to split overrides per subsystem. **Why every configuration option carries the `AGENTIC_KIT_` prefix.** The name collision is not hypothetical: coreMQTT's bundled `core_mqtt_config_defaults.h` defines a same-named `MQTT_SEND_TIMEOUT_MS` (default 20000U) that fought the SDK's 2000U by include order, and `LOG_LEVEL` is claimed by several platform SDKs. The prefix moves these names into the SDK's own namespace — your `-D` no longer hits someone else's macro, and theirs no longer hits yours. @@ -125,7 +131,7 @@ Lines above the ceiling vanish at compile time: no function call, no argument ev **There is no runtime level: what compiles in is what prints.** Lines at or below the ceiling emit unconditionally. -When modules need different levels (iot-client at info while rtc-tcp-client stays at debug, say), use the **per-module ceilings**: `AGENTIC_KIT_IOT_LOG_LEVEL`, `AGENTIC_KIT_TAI_LOG_LEVEL` and `AGENTIC_KIT_TUYA_BLE_LOG_LEVEL` all default to the SDK-wide ceiling and can only **lower their one module** further — the effective ceiling is the smaller of the two, so a value above `AGENTIC_KIT_LOG_LEVEL` has no effect (the excess is clamped back to the global value in the module's config file, so block-level gates see the effective ceiling). They gate each module's vocabulary where it is defined (`IOT_LOG*` / `TAI_LOG*` / `TUYA_BLE_HAL_LOG*`, plus rtc-tcp-client's packet-log formatter) and ride the same override pickup as the global ceiling. +When modules need different levels (iot-client at info while rtc-tcp-client stays at debug, say), use the **per-module ceilings**: `AGENTIC_KIT_IOT_LOG_LEVEL`, `AGENTIC_KIT_TAI_LOG_LEVEL`, `AGENTIC_KIT_TUYA_BLE_LOG_LEVEL`, `AGENTIC_KIT_LAN_LOG_LEVEL` and `AGENTIC_KIT_BIZ_LOG_LEVEL` all default to the SDK-wide ceiling and can only **lower their one module** further — the effective ceiling is the smaller of the two, so a value above `AGENTIC_KIT_LOG_LEVEL` has no effect (the excess is clamped back to the global value in the module's config file, so block-level gates see the effective ceiling). They gate each module's vocabulary where it is defined (`IOT_LOG*` / `TAI_LOG*` / `TUYA_BLE_HAL_LOG*` / `LAN_LOG*` / `BIZ_LOG*`, plus rtc-tcp-client's packet-log formatter) and ride the same override pickup as the global ceiling. The example above — iot at info, rtc at debug — is the default ceiling of 4 plus one line, `-DAGENTIC_KIT_IOT_LOG_LEVEL=3`. Note that the ceiling applies at the level a line actually emits at: tuya-ble's `TUYA_BLE_HAL_LOGI` dispatches at debug, so keeping it takes a 4, not a 3. @@ -191,6 +197,8 @@ See [Where Defaults Live](#默认值在哪里) for each subsystem's file path. T | `AGENTIC_KIT_IOT_LOG_LEVEL` | = `AGENTIC_KIT_LOG_LEVEL` | Lower iot-client alone (lower-only, see the log section above) | | `AGENTIC_KIT_TAI_LOG_LEVEL` | = `AGENTIC_KIT_LOG_LEVEL` | Lower rtc-tcp-client alone; below 3 the packet-log formatter goes too | | `AGENTIC_KIT_TUYA_BLE_LOG_LEVEL` | = `AGENTIC_KIT_LOG_LEVEL` | Lower tuya-ble alone; `TUYA_BLE_HAL_LOGI` dispatches at debug, keeping it takes a 4 | +| `AGENTIC_KIT_LAN_LOG_LEVEL` | = `AGENTIC_KIT_LOG_LEVEL` | Lower tuya-lan alone; used only when LAN is enabled | +| `AGENTIC_KIT_BIZ_LOG_LEVEL` | = `AGENTIC_KIT_LOG_LEVEL` | Lower biz-client alone, including role-request warnings | ### iot-client: MQTT {#iot-client-mqtt} diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/porting-to-new-platform.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/porting-to-new-platform.md index dcf5c68..0422c20 100644 --- a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/porting-to-new-platform.md +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/porting-to-new-platform.md @@ -28,7 +28,7 @@ The RTC TCP Client uses a PAL (Platform Abstraction Layer) for cross-platform po ### 1. Implement the PAL Interfaces {#1-实现-pal-接口} -Create a `pal_xxx.c` file, implement every PAL function, and populate the `pal_t` struct: +Create a `pal_xxx.c` file, implement the base PAL functions, and populate the `pal_t` struct: ```c #include "pal.h" @@ -113,6 +113,22 @@ Reference implementation: `pal/pal_posix.c` The TCP implementation depends on the specific network stack, such as lwIP or AT commands. +### Optional LAN Networking {#lan-network} + +All platform capabilities share `pal_t`. LAN additionally needs `tcp_listen`, +`tcp_accept`, `udp_bind`, `udp_sendto`, `udp_recvfrom`, `socket_local_port` and +`udp_close`. Listening and datagram operations must be nonblocking. Accepted TCP +handles must work with the existing `tcp_send/recv/poll/close`, preserving immediate +returns at timeout zero and bounded waits at positive timeouts. LAN uses timeout +zero for TCP I/O. Listening, accepted and UDP handles use the supplied PAL's +allocator and must be released through that same allocator. + +`pal_is_valid()` checks only base callbacks; LAN checks the complete optional +group with `pal_has_server_network()`. The built-in POSIX/FreeRTOS ports supply +them with `AGENTIC_KIT_ENABLE_LAN=1` and leave them NULL when disabled. Field +layout is independent of the build option; rebuild the SDK and all consumers +after extending `pal_t`. Custom ports may omit this group when not using LAN. + ### FreeRTOS Monotonic Time {#freertos-tick-clock} The FreeRTOS clock implementation is private to `pal/pal_freertos.c`. Applications read time through the `pal_t` callbacks, without a separate time header: diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/tuya-lan-architecture.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/tuya-lan-architecture.md new file mode 100644 index 0000000..d18d8e4 --- /dev/null +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/tuya-lan-architecture.md @@ -0,0 +1,206 @@ +--- +title: Tuya LAN Architecture and Features +sidebar_label: Tuya LAN Architecture and Features +--- + +# Tuya LAN Architecture and Features + +`tuya_lan` provides a Tuya LAN 3.5 local control service for activated devices. An App on the local network can discover a device, establish an authenticated Session, and query or control DPs. The device application can explicitly report state to authenticated Sessions. + +This guide describes the current implementation's modules, data flow and execution constraints. For API parameters and integration code, see the [Tuya LAN API Reference](../reference/tuya-lan.md). The protocol reference baseline and design decisions are recorded in `modules/tuya-lan/CONTEXT.md` and `modules/tuya-lan/docs/adr/0001-lan-is-application-driven-and-shares-dp-state.md`. + +## 1. Architecture and Module Relationships {#architecture} + +```mermaid +flowchart LR + Peer["Local network App"] <-->|"UDP discovery / TCP LAN 3.5"| LAN["tuya_lan"] + LAN <-->|"on_dp / on_query / explicit report"| App["Device application"] + App -->|"Activation data / shared DP registry"| IoT["iot-client"] + IoT <-->|"MQTT / ATOP"| Cloud["Tuya Cloud"] + BLE["tuya-ble: BLE provisioning"] -->|"WiFi credentials / token"| App + App -->|"Independent AI connection"| RTC["rtc-tcp-client / rtc-client"] + LAN --> PAL["pal_t: network / allocation / clocks"] + PAL --> Ports["pal_posix.c / pal_freertos.c"] + LAN --> Shared["cJSON / RNG / mbedTLS"] +``` + +The application composes these modules. The arrows do not imply that all callbacks share a thread. + +- **`tuya-lan`** manages Discovery, TCP Sessions, authentication, command parsing and send queues. It passes business requests to the application through callbacks. +- **`iot-client`** provides the existing Activation flow and DP registry. The application connects LAN callbacks to public DP APIs so that LAN and MQTT use the same schema and DP state. +- **`tuya-ble`** currently handles BLE provisioning. LAN uses the device's Activation data; BLE provisioning and the LAN service have separate lifecycles. +- **RTC/TAI modules** provide independent AI Connection/Session or audio/video capabilities. A LAN Session and an AI Foundation Session have different protocols, keys and lifecycles. + +The `tuya_lan` build target links the PAL, shared infrastructure library and cJSON. It does not link the IoT, BLE or RTC libraries. Applications can use the IoT DP registry or implement their own DP callbacks; the LAN protocol layer does not need to understand product behavior. + +### Internal Responsibilities {#layers} + +- **Public API**, `include/tuya_lan.h`: configuration, lifecycle, service pumping, query/control callbacks, explicit reports and extension command sending. +- **Service layer**, `src/tuya_lan.c`: TCP listening, connection capacity, UDP discovery scheduling, partial sends, send queues and cleanup of individual failed Sessions. +- **Protocol layer**, `src/tuya_lan_protocol.c`: LAN 3.5 Frames, stream framing, three-step authentication, Session keys, sequence validation and command dispatch. Its header is private to the module. +- **Platform capabilities**, `pal/pal.h`: all platform callbacks share `pal_t`. LAN uses optional listening, accept and UDP callbacks. Accepted TCP connections reuse the existing `tcp_send/recv/poll/close` callbacks with `timeout_ms=0` for sends and receives. Implementations live in `pal/pal_posix.c` (BSD sockets) and `pal/pal_freertos.c` (lwIP). +- **Shared capabilities**: the same `pal_t` supplies allocation and clocks, cJSON handles JSON, and the shared RNG and mbedTLS provide randomness and cryptographic operations. `pal_is_valid()` requires only base callbacks; LAN checks its complete server/datagram group with `pal_has_server_network()`. + +## 2. Implemented Features {#features} + +- **Device discovery**: periodic UDP announcements and responses to validated discovery requests. +- **Session handling**: multiple TCP connections, independent authentication and keys, handshake/receive-activity deadlines, and handling of fragmented or coalesced Frames. +- **Local control**: commands `0x07` / `0x0d` pass a bare `dps` object to `on_dp`, then return success or an error according to the callback result. +- **State queries**: commands `0x0a` / `0x10` use `on_query` to obtain a complete `{"dps":{...}}` document for the response. +- **Heartbeat replies**: authenticated Sessions receive a success response to `0x09`. The device does not create a heartbeat sending task. +- **Explicit reports**: the application calls `tuya_lan_report()` to send a `0x08` state report to authenticated Sessions. +- **Extension commands**: other authenticated commands reach `on_command`. The application uses `tuya_lan_send()` to reply to a specified Session. + +The device's `device_id`, `uuid`, `product_key` and `local_key` come from its existing device data. `local_key` must be a string containing exactly 16 bytes. The MQTT `secret_key`, factory `auth_key` and AI session token cannot replace it. + +## 3. Discovery and Authentication {#sessions} + +### Discovery {#discovery} + +The default TCP listening port is **6668**, the UDP receive port is **7000**, and periodic announcements go to **255.255.255.255:6667**. The first `tuya_lan_process()` attempts an announcement; the default interval thereafter is five seconds. + +Announcements carry device identity, product information, IPv4 and protocol version. The IPv4 address in a discovery request must match its actual UDP source IP. Responses go to the actual source address and port. The public Discovery key is used for the discovery message format; subsequent `local_key` authentication establishes control authorization. + +Setting `discovery_interval_ms = 0` or `broadcast_ip = NULL` disables periodic announcements while discovery request responses remain available. Recreate the service with new configuration after the device's IPv4 address changes so discovery messages carry the new address. + +### Three-Step Authentication and Session Keys {#authentication} + +Each accepted TCP connection creates an independent Session. DP, query, heartbeat and extension commands are accepted only after authentication completes. + +```mermaid +sequenceDiagram + participant Peer as Local network App + participant LAN as LAN Session + Peer->>LAN: Establish TCP connection + Note over Peer,LAN: Handshake messages are encrypted with local_key + Peer->>LAN: Command 3: 16-byte rand_a + LAN->>Peer: Command 4: rand_b + HMAC(local_key, rand_a) + Peer->>LAN: Command 5: HMAC(local_key, rand_b) + Note over LAN: Validate, derive Session key and mark authenticated + Note over Peer,LAN: Successful Finish has no extra ACK, subsequent messages use the Session key + Peer->>LAN: DP control / query / heartbeat / extension command +``` + +HMAC uses SHA-256. The current LAN 3.5 implementation derives the Session key from both peers' random values and `local_key`. Each connection retains its own key, sequence counters, receive buffer and send queue. The default handshake deadline is 10 seconds; after authentication, the receive-activity deadline is 30 seconds. Any incoming command that passes authentication and sequence validation refreshes activity. Partial Frame data does not refresh it. + +LAN 3.5 Frames use AES-128-GCM with the header as authenticated additional data. Incoming sequence counters are committed only after GCM authentication succeeds; sequence numbers must strictly increase. Authentication failure, replay, out-of-order handshake messages, invalid Frame lengths, randomness failure or timeout close the affected Session while other Sessions continue working. + +## 4. DP Data Flow and the Cloud {#dp-flow} + +### Local Control {#local-control} + +Use the public `iot_client_apply_local_dps()` API to connect LAN control to existing product behavior. The following diagram shows the success path: + +```mermaid +sequenceDiagram + participant Peer as Local network App + participant LAN as tuya_lan + participant App as Device application callback + participant DP as IoT DP registry + Peer->>LAN: Authenticated control command containing data.dps + LAN->>App: on_dp, bare object such as {"1":true} + App->>DP: iot_client_apply_local_dps() + DP->>DP: Validate the batch and prepare values and notification snapshots + DP->>DP: Commit the entire batch and set dirty + DP-->>App: Outside the lock, call save callback, then each DP callback + Note over App: The product implements persistence and hardware actions + DP-->>App: Return result + App-->>LAN: Return success or error + LAN-->>Peer: Queue the corresponding ACK + Note over App,DP: The application explicitly reports to the cloud later +``` + +A failed batch leaves DP values and dirty flags unchanged and invokes none of that batch's callbacks. After success, the persistence callback runs first, followed by each DP callback, all outside the internal lock. A success ACK means the application callback accepted the request; the product defines how hardware execution proceeds. + +This entry point has different dirty semantics from MQTT downlinks: local controls set dirty so the application can synchronize with the cloud later. The application decides whether product permissions allow a request. The DP layer currently does not enforce the schema's ro/wr/rw access mode. + +### Queries and the Two Reporting Channels {#query-report} + +`on_query` writes a complete `{"dps":{...}}` document. The application can obtain a shared snapshot through `iot_dp_dump_json()`. This API follows the existing persistence rules and omits raw DPs; construct the document in the application when raw state must be included. + +State notifications have two independent entry points: + +- **Notify the local network App**: the application constructs `{"dps":{...}}` and calls `tuya_lan_report()`. The protocol layer wraps it in a report containing `protocol:4`, `t` and `data.dps`. +- **Synchronize with the cloud**: the application calls a cloud reporting API such as `iot_dp_report_all_dirty()` on the IoT owner thread. It should still call `iot_dp_report_all()` after every cloud reconnect because restored data does not retain persisted dirty flags. + +LAN queries, control ACKs and LAN reports neither initiate MQTT reporting nor clear cloud dirty flags. The LAN service does not push additional state after a connection succeeds, a control succeeds or a query arrives. Local control can operate while MQTT is disconnected when saved credentials and a valid schema are available. + +The `t` field in a LAN report uses UTC seconds from `pal->wal_clock_time() / 1000`. This path currently sends `t=0` when UTC returns zero, so the application should synchronize time before reporting. Handshake and activity deadlines use monotonic `time_ms()`; UTC corrections do not affect these deadlines. + +## 5. Threads, Queues and Return Values {#execution} + +All LAN APIs and callbacks execute on one application thread. `tuya_lan_process()` creates no worker. Each call performs the following work: + +1. Accept new connections, with at most three attempts; close new connections when capacity is exhausted. +2. Check each Session's deadline and try to flush its send queue. +3. Read each connection at most four times, up to 1024 bytes per read, then try sending again after processing complete Frames. +4. Send a discovery announcement when due, then process at most four UDP datagrams. + +Each send-queue flush makes at most four send calls. These budgets limit I/O attempts; pump duration also depends on cryptographic operations, JSON parsing and application callbacks. The application must pump the service frequently. + +**Scheduling on a shared thread**: LAN and IoT can run serially on the same application thread; MQTT operations must retain a single thread owner. The `timeout_ms` argument to `iot_client_process()` does not control its actual blocking budget. Even when it is zero, `AGENTIC_KIT_MQTT_RECV_TIMEOUT_MS` determines the receive timeout. Account for this configuration when scheduling LAN calls. Receive callbacks in `rtc-tcp-client` (TAI) run on its worker thread, while BLE callbacks run in the platform BLE context. Hand LAN operations triggered from those contexts back to the LAN owner thread. + +**Callback reentrancy**: `on_dp`, `on_query` and `on_command` may call `tuya_lan_report()` and `tuya_lan_send()`. Defer recursive `process()`, stop, disconnect-all and destroy operations until the current callback returns. + +### Queues and Error Semantics {#backpressure} + +Each Session has its own bounded send queue. Partial writes and would-block results retain unsent bytes for subsequent `process()` calls. Queue overflow fails and closes the affected Session. + +- `LAN_OK` from `report()` / `send()` means the Frame was queued. It does not establish that the socket finished sending or the App received it. Continue pumping to send queued data. +- `report()` targets all authenticated Sessions. Some Sessions may accept the queued report while others fail. A valid report may return `LAN_OK` when there are no authenticated Sessions. +- After a normal start, `process()` returns `LAN_OK` when the pump completes. Network or protocol failures in an individual Session close it internally rather than being reported individually through this return value. +- `tuya_lan_client_count()` counts valid TCP connections, including connections that are not yet authenticated. It is not an authenticated Session count. +- Control callback errors and query output errors normally produce error code 1 and keep the Session open. A non-`LAN_OK` result from `on_command` closes the Session. When the application supplies this callback, it must reply to extension commands through `send()`. + +## 6. Configuration and Resource Budget {#resources} + +`tuya_lan_config_init()` fills the runtime defaults: at most **three connections**, a **10-second** handshake deadline, a **30-second** receive-activity deadline and **five-second** discovery announcements. The connection limit can be set from 1 to 3. TCP/UDP listening ports can be zero to request ephemeral ports; port getters return the actual values. The discovery destination port must be nonzero. + +The compile-time `AGENTIC_KIT_LAN_MAX_FRAME_SIZE` defaults to **4096 bytes** and supports 128–65536. It limits the entire Frame; JSON or extension data must leave space for Frame overhead, the return code and any relevant inner header. `AGENTIC_KIT_LAN_LOG_LEVEL` follows the SDK's global log ceiling by default and can only lower it further. + +For a maximum Frame size of `F`, each accepted Session has an `F` receive buffer and a `2F` send queue. The service has two additional `F` buffers. With the default three connections all accepted, these buffers alone total **44 KiB**. Structures, JSON, cryptographic temporary allocations and sockets require additional memory; 44 KiB is not the total peak memory requirement. + +## 7. Integration and Lifecycle {#integration} + +1. Call `iot_init()` or `iot_init_default()` once at startup to bind the shared PAL/RNG/cJSON allocator. `tuya_lan_config_init()` only fills configuration defaults. +2. Obtain the activated device's data, current IPv4 address and product schema, and initialize the application's DP registry. +3. Set `pal`, device data and business callbacks, then call `tuya_lan_create()` to allocate the service. `config.pal` must reuse the same long-lived PAL initialized in step 1. SDK modules share the PAL/cJSON allocator; do not bind a separate allocator for LAN. No socket is listening yet. +4. Call `tuya_lan_start()` to open TCP/UDP and call `tuya_lan_process()` from the application loop. +5. Explicitly report through LAN and to the cloud according to product policy. Call `tuya_lan_stop()` to stop the service and `tuya_lan_destroy()` when it is no longer needed. + +A stopped service can be started again. Configuration is shallow-copied: the PAL, device strings and callback `user` data must remain valid while in use. Before unbinding, resetting, changing keys or IPv4, or releasing the object that owns the credentials, stop and destroy LAN, then recreate it with the new configuration. + +### Build Options {#build-options} + +LAN is disabled by default. Enable it with host CMake: + +```bash +cmake -S . -B build -DAGENTIC_KIT_ENABLE_LAN=ON -DPython3_EXECUTABLE=$(which python3) +cmake --build build -j 4 +``` + +For ESP-IDF, enable LAN in the **Agentic Kit** menu, corresponding to `CONFIG_AGENTIC_KIT_ENABLE_LAN=y`. The build exports `AGENTIC_KIT_ENABLE_LAN=0/1` to application code for conditional compilation. + +Disabled builds omit the LAN implementation, LAN tests and example. Optional server/UDP code inside the platform PALs is excluded and those callbacks are NULL. The general `iot_client_apply_local_dps()` entry point remains available. The `pal_t` field layout is independent of the option; all consumers must rebuild after new fields are added. Custom PALs need the optional group only for LAN. Runtime `start()` / `stop()` controls the listening state of a compiled service. + +See the [API Reference](../reference/tuya-lan.md#build) for complete configuration, callback examples and instructions for running the POSIX `lan_demo`. + +## 8. Capability Boundaries and Validation {#limits-validation} + +The current implementation provides LAN 3.5 Discovery, authentication and local DP services. Legacy LAN versions and AP provisioning are separate capabilities. + +`TUYA_LAN_CMD_P2P = 0x20` can reach the application as an authenticated extension command. It provides a signaling entry point. This module does not implement a P2P negotiation state machine, NAT traversal, media transport or an RTC bridge. Applications must connect other implementations for those features. AI session tokens are not used for LAN authentication. + +Existing validation has the following layers: + +- `test_protocol.c`: authentication, Session keys, Frame validation, sequence/replay handling, JSON and cryptographic failure paths. +- `test_service.c`: lifecycle, connection isolation, discovery scheduling, partial sends and backpressure. +- `test_peer.py`: an independent encrypted peer validates the protocol and shared DP registry through real loopback sockets. +- `pal/test/test_net.c`: the same loopback cases exercise POSIX and the actual FreeRTOS PAL source (with host kernel declaration shims), covering listening, UDP, shared connected/accepted TCP operations, zero/positive timeouts and resource cleanup. +- `iot_dp_test.c`: local DP batch atomicity, rollback on allocation failure, callback lifecycle, and UTC/empty-report regressions. + +```bash +ctest --test-dir build -R '^(tuya_lan_.*|pal_net_test|pal_freertos_net_test|iot_dp_test)$' --output-on-failure --no-tests=error +``` + +Keep tests serial. Automated cases require no cloud credentials. Interoperability with a real App, target hardware and wireless network behavior still require validation in the product environment. diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/biz-client.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/biz-client.md index 59b945d..eb9d851 100644 --- a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/biz-client.md +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/biz-client.md @@ -23,6 +23,13 @@ Initialize IoT first; free results before destroying Biz Client and finish all calls before IoT teardown. Instances keep separate contexts and label languages; the application owns serialized scheduling. +## Logging {#logging} + +`AGENTIC_KIT_BIZ_LOG_LEVEL` defaults to the SDK-wide `AGENTIC_KIT_LOG_LEVEL` +and can only lower biz-client's compile-time log ceiling. Set it to `0` to +disable this module's logs; higher values are clamped to the global ceiling. +See [compile-time configuration](../guides/compile-time-knobs.md) for overrides. + ## Role APIs {#role-api} All operations take a Biz Client first and optional diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/tuya-lan.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/tuya-lan.md new file mode 100644 index 0000000..9ddf06f --- /dev/null +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/tuya-lan.md @@ -0,0 +1,182 @@ +--- +title: Tuya LAN Local Control +sidebar_label: Tuya LAN +--- + +# Tuya LAN Local Control + +See [Tuya LAN Architecture and Features](../guides/tuya-lan-architecture.md) for +module relationships, feature boundaries and data flow. This page covers APIs, +configuration and integration. + +## Responsibilities {#overview} + +`modules/tuya-lan` (CMake target `tuya_lan`) provides Tuya LAN 3.5 local control +for activated devices: UDP discovery, TCP Sessions, three-step authentication, +heartbeats, DP queries, local DP controls and explicit status reports. Device +identity and `local_key` come from Activation; the application retains the +schema, DP state and product behavior. + +The application drives `tuya_lan_process()`; there is no worker. LAN and MQTT +connections are independent. With saved credentials and schema, LAN control +does not require an active MQTT connection. + +The wire reference is this [pinned TuyaOpen version](https://github.com/tuya/TuyaOpen/tree/68f76c966ef6154c319ce122506d4e0934b2979b/src/tuya_cloud_service/lan). +Only LAN 3.5 is implemented. Legacy LAN versions, AP provisioning and P2P media +are separate capabilities. + +## Build and Example {#build} + +Public headers are `tuya_lan.h` and the unified `pal.h` platform interface. +Networking lives in `pal_posix.c` (BSD sockets) and `pal_freertos.c` (lwIP). +Optional server/datagram callbacks are appended to `pal_t`; all consumers must +rebuild. Custom ports need to implement this group only when using LAN. + +LAN is disabled by default. Enable it explicitly with the host CMake option +`AGENTIC_KIT_ENABLE_LAN`: + +```bash +cmake -S . -B build -DAGENTIC_KIT_ENABLE_LAN=ON -DPython3_EXECUTABLE=$(which python3) +cmake --build build -j 4 +``` + +To disable it, reconfigure with `cmake -S . -B build -DAGENTIC_KIT_ENABLE_LAN=OFF`. +Disabled builds omit `tuya_lan`, LAN tests, `pal_net_test`, +`pal_freertos_net_test` and `lan_demo`. The optional server/UDP implementations +inside the platform PALs are excluded and their callbacks are NULL; the base PAL +remains available. `iot_client_apply_local_dps()` remains available to other +local control channels. + +The standalone example project also needs the option enabled: + +```bash +cmake -S examples/posix -B build-examples -DAGENTIC_KIT_ENABLE_LAN=ON -DPython3_EXECUTABLE=$(which python3) +cmake --build build-examples --target lan_demo -j 4 +./build-examples/lan_demo 192.168.1.20 +``` + +For ESP-IDF, enable LAN under **Agentic Kit** in `idf.py menuconfig`, or set +`CONFIG_AGENTIC_KIT_ENABLE_LAN=y` in the application's `sdkconfig.defaults`. +It defaults to disabled. The build publishes `AGENTIC_KIT_ENABLE_LAN=0` or `1` +to SDK consumers, so applications can guard integration code with +`#if AGENTIC_KIT_ENABLE_LAN`. The build option determines this macro's value. + +Set `TUYA_DEVICE_ID`, `TUYA_LOCAL_KEY`, `TUYA_UUID` and `TUYA_PRODUCT_KEY` in the +environment, using the same device's saved Activation data. The example does +not print keys. `local_key` must be a 16-byte string. + +The example exposes bool DP 1 using an offline DP registry without connecting +to the cloud. Products reuse their existing IoT client and actual schema. +Optional extra arguments select TCP/UDP ports; zero selects an ephemeral port. +Defaults are TCP 6668, UDP 7000 and discovery announcements to UDP 6667. + +## API and Configuration {#api} + +- `tuya_lan_config_init()` sets default ports, up to three Sessions, a 10-second + handshake deadline, a 30-second heartbeat deadline and five-second discovery + announcements. `discovery_interval_ms = 0` disables periodic announcements. +- `tuya_lan_create()` / `start()` / `stop()` / `destroy()` allocate, listen, + close connections and release resources. A stopped service can restart; + destroy and recreate it when credentials change. +- `tuya_lan_process()` performs bounded nonblocking I/O, discovery and timeout + work. Call it frequently from the application owner. +- `tuya_lan_report()` explicitly reports a complete `{"dps":{...}}` document to + authenticated Sessions. +- `tuya_lan_send()` replies to an extension command on a specified Session. +- `tuya_lan_disconnect_all()` closes Sessions; port getters return actual ports. + +`start()` / `stop()` control a compiled LAN service at runtime; the build option +above controls whether LAN is compiled. + +`on_dp` receives a bare DP object such as `{"1":true}`. Return zero after +accepting it; any other return produces an error reply. `on_query` writes a +complete `{"dps":{...}}` document into a bounded caller buffer and sets its length. +Lengths exclude a terminating NUL. Callback data is borrowed during the call. + +## Reusing IoT DP State {#integration} + +```c +static int on_lan_dp(const char *json, size_t len, void *user) +{ + return iot_client_apply_local_dps(user, json, len); +} + +static int on_lan_query(char *out, size_t cap, size_t *len, void *user) +{ + iot_client_t *client = user; + char *state = NULL; + if (iot_dp_dump_json(client, &state) != OPRT_OK) return LAN_ERR_MEMORY; + size_t n = strlen(state); + int rc = n <= cap ? LAN_OK : LAN_ERR_LIMIT; + if (rc == LAN_OK) { memcpy(out, state, n); *len = n; } + client->pal->free(state); + return rc; +} +``` + +`iot_client_apply_local_dps()` validates the complete nonempty batch and prepares +notification snapshots before committing. Errors leave values and dirty flags +unchanged. Success marks local writes dirty, invokes persistence first, then +the existing per-DP callback, all outside the lock. Bare DP IDs must be unique +decimal strings from 1 to 255 without leading zeros. + +LAN queries and reports neither clear MQTT dirty bits nor publish to the cloud. +The application invokes `iot_dp_report_all_dirty()` on its IoT owner thread and +still calls `iot_dp_report_all()` after every cloud reconnect. As before, +`iot_dp_dump_json()` omits raw DPs; build an explicit report for raw notifications. +Schema access mode is not enforced; apply product authorization before accepting +local controls. + +## Lifecycle and Porting {#lifecycle} + +Call `iot_init()` or `iot_init_default()` once at startup to bind the shared +PAL/RNG/cJSON allocator. Config strings must outlive the service. Stop LAN +before unbinding, reset, credential replacement or IoT client release; recreate +it with the new credentials and current IPv4 address. + +All LAN calls and callbacks share one application thread. Callbacks may send +or report. Defer destroy, stop, disconnect-all and recursive process calls until +callbacks return. When sharing the MQTT owner, pump LAN frequently enough; +`iot_client_process()` has a compile-time blocking budget. + +Ports provide optional listening, accept and datagram operations through the +same `pal_t`. `tuya_lan_create()` checks the full group with +`pal_has_server_network()`; base `pal_is_valid()` still checks only the mandatory +callbacks. Accepted TCP connections reuse `tcp_send/recv/poll/close`; LAN sends +and receives with `timeout_ms=0` so network I/O never waits. Configuration takes +only `config.pal`, reusing the PAL bound during SDK initialization. The module +retains partial writes and handles would-block with bounded per-Session queues +and pump work. Platform socket calls belong in the PAL. + +## Protocol Boundaries {#limits} + +LAN 3.5 uses AES-GCM Frames and HMAC nonce negotiation. DP, heartbeat and +extensions require authentication. Invalid tags, replay, invalid lengths and +timeouts close the affected Session. The public discovery key grants no control +authorization. Discovery requests must advertise the actual sender IP; replies +go to that source address and port. + +LAN and local DP input share bounded JSON string and number checks, rejecting +invalid UTF-8, malformed escapes, unpaired surrogates and decoded NUL before +cJSON validates the JSON structure. + +`on_command` receives authenticated extension data, including P2P signaling +command `0x20`. A separate module implements P2P negotiation and media. AI +session tokens are not LAN credentials. + +`AGENTIC_KIT_LAN_MAX_FRAME_SIZE` defaults to 4096 (range 128–65536). +`AGENTIC_KIT_LAN_LOG_LEVEL` follows and can only lower the global log ceiling. + +## Verification {#verification} + +```bash +cmake -S . -B build -DAGENTIC_KIT_ENABLE_LAN=ON -DPython3_EXECUTABLE=$(which python3) +cmake --build build -j 4 +ctest --test-dir build -R '^(tuya_lan_.*|pal_net_test|pal_freertos_net_test|iot_dp_test)$' --output-on-failure +``` + +The independent encrypted peer covers negotiation, replay, malformed Frames and +resource failures; service tests cover backpressure and lifecycle. A Python peer +uses ephemeral loopback ports through the actual socket service and IoT DP +registry without cloud credentials. Run the full ctest suite serially. Validate +the real App and wireless network on the target hardware. diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index 34ce5c3..de1a428 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -57,6 +57,7 @@ const sidebars: SidebarsConfig = { 'guides/device-mcp', 'guides/ota-upgrade', 'guides/atop-generic-call', + 'guides/tuya-lan-architecture', 'guides/ai-role-management', 'guides/tls-cert-verification', 'guides/porting-to-new-platform', @@ -73,6 +74,7 @@ const sidebars: SidebarsConfig = { 'reference/rtc-client', 'reference/iot-client', 'reference/biz-client', + 'reference/tuya-lan', ], }, 'get-authkey', diff --git a/examples/esp-idf/components/agentic_kit/CMakeLists.txt b/examples/esp-idf/components/agentic_kit/CMakeLists.txt index ec0bf8e..62d6fe6 100644 --- a/examples/esp-idf/components/agentic_kit/CMakeLists.txt +++ b/examples/esp-idf/components/agentic_kit/CMakeLists.txt @@ -40,6 +40,7 @@ set(AK_SRCS ${AK_DIR}/common/tls.c ${AK_DIR}/common/log.c ${AK_DIR}/common/rng.c + ${AK_DIR}/common/json_lexical.c # vendored coreHTTP ${AK_DIR}/third_party/coreHTTP/source/core_http_client.c @@ -53,6 +54,19 @@ set(AK_SRCS ${AK_DIR}/third_party/coreMQTT/source/core_mqtt_state.c ) +set(AK_LAN_ENABLED 0) +set(AK_LAN_INCLUDE_DIRS) +set(AK_LAN_PRIV_INCLUDE_DIRS) +if(CONFIG_AGENTIC_KIT_ENABLE_LAN) + set(AK_LAN_ENABLED 1) + list(APPEND AK_SRCS + ${AK_DIR}/modules/tuya-lan/src/tuya_lan.c + ${AK_DIR}/modules/tuya-lan/src/tuya_lan_protocol.c + ) + list(APPEND AK_LAN_INCLUDE_DIRS ${AK_DIR}/modules/tuya-lan/include) + list(APPEND AK_LAN_PRIV_INCLUDE_DIRS ${AK_DIR}/modules/tuya-lan/src) +endif() + idf_component_register( SRCS ${AK_SRCS} INCLUDE_DIRS @@ -60,11 +74,13 @@ idf_component_register( ${AK_DIR}/modules/rtc-tcp-client/include ${AK_DIR}/modules/iot-client/include ${AK_DIR}/modules/tuya-ble/include + ${AK_LAN_INCLUDE_DIRS} ${AK_DIR}/pal ${AK_DIR}/common PRIV_INCLUDE_DIRS ${AK_DIR}/modules/rtc-tcp-client/src ${AK_DIR}/modules/iot-client/src + ${AK_LAN_PRIV_INCLUDE_DIRS} ${AK_DIR}/third_party/coreHTTP/source/include ${AK_DIR}/third_party/coreHTTP/source/interface ${AK_DIR}/third_party/coreHTTP/source/dependency/3rdparty/llhttp/include @@ -74,6 +90,11 @@ idf_component_register( REQUIRES mbedtls json freertos lwip esp_event esp_netif esp_timer esp_wifi ) +# Public so application code can guard its optional LAN integration too. +target_compile_definitions(${COMPONENT_LIB} PUBLIC + AGENTIC_KIT_ENABLE_LAN=${AK_LAN_ENABLED} +) + # No MQTT_DO_NOT_USE_CUSTOM_CONFIG: coreMQTT must pick up common/core_mqtt_config.h # here too, or a refused CONNECT logs only a bare MQTTServerRefused on exactly the # target this diagnostic exists for. ${AK_DIR}/common is already in INCLUDE_DIRS diff --git a/examples/esp-idf/components/agentic_kit/Kconfig b/examples/esp-idf/components/agentic_kit/Kconfig new file mode 100644 index 0000000..cef60fe --- /dev/null +++ b/examples/esp-idf/components/agentic_kit/Kconfig @@ -0,0 +1,13 @@ +menu "Agentic Kit" + + config AGENTIC_KIT_ENABLE_LAN + bool "Enable Tuya LAN 3.5" + default n + help + Build the optional Tuya LAN 3.5 service and socket PAL extension + for local discovery and DP control. The application must initialize + and process the LAN service explicitly after the network is ready. + When disabled, LAN sources and include paths are excluded from + the Agentic Kit component. + +endmenu diff --git a/examples/posix/CMakeLists.txt b/examples/posix/CMakeLists.txt index 9ee7254..590c795 100644 --- a/examples/posix/CMakeLists.txt +++ b/examples/posix/CMakeLists.txt @@ -103,6 +103,12 @@ add_executable(dp_management_demo target_include_directories(dp_management_demo PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/dp-management") target_link_libraries(dp_management_demo PRIVATE tuya_iot_client) +# -- lan --------------------------------------------------------------------- +if(AGENTIC_KIT_ENABLE_LAN) + add_executable(lan_demo "${CMAKE_CURRENT_SOURCE_DIR}/lan/lan_demo.c") + target_link_libraries(lan_demo PRIVATE tuya_lan tuya_iot_client) +endif() + # -- ota-demo ---------------------------------------------------------------- add_executable(ota_demo "${CMAKE_CURRENT_SOURCE_DIR}/ota-demo/ota_demo.c" diff --git a/examples/posix/lan/lan_demo.c b/examples/posix/lan/lan_demo.c new file mode 100644 index 0000000..87e6744 --- /dev/null +++ b/examples/posix/lan/lan_demo.c @@ -0,0 +1,131 @@ +/* LAN-only example. Production applications reuse their existing IoT client. */ +#include "iot_client.h" +#include "iot_dp.h" +#include "tuya_lan.h" +#include "log.h" +#include +#include +#include +#include + +static volatile sig_atomic_t running = 1; +typedef struct { iot_client_t *client; bool report_pending; } app_t; + +static void stop_requested(int sig) { (void)sig; running = 0; } + +static int parse_port(const char *text, uint16_t *port) +{ + char *end = NULL; + if (!text || !*text || *text == '-' || *text == '+') return -1; + errno = 0; + unsigned long n = strtoul(text, &end, 10); + if (errno || *end || n > 65535) return -1; + *port = (uint16_t)n; + return 0; +} + +static int apply_dps(const char *json, size_t len, void *user) +{ + app_t *app = user; + int rc = iot_client_apply_local_dps(app->client, json, len); + if (rc == OPRT_OK) app->report_pending = true; + return rc; +} + +static int query_dps(char *json, size_t capacity, size_t *len, void *user) +{ + app_t *app = user; + char *state = NULL; + if (iot_dp_dump_json(app->client, &state) != OPRT_OK) return LAN_ERR_MEMORY; + size_t n = strlen(state); + int rc = n <= capacity ? LAN_OK : LAN_ERR_LIMIT; + if (rc == LAN_OK) { memcpy(json, state, n); *len = n; } + app->client->pal->free(state); + return rc; +} + +static void drive_device(uint8_t id, const iot_dp_value_t *value, void *user) +{ + (void)user; + if (id == 1 && value->type == IOT_DP_TYPE_BOOL) + log_tag_info("lan-demo", "switch DP %u = %d", id, value->value.boolean); +} + +int main(int argc, char **argv) +{ + const char *device_id = getenv("TUYA_DEVICE_ID"); + const char *local_key = getenv("TUYA_LOCAL_KEY"); + const char *uuid = getenv("TUYA_UUID"); + const char *product_key = getenv("TUYA_PRODUCT_KEY"); + if (argc < 2 || argc > 4 || !device_id || !local_key || !uuid || !product_key) { + log_tag_error("lan-demo", "Usage: lan_demo [tcp-port [udp-port]]; set TUYA_DEVICE_ID, " + "TUYA_LOCAL_KEY, TUYA_UUID, TUYA_PRODUCT_KEY from saved activation credentials"); + return 2; + } + if (iot_init_default() != OPRT_OK) return 1; + /* Empty cloud credentials avoid DNS and MQTT: this client owns only the + * example's DP registry. A product passes its already activated client. */ + iot_client_config_t iot_config = { + .schema = "[{\"id\":1,\"type\":\"obj\",\"mode\":\"rw\"," + "\"property\":{\"type\":\"bool\"}}]", + .mqtt_disable_auto_connect = true, + .skip_version_report = true + }; + app_t app = { .client = iot_client_init(&iot_config) }; + if (!app.client) return 1; + iot_dp_value_t initial = { .type = IOT_DP_TYPE_BOOL, .value.boolean = false }; + if (iot_dp_set(app.client, 1, &initial) != OPRT_OK) { + iot_client_deinit(app.client); + return 1; + } + iot_dp_set_callback(app.client, drive_device, &app); + tuya_lan_config_t config; + tuya_lan_config_init(&config); + if ((argc > 2 && parse_port(argv[2], &config.tcp_port)) || + (argc > 3 && parse_port(argv[3], &config.udp_port))) { + iot_client_deinit(app.client); + return 2; + } + config.pal = app.client->pal; + config.device_id = device_id; + config.local_key = local_key; + config.uuid = uuid; + config.product_key = product_key; + config.ipv4 = argv[1]; + config.bind_ip = argv[1]; + if (strcmp(argv[1], "127.0.0.1") == 0) config.broadcast_ip = argv[1]; + config.on_dp = apply_dps; + config.on_query = query_dps; + config.user = &app; + tuya_lan_t *lan = NULL; + int rc = tuya_lan_create(&config, &lan); + if (rc == LAN_OK) rc = tuya_lan_start(lan); + if (rc != LAN_OK) { + log_tag_error("lan-demo", "LAN start failed: %d", rc); + tuya_lan_destroy(lan); + iot_client_deinit(app.client); + return 1; + } + signal(SIGINT, stop_requested); + signal(SIGTERM, stop_requested); + log_tag_info("lan-demo", "LAN listening: TCP %u, UDP %u", + tuya_lan_tcp_port(lan), tuya_lan_udp_port(lan)); + while (running) { + rc = tuya_lan_process(lan); + if (rc != LAN_OK) break; + /* Application policy: explicitly notify LAN peers after local controls. + * This does not publish MQTT or clear the cloud's DP dirty bits. */ + if (app.report_pending) { + char *state = NULL; + if (iot_dp_dump_json(app.client, &state) == OPRT_OK) { + int report_rc = tuya_lan_report(lan, state, strlen(state)); + if (report_rc == LAN_OK) app.report_pending = false; + app.client->pal->free(state); + } + } + app.client->pal->sleep_ms(10); + } + tuya_lan_destroy(lan); + iot_client_deinit(app.client); + return rc == LAN_OK ? 0 : 1; +} diff --git a/modules/biz-client/CONTEXT.md b/modules/biz-client/CONTEXT.md index 8c7e757..4d4baac 100644 --- a/modules/biz-client/CONTEXT.md +++ b/modules/biz-client/CONTEXT.md @@ -34,6 +34,8 @@ storage, background tasks, MQTT loop, or RTC Session ownership. An accepted envelope is not enough: each result must match the operation's shape. Empty lists are valid; absent/null results and false acknowledgements are invalid. IDs and strings are never silently truncated. +- Logs use `BIZ_LOG*` through the shared facade. `AGENTIC_KIT_BIZ_LOG_LEVEL` + defaults to the SDK-wide ceiling and can only lower the business-service level. - No RTC TCP Client dependency. The app verifies when a new role affects its next Event and decides if a new Session is required. diff --git a/modules/biz-client/docs/adr/0001-business-services-use-public-atop.md b/modules/biz-client/docs/adr/0001-business-services-use-public-atop.md index 2847903..01b2fd8 100644 --- a/modules/biz-client/docs/adr/0001-business-services-use-public-atop.md +++ b/modules/biz-client/docs/adr/0001-business-services-use-public-atop.md @@ -17,6 +17,10 @@ cloud rejection details, validate results, and reject truncation. Use PAL allocation and the common log facade so POSIX and FreeRTOS share implementation. Add sources to both root CMake and the ESP-IDF component. +Typed list wrappers share one private decoder for result envelopes, allocation, +iteration and failure cleanup. Type-specific callbacks validate role, language +and timbre fields; no generic list interface is exposed to applications. + NVS caching, workers, queue ownership, avatar HTTP fetching/decoding, LVGL and TAI reconnection remain application concerns. Copying the study-machine role component unchanged would import ESP-IDF/PSRAM/NVS and a global client into a diff --git a/modules/biz-client/include/biz_client_config_defaults.h b/modules/biz-client/include/biz_client_config_defaults.h new file mode 100644 index 0000000..4c13d4c --- /dev/null +++ b/modules/biz-client/include/biz_client_config_defaults.h @@ -0,0 +1,17 @@ +#ifndef BIZ_CLIENT_CONFIG_DEFAULTS_H +#define BIZ_CLIENT_CONFIG_DEFAULTS_H + +/* Load integrator overrides before applying module defaults. */ +#include "log.h" + +#ifndef AGENTIC_KIT_BIZ_LOG_LEVEL +#define AGENTIC_KIT_BIZ_LOG_LEVEL AGENTIC_KIT_LOG_LEVEL +#endif + +/* A module can lower the SDK-wide ceiling, never raise it. */ +#if AGENTIC_KIT_BIZ_LOG_LEVEL > AGENTIC_KIT_LOG_LEVEL +#undef AGENTIC_KIT_BIZ_LOG_LEVEL +#define AGENTIC_KIT_BIZ_LOG_LEVEL AGENTIC_KIT_LOG_LEVEL +#endif + +#endif diff --git a/modules/biz-client/src/ai_role.c b/modules/biz-client/src/ai_role.c index 1af2450..f2c0494 100644 --- a/modules/biz-client/src/ai_role.c +++ b/modules/biz-client/src/ai_role.c @@ -1,5 +1,6 @@ #include "tuya_ai_role.h" #include "service_internal.h" +#include "bounded_string.h" #include #include #include @@ -31,6 +32,47 @@ static int total_count(const cJSON *root, int *out) return OPRT_OK; } +typedef int (*list_decoder_t)(const cJSON *, void *, const void *); + +typedef struct { + void *items; + size_t count; + int total_count; +} decoded_list_t; + +/* Publish only a complete list. The decoder never owns the staging allocation. */ +static int query_list(tuya_biz_client_t *ctx, const char *api, cJSON *body, + size_t item_size, list_decoder_t decode, const void *arg, + bool read_total_count, decoded_list_t *out, iot_atop_rejection_t *err) +{ + cJSON *root = NULL; + int rc = biz_call(ctx, api, body, &root, err); + if (rc) return rc; + + decoded_list_t list = { .total_count = -1 }; + const cJSON *array = list_array(root); + size_t n = array ? (size_t)cJSON_GetArraySize(array) : 0; + if (!array || (read_total_count && total_count(root, &list.total_count))) { + rc = OPRT_INVALID_RESULT; + } else if (n) { + if (n > SIZE_MAX / item_size || !(list.items = ctx->pal->malloc(n * item_size))) { + rc = OPRT_MALLOC_FAILED; + } else { + memset(list.items, 0, n * item_size); + const cJSON *entry = NULL; + cJSON_ArrayForEach(entry, array) { + rc = decode(entry, (unsigned char *)list.items + list.count * item_size, arg); + if (rc) break; + ++list.count; + } + } + } + cJSON_Delete(root); + if (rc) ctx->pal->free(list.items); + else *out = list; + return rc; +} + static int parse_role(const cJSON *obj, tuya_ai_role_t *out, int source_type) { if (!cJSON_IsObject(obj)) return OPRT_INVALID_RESULT; @@ -65,6 +107,11 @@ static int parse_role(const cJSON *obj, tuya_ai_role_t *out, int source_type) return OPRT_OK; } +static int decode_role(const cJSON *obj, void *out, const void *source_type) +{ + return parse_role(obj, out, *(const int *)source_type); +} + void tuya_ai_role_list_free(tuya_biz_client_t *ctx, tuya_ai_role_list_t *out) { if (ctx && out) { ctx->pal->free(out->items); memset(out, 0, sizeof(*out)); } @@ -107,27 +154,13 @@ static cJSON *paged_body(tuya_biz_client_t *ctx, int page, int size) static int roles_query(tuya_biz_client_t *ctx, const char *api, cJSON *body, int type, tuya_ai_role_list_t *out, iot_atop_rejection_t *err) { - cJSON *root = NULL; - int rc = biz_call(ctx, api, body, &root, err); - if (rc) return rc; - const cJSON *array = list_array(root); - size_t n = array ? (size_t)cJSON_GetArraySize(array) : 0; - if (!array || total_count(root, &out->total_count)) rc = OPRT_INVALID_RESULT; - else if (n) { - if (n > SIZE_MAX / sizeof(*out->items)) rc = OPRT_MALLOC_FAILED; - else out->items = ctx->pal->malloc(n * sizeof(*out->items)); - if (!rc && !out->items) rc = OPRT_MALLOC_FAILED; - if (!rc) { - const cJSON *entry = NULL; - cJSON_ArrayForEach(entry, array) { - rc = parse_role(entry, &out->items[out->count], type); - if (rc) break; - ++out->count; - } - } + decoded_list_t list; + int rc = query_list(ctx, api, body, sizeof(*out->items), decode_role, &type, true, &list, err); + if (!rc) { + out->items = list.items; + out->count = list.count; + out->total_count = list.total_count; } - cJSON_Delete(root); - if (rc) tuya_ai_role_list_free(ctx, out); return rc; } @@ -163,7 +196,7 @@ int tuya_ai_role_bind(tuya_biz_client_t *ctx, tuya_ai_role_type_t type, { int rc = biz_prepare(ctx, err); if (rc || (type != TUYA_AI_ROLE_CUSTOM && type != TUYA_AI_ROLE_TEMPLATE) || - !biz_valid_string(id, TUYA_AI_ROLE_ID_LEN)) return OPRT_INVALID_PARAMETER; + !bounded_string_nonempty(id, TUYA_AI_ROLE_ID_LEN)) return OPRT_INVALID_PARAMETER; cJSON *body = cJSON_CreateObject(); if (!body || !cJSON_AddStringToObject(body, "roleId", id) || !cJSON_AddNumberToObject(body, "bindRoleType", type)) { @@ -175,7 +208,7 @@ int tuya_ai_role_bind(tuya_biz_client_t *ctx, tuya_ai_role_type_t type, int tuya_ai_role_delete_custom(tuya_biz_client_t *ctx, const char *id, iot_atop_rejection_t *err) { int rc = biz_prepare(ctx, err); - if (rc || !biz_valid_string(id, TUYA_AI_ROLE_ID_LEN)) return OPRT_INVALID_PARAMETER; + if (rc || !bounded_string_nonempty(id, TUYA_AI_ROLE_ID_LEN)) return OPRT_INVALID_PARAMETER; cJSON *body = cJSON_CreateObject(); if (!body || !cJSON_AddStringToObject(body, "roleId", id)) { cJSON_Delete(body); return OPRT_MALLOC_FAILED; @@ -186,9 +219,9 @@ int tuya_ai_role_delete_custom(tuya_biz_client_t *ctx, const char *id, iot_atop_ static bool valid_edit(const tuya_ai_role_edit_t *edit) { return edit && (edit->name || edit->lang_code || edit->timbre_id || edit->set_speed) && - (!edit->name || biz_valid_string(edit->name, TUYA_AI_ROLE_NAME_LEN)) && - (!edit->lang_code || biz_valid_string(edit->lang_code, TUYA_AI_ROLE_LANG_LEN)) && - (!edit->timbre_id || biz_valid_string(edit->timbre_id, TUYA_AI_ROLE_ID_LEN)) && + (!edit->name || bounded_string_nonempty(edit->name, TUYA_AI_ROLE_NAME_LEN)) && + (!edit->lang_code || bounded_string_nonempty(edit->lang_code, TUYA_AI_ROLE_LANG_LEN)) && + (!edit->timbre_id || bounded_string_nonempty(edit->timbre_id, TUYA_AI_ROLE_ID_LEN)) && (!edit->set_speed || (isfinite(edit->speed) && edit->speed > 0)); } @@ -217,7 +250,7 @@ int tuya_ai_role_apply_edit(tuya_biz_client_t *ctx, const tuya_ai_role_t *cur, { int rc = biz_prepare(ctx, err); if (out_id) out_id[0] = '\0'; - if (rc || !out_id || !cur || !biz_valid_string(cur->role_id, sizeof(cur->role_id)) || + if (rc || !out_id || !cur || !bounded_string_nonempty(cur->role_id, sizeof(cur->role_id)) || (cur->type != TUYA_AI_ROLE_TEMPLATE && cur->type != TUYA_AI_ROLE_CUSTOM) || !valid_edit(edit)) return OPRT_INVALID_PARAMETER; bool create = cur->type == TUYA_AI_ROLE_TEMPLATE; @@ -226,7 +259,7 @@ int tuya_ai_role_apply_edit(tuya_biz_client_t *ctx, const tuya_ai_role_t *cur, edit_body(cur->role_id, edit, create), &root, err); if (!rc) { if (create) { - if (!cJSON_IsString(root) || !biz_valid_string(root->valuestring, TUYA_AI_ROLE_ID_LEN)) + if (!cJSON_IsString(root) || !bounded_string_nonempty(root->valuestring, TUYA_AI_ROLE_ID_LEN)) rc = OPRT_INVALID_RESULT; else strcpy(out_id, root->valuestring); } else strcpy(out_id, cur->role_id); @@ -235,47 +268,40 @@ int tuya_ai_role_apply_edit(tuya_biz_client_t *ctx, const tuya_ai_role_t *cur, return rc; } +static int decode_language(const cJSON *entry, void *out, const void *arg) +{ + (void)arg; + tuya_ai_language_t *item = out; + if (!cJSON_IsObject(entry) || + biz_copy_string(entry, "langCode", item->code, sizeof(item->code), true) || + biz_copy_string(entry, "langName", item->name, sizeof(item->name), false)) + return OPRT_INVALID_RESULT; + const cJSON *def = cJSON_GetObjectItemCaseSensitive(entry, "hasDefault"); + if (def && !cJSON_IsBool(def)) return OPRT_INVALID_RESULT; + item->has_default = cJSON_IsTrue(def); + return OPRT_OK; +} + int tuya_ai_role_list_languages(tuya_biz_client_t *ctx, tuya_ai_language_list_t *out, iot_atop_rejection_t *err) { int rc = biz_prepare(ctx, err); if (out) memset(out, 0, sizeof(*out)); if (rc || !out) return OPRT_INVALID_PARAMETER; - cJSON *root = NULL; - rc = biz_call(ctx, "thing.ai.agent.config.languages.list", biz_query_body(ctx), &root, err); - if (rc) return rc; - const cJSON *array = list_array(root); - size_t n = array ? (size_t)cJSON_GetArraySize(array) : 0; - if (!array) rc = OPRT_INVALID_RESULT; - else if (n) { - if (n > SIZE_MAX / sizeof(*out->items)) rc = OPRT_MALLOC_FAILED; - else out->items = ctx->pal->malloc(n * sizeof(*out->items)); - if (!rc && !out->items) rc = OPRT_MALLOC_FAILED; - if (!rc) { - const cJSON *entry = NULL; - cJSON_ArrayForEach(entry, array) { - tuya_ai_language_t *item = &out->items[out->count]; - memset(item, 0, sizeof(*item)); - if (!cJSON_IsObject(entry) || - biz_copy_string(entry, "langCode", item->code, sizeof(item->code), true) || - biz_copy_string(entry, "langName", item->name, sizeof(item->name), false)) { - rc = OPRT_INVALID_RESULT; break; - } - const cJSON *def = cJSON_GetObjectItemCaseSensitive(entry, "hasDefault"); - if (def && !cJSON_IsBool(def)) { rc = OPRT_INVALID_RESULT; break; } - item->has_default = cJSON_IsTrue(def); - ++out->count; - } - } + decoded_list_t list; + rc = query_list(ctx, "thing.ai.agent.config.languages.list", biz_query_body(ctx), + sizeof(*out->items), decode_language, NULL, false, &list, err); + if (!rc) { + out->items = list.items; + out->count = list.count; } - cJSON_Delete(root); - if (rc) tuya_ai_language_list_free(ctx, out); return rc; } -static int parse_timbre(const cJSON *obj, tuya_ai_timbre_t *out) +static int decode_timbre(const cJSON *obj, void *item, const void *arg) { - memset(out, 0, sizeof(*out)); + (void)arg; + tuya_ai_timbre_t *out = item; if (!cJSON_IsObject(obj) || biz_copy_string(obj, "voiceId", out->id, sizeof(out->id), true) || biz_copy_string(obj, "voiceName", out->name, sizeof(out->name), false)) return OPRT_INVALID_RESULT; const cJSON *langs = cJSON_GetObjectItemCaseSensitive(obj, "supportLangs"); @@ -301,31 +327,18 @@ int tuya_ai_role_list_timbres(tuya_biz_client_t *ctx, const char *lang, int page int rc = biz_prepare(ctx, err); if (out) memset(out, 0, sizeof(*out)); if (rc || !out || page < 1 || size < 1 || - (lang && !biz_valid_string(lang, TUYA_AI_ROLE_LANG_LEN))) return OPRT_INVALID_PARAMETER; + (lang && !bounded_string_nonempty(lang, TUYA_AI_ROLE_LANG_LEN))) return OPRT_INVALID_PARAMETER; cJSON *body = paged_body(ctx, page, size); if (body && lang && !cJSON_AddStringToObject(body, "lang", lang)) { cJSON_Delete(body); return OPRT_MALLOC_FAILED; } - cJSON *root = NULL; - rc = biz_call(ctx, "thing.ai.timbre.market.page", body, &root, err); - if (rc) return rc; - const cJSON *array = list_array(root); - size_t n = array ? (size_t)cJSON_GetArraySize(array) : 0; - if (!array || total_count(root, &out->total_count)) rc = OPRT_INVALID_RESULT; - else if (n) { - if (n > SIZE_MAX / sizeof(*out->items)) rc = OPRT_MALLOC_FAILED; - else out->items = ctx->pal->malloc(n * sizeof(*out->items)); - if (!rc && !out->items) rc = OPRT_MALLOC_FAILED; - if (!rc) { - const cJSON *entry = NULL; - cJSON_ArrayForEach(entry, array) { - rc = parse_timbre(entry, &out->items[out->count]); - if (rc) break; - ++out->count; - } - } + decoded_list_t list; + rc = query_list(ctx, "thing.ai.timbre.market.page", body, sizeof(*out->items), + decode_timbre, NULL, true, &list, err); + if (!rc) { + out->items = list.items; + out->count = list.count; + out->total_count = list.total_count; } - cJSON_Delete(root); - if (rc) tuya_ai_timbre_list_free(ctx, out); return rc; } diff --git a/modules/biz-client/src/biz_client.c b/modules/biz-client/src/biz_client.c index b18c6b7..0210f87 100644 --- a/modules/biz-client/src/biz_client.c +++ b/modules/biz-client/src/biz_client.c @@ -1,21 +1,13 @@ #include "service_internal.h" -#include "log.h" +#include "bounded_string.h" #include -bool biz_valid_string(const char *text, size_t capacity) -{ - if (!text || !text[0]) return false; - /* Bounded even for an unterminated public fixed-size field. */ - for (size_t i = 1; i < capacity; ++i) if (!text[i]) return true; - return false; -} - int tuya_biz_client_create(iot_client_t *iot, const char *lang, tuya_biz_client_t **out) { if (!out) return OPRT_INVALID_PARAMETER; *out = NULL; if (!iot || !iot->pal || !iot->pal->malloc || !iot->pal->free || - (lang && !biz_valid_string(lang, 32))) return OPRT_INVALID_PARAMETER; + (lang && !bounded_string_nonempty(lang, 32))) return OPRT_INVALID_PARAMETER; tuya_biz_client_t *ctx = iot->pal->malloc(sizeof(*ctx)); if (!ctx) return OPRT_MALLOC_FAILED; memset(ctx, 0, sizeof(*ctx)); @@ -56,7 +48,7 @@ int biz_call(tuya_biz_client_t *ctx, const char *api, cJSON *body, * time, which ports may not have synchronized. Fail closed below Nov 2023. */ uint64_t utc_ms = ctx->pal->wal_clock_time(); if (utc_ms < 1700000000000ULL) { - log_tag_warn("biz", "PAL reports UTC unavailable; refusing signed role request"); + BIZ_LOGW("PAL reports UTC unavailable; refusing signed role request"); cJSON_Delete(body); return OPRT_UNINITIALIZED; } @@ -86,7 +78,7 @@ int biz_call(tuya_biz_client_t *ctx, const char *api, cJSON *body, } } iot_atop_response_free(ctx->iot, &response); - if (rc != OPRT_OK) log_tag_warn("biz", "%s failed: %d", api, rc); + if (rc != OPRT_OK) BIZ_LOGW("%s failed: %d", api, rc); return rc; } diff --git a/modules/biz-client/src/service_internal.h b/modules/biz-client/src/service_internal.h index d09eea5..2a896ce 100644 --- a/modules/biz-client/src/service_internal.h +++ b/modules/biz-client/src/service_internal.h @@ -1,9 +1,31 @@ #ifndef BIZ_SERVICE_INTERNAL_H #define BIZ_SERVICE_INTERNAL_H +#include "biz_client_config_defaults.h" #include "tuya_biz_client.h" #include "iot_atop.h" #include "cJSON.h" +#if AGENTIC_KIT_BIZ_LOG_LEVEL >= LOG_ERROR +#define BIZ_LOGE(...) log_tag_error("biz", __VA_ARGS__) +#else +#define BIZ_LOGE(...) ((void)0) +#endif +#if AGENTIC_KIT_BIZ_LOG_LEVEL >= LOG_WARN +#define BIZ_LOGW(...) log_tag_warn("biz", __VA_ARGS__) +#else +#define BIZ_LOGW(...) ((void)0) +#endif +#if AGENTIC_KIT_BIZ_LOG_LEVEL >= LOG_INFO +#define BIZ_LOGI(...) log_tag_info("biz", __VA_ARGS__) +#else +#define BIZ_LOGI(...) ((void)0) +#endif +#if AGENTIC_KIT_BIZ_LOG_LEVEL >= LOG_DEBUG +#define BIZ_LOGD(...) log_tag_debug("biz", __VA_ARGS__) +#else +#define BIZ_LOGD(...) ((void)0) +#endif + struct tuya_biz_client { iot_client_t *iot; const pal_t *pal; @@ -16,5 +38,4 @@ cJSON *biz_query_body(const tuya_biz_client_t *); int biz_call(tuya_biz_client_t *, const char *api, cJSON *body, cJSON **result, iot_atop_rejection_t *); int biz_copy_string(const cJSON *, const char *, char *, size_t, bool required); -bool biz_valid_string(const char *, size_t capacity); #endif diff --git a/modules/biz-client/test/ai_role_test.c b/modules/biz-client/test/ai_role_test.c index da77875..79ad122 100644 --- a/modules/biz-client/test/ai_role_test.c +++ b/modules/biz-client/test/ai_role_test.c @@ -10,6 +10,8 @@ #include static int calls, frees, live, fail_after = -1; +static size_t fail_size; +static int size_failures; static int response_rc; static const char *response_json; static char api[128]; @@ -21,6 +23,7 @@ static tuya_biz_client_t *biz; static void *allocate(size_t n) { + if (fail_size && n == fail_size) { ++size_failures; return NULL; } if (fail_after == 0) return NULL; if (fail_after > 0) --fail_after; void *p = malloc(n); @@ -275,6 +278,144 @@ static int allocation_failures_release_everything(void) return 0; } +static int partial_lists_are_discarded(void) +{ + cJSON_Delete(request_body); request_body = NULL; + int baseline = live; + response("{\"items\":[{\"roleId\":\"r1\"},{\"roleId\":\"r2\",\"speed\":0}],\"totalCount\":10}"); + tuya_ai_role_list_t roles = {0}; + CHECK(tuya_ai_role_list_templates(biz, &roles, NULL) == OPRT_INVALID_RESULT); + CHECK(!roles.items && !roles.count && roles.total_count == 0); + cJSON_Delete(request_body); request_body = NULL; + CHECK(live == baseline); + + response("[{\"langCode\":\"en\"},{\"langCode\":\"zh\",\"hasDefault\":\"true\"}]"); + tuya_ai_language_list_t languages = {0}; + CHECK(tuya_ai_role_list_languages(biz, &languages, NULL) == OPRT_INVALID_RESULT); + CHECK(!languages.items && !languages.count); + cJSON_Delete(request_body); request_body = NULL; + CHECK(live == baseline); + + response("{\"records\":[{\"voiceId\":\"v1\"},{\"voiceId\":\"v2\",\"supportLangs\":[false]}],\"totalCount\":10}"); + tuya_ai_timbre_list_t timbres = {0}; + CHECK(tuya_ai_role_list_timbres(biz, NULL, 1, 2, &timbres, NULL) == OPRT_INVALID_RESULT); + CHECK(!timbres.items && !timbres.count && timbres.total_count == 0); + cJSON_Delete(request_body); request_body = NULL; + CHECK(live == baseline); + return 0; +} + +static int list_allocation_failures(void) +{ + cJSON_Delete(request_body); request_body = NULL; + int baseline = live; + response("[{\"roleId\":\"r1\"},{\"roleId\":\"r2\"}]"); + tuya_ai_role_list_t roles = {0}; + size_failures = 0; fail_size = 2 * sizeof(*roles.items); + int rc = tuya_ai_role_list_templates(biz, &roles, NULL); + fail_size = 0; + CHECK(size_failures == 1 && rc == OPRT_MALLOC_FAILED); + CHECK(!roles.items && !roles.count && roles.total_count == 0); + cJSON_Delete(request_body); request_body = NULL; + CHECK(live == baseline); + + response("[{\"langCode\":\"en\"},{\"langCode\":\"zh\"}]"); + tuya_ai_language_list_t languages = {0}; + size_failures = 0; fail_size = 2 * sizeof(*languages.items); + rc = tuya_ai_role_list_languages(biz, &languages, NULL); + fail_size = 0; + CHECK(size_failures == 1 && rc == OPRT_MALLOC_FAILED); + CHECK(!languages.items && !languages.count); + cJSON_Delete(request_body); request_body = NULL; + CHECK(live == baseline); + + response("[{\"voiceId\":\"v1\"},{\"voiceId\":\"v2\"}]"); + tuya_ai_timbre_list_t timbres = {0}; + size_failures = 0; fail_size = 2 * sizeof(*timbres.items); + rc = tuya_ai_role_list_timbres(biz, NULL, 1, 2, &timbres, NULL); + fail_size = 0; + CHECK(size_failures == 1 && rc == OPRT_MALLOC_FAILED); + CHECK(!timbres.items && !timbres.count && timbres.total_count == 0); + cJSON_Delete(request_body); request_body = NULL; + CHECK(live == baseline); + return 0; +} + +static int all_list_envelopes(void) +{ + const char *keys[] = {NULL, "datas", "list", "records", "rows", "content", "data", "items"}; + for (size_t i = 0; i < sizeof(keys)/sizeof(keys[0]); ++i) { + char json[192]; + if (keys[i]) snprintf(json, sizeof(json), + "{\"%s\":[{\"langCode\":\"en\"},{\"langCode\":\"zh\"}],\"totalCount\":\"ignored\"}", keys[i]); + else strcpy(json, "[{\"langCode\":\"en\"},{\"langCode\":\"zh\"}]"); + response(json); + tuya_ai_language_list_t languages = {0}; + CHECK(tuya_ai_role_list_languages(biz, &languages, NULL) == OPRT_OK); + CHECK(languages.count == 2 && !strcmp(languages.items[1].code, "zh")); + CHECK(!languages.items[0].name[0] && !languages.items[0].has_default); + tuya_ai_language_list_free(biz, &languages); + + if (keys[i]) snprintf(json, sizeof(json), + "{\"%s\":[{\"voiceId\":\"v1\"},{\"voiceId\":\"v2\"}],\"totalCount\":7}", keys[i]); + else strcpy(json, "[{\"voiceId\":\"v1\"},{\"voiceId\":\"v2\"}]"); + response(json); + tuya_ai_timbre_list_t timbres = {0}; + CHECK(tuya_ai_role_list_timbres(biz, NULL, 1, 2, &timbres, NULL) == OPRT_OK); + CHECK(timbres.count == 2 && !strcmp(timbres.items[1].id, "v2")); + CHECK(!timbres.items[0].name[0] && !timbres.items[0].support_langs[0]); + CHECK(timbres.total_count == (keys[i] ? 7 : -1)); + tuya_ai_timbre_list_free(biz, &timbres); + } + response("[]"); + tuya_ai_language_list_t languages = {0}; + CHECK(tuya_ai_role_list_languages(biz, &languages, NULL) == OPRT_OK); + CHECK(!languages.items && !languages.count); + response("{\"items\":[],\"totalCount\":0}"); + tuya_ai_timbre_list_t timbres = {0}; + CHECK(tuya_ai_role_list_timbres(biz, NULL, 1, 2, &timbres, NULL) == OPRT_OK); + CHECK(!timbres.items && !timbres.count && timbres.total_count == 0); + return 0; +} + +static int request_language_capacity(void) +{ + char lang[33]; memset(lang, 'a', sizeof(lang)); lang[31] = '\0'; + tuya_biz_client_t *ctx = NULL; + CHECK(tuya_biz_client_create(&iot, lang, &ctx) == OPRT_OK); + response("[]"); + tuya_ai_role_list_t roles = {0}; + CHECK(tuya_ai_role_list_templates(ctx, &roles, NULL) == OPRT_OK); + CHECK(strlen(str("requestLang")) == 31 && !strcmp(str("requestLang"), lang)); + tuya_ai_role_list_free(ctx, &roles); + tuya_biz_client_destroy(ctx); + lang[31] = 'a'; lang[32] = '\0'; + ctx = (void *)1; + int before = calls; + CHECK(tuya_biz_client_create(&iot, lang, &ctx) == OPRT_INVALID_PARAMETER && !ctx); + CHECK(calls == before); + return 0; +} + +static int fixed_role_id_capacity(void) +{ + tuya_ai_role_t role = { .type = TUYA_AI_ROLE_CUSTOM }; + memset(role.role_id, 'r', sizeof(role.role_id) - 1); + tuya_ai_role_edit_t edit = { .name = "Updated" }; + char id[TUYA_AI_ROLE_ID_LEN]; + response("true"); + CHECK(tuya_ai_role_apply_edit(biz, &role, &edit, id, NULL) == OPRT_OK); + CHECK(strlen(id) == sizeof(role.role_id) - 1 && !strcmp(str("roleId"), role.role_id)); + + /* The public field is genuinely unterminated; adjacent bytes are nonzero. */ + memset(role.role_id, 'r', sizeof(role.role_id)); + memset(role.template_id, 't', sizeof(role.template_id)); + int before = calls; + CHECK(tuya_ai_role_apply_edit(biz, &role, &edit, id, NULL) == OPRT_INVALID_PARAMETER); + CHECK(!id[0] && calls == before); + return 0; +} + int main(void) { cJSON_Hooks hooks = {allocate, release}; cJSON_InitHooks(&hooks); @@ -284,7 +425,9 @@ int main(void) int (*tests[])(void) = { lifecycle, bound_and_templates, list_shapes_and_pagination, bind_and_delete, template_edit_creates_and_binds, custom_edit_preserves_omitted_fields, languages_and_timbres, malformed_results_and_rejections, invalid_arguments_do_not_call_cloud, - failed_creation_and_overlong_fields, allocation_failures_release_everything }; + failed_creation_and_overlong_fields, allocation_failures_release_everything, + partial_lists_are_discarded, list_allocation_failures, all_list_envelopes, + request_language_capacity, fixed_role_id_capacity }; int failed = 0; for (size_t i = 0; i < sizeof(tests)/sizeof(tests[0]); ++i) failed += tests[i](); cJSON_Delete(request_body); tuya_biz_client_destroy(biz); diff --git a/modules/biz-client/test/test_log_compile.py b/modules/biz-client/test/test_log_compile.py new file mode 100644 index 0000000..5eb47f0 --- /dev/null +++ b/modules/biz-client/test/test_log_compile.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Compile/run the Business Client's log ceilings and override pickup.""" + +import argparse +from pathlib import Path +import subprocess +import tempfile + + +FIXTURE = r""" +#include +#include + +void test_log_sink(int level, const char *tag, const char *fmt, ...); +#include "service_internal.h" + +#if AGENTIC_KIT_BIZ_LOG_LEVEL != TEST_EXPECT_LEVEL +#error "Business Client log ceiling was not clamped or picked up correctly" +#endif + +static unsigned seen[5]; +static unsigned evaluated; +static int failed; + +static void record(int level, const char *fmt, const char *expected, va_list args) +{ + if (level < LOG_ERROR || level > LOG_DEBUG || strcmp(fmt, expected) || + va_arg(args, unsigned) != (unsigned)level) { + failed = 1; + return; + } + ++seen[level]; +} + +/* Defining both sinks keeps every matrix build independent of common/log.c. */ +void log_emit(int level, const char *fmt, ...) +{ + va_list args; + va_start(args, fmt); + record(level, fmt, "[biz] value %u", args); + va_end(args); +} + +void test_log_sink(int level, const char *tag, const char *fmt, ...) +{ + va_list args; + if (strcmp(tag, "biz")) failed = 1; + va_start(args, fmt); + record(level, fmt, "value %u", args); + va_end(args); +} + +int main(void) +{ + BIZ_LOGE("value %u", ++evaluated); + BIZ_LOGW("value %u", ++evaluated); + BIZ_LOGI("value %u", ++evaluated); + BIZ_LOGD("value %u", ++evaluated); + if (evaluated != TEST_EXPECT_LEVEL) return 1; + for (unsigned level = LOG_ERROR; level <= LOG_DEBUG; ++level) + if (seen[level] != (unsigned)(level <= TEST_EXPECT_LEVEL)) return 2; + return failed ? 3 : 0; +} +""" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cc", required=True, help="C compiler executable") + args = parser.parse_args() + root = Path(__file__).resolve().parents[3] + includes = [ + root / "common", + root / "pal", + root / "modules/biz-client/include", + root / "modules/biz-client/src", + root / "modules/iot-client/include", + root / "third_party/cJSON", + ] + cases = [] + for global_level in range(5): + for module_level in [None, *range(5), 9]: + defines = {"AGENTIC_KIT_LOG_LEVEL": global_level} + if module_level is not None: + defines["AGENTIC_KIT_BIZ_LOG_LEVEL"] = module_level + effective = min(global_level, module_level if module_level is not None else global_level) + cases.append((f"global-{global_level}-biz-{module_level}", defines, effective, None)) + cases.extend([ + ("default", {}, 4, None), + ("custom-header-and-sink", {}, 1, "explicit"), + ("automatic-header-and-sink", {}, 2, "automatic"), + ]) + + with tempfile.TemporaryDirectory(prefix="agentic-biz-log-") as directory: + work = Path(directory) + source = work / "test.c" + source.write_text(FIXTURE, encoding="utf-8") + for name, defines, expected, config in cases: + case_dir = work / name + case_dir.mkdir() + command = [args.cc, "-std=c99", "-Wall", "-Wextra", "-Werror"] + command += [f"-I{path}" for path in [case_dir, *includes]] + command += [f"-D{key}={value}" for key, value in defines.items()] + command += [f"-DTEST_EXPECT_LEVEL={expected}"] + if config: + header = "business_options.h" if config == "explicit" else "agentic_kit_config.h" + (case_dir / header).write_text( + "#define AGENTIC_KIT_LOG_LEVEL 3\n" + f"#define AGENTIC_KIT_BIZ_LOG_LEVEL {expected}\n" + "#define AGENTIC_KIT_LOG(level, tag, fmt, ...) " + "test_log_sink(level, tag, fmt, ##__VA_ARGS__)\n", + encoding="utf-8", + ) + if config == "explicit": + command += [f'-DAGENTIC_KIT_USER_CONFIG="{header}"'] + executable = case_dir / "test" + subprocess.run([*command, str(source), "-o", str(executable)], check=True) + subprocess.run([str(executable)], check=True) + print(f"Business Client log compile matrix: {len(cases)}/{len(cases)} passed") + + +if __name__ == "__main__": + main() diff --git a/modules/iot-client/CONTEXT.md b/modules/iot-client/CONTEXT.md index e56bad5..8b37b85 100644 --- a/modules/iot-client/CONTEXT.md +++ b/modules/iot-client/CONTEXT.md @@ -67,6 +67,18 @@ validates and exposes the notice; the application owns playback interruption, Event correlation, and any TAI action. _Avoid_: DP set, TAI Event, injected callback. +**Local DP control**: +An application-authenticated LAN/BLE request applied through +`iot_client_apply_local_dps()`. It shares the schema, DP state and application +DP callback with cloud downlink, but marks its writes dirty for a later cloud +report. The complete batch and notification snapshots are prepared before +commit; failures leave state unchanged. Persistence fires before DP callbacks, +outside the lock. The application owns authentication and channel reporting. +String and number lexical checks are shared with LAN in `common/json_lexical.h`; +canonical DP IDs, duplicate-ID rejection and the flat DP-object grammar remain +IoT Client responsibilities. +_Avoid_: cloud downlink (its writes remain clean), Report (an outbound action). + **Activation** (on-boarding): First-time provisioning that authenticates the device and returns its credentials (devid / secret_key / local_key) together with its schema and schema id. diff --git a/modules/iot-client/include/iot_client.h b/modules/iot-client/include/iot_client.h index 3745309..80ad193 100644 --- a/modules/iot-client/include/iot_client.h +++ b/modules/iot-client/include/iot_client.h @@ -319,6 +319,38 @@ struct iot_dp_context; */ IOT_API iot_client_t *iot_client_init(const iot_client_config_t *config); +/** + * @brief Apply an authenticated local DP set from a LAN/BLE transport. + * + * The application authenticates the request before calling this function. Input + * is a non-empty bare DP object, e.g. {"1":true,"2":42}, in exactly @p len bytes + * (excluding a terminating NUL). The buffer need not be NUL-terminated. IDs must + * be unescaped decimal strings in 1..255, without leading zeros or duplicates. + * Values use the schema's wire form: bool, integer, string, enum label/index, + * or base64 for raw. Integer values must be integral and fit int32_t. Embedded + * NUL, escaped NUL, malformed UTF-8/JSON, and trailing data are rejected. + * + * The whole batch is validated and its storage/notification snapshots allocated + * before committing: any error leaves all DP values and dirty flags unchanged + * and fires no callback. Success marks each supplied DP dirty, fires the save + * callback once, then the existing DP callback once per supplied DP in request + * order, all outside the internal lock. Callback values are borrowed for the + * duration of each call and independent of the live cache. Raw DPs are omitted + * from the persistence snapshot, as with other DP writes. + * + * This function never publishes MQTT. The application owns cloud reporting and + * must serialize any callback-initiated MQTT operation with its IoT process loop. + * The caller keeps @p client alive through all callbacks and until return. + * Schema access mode is not enforced, matching the existing DP API. + * + * @return OPRT_OK; OPRT_INVALID_PARAMETER for malformed/empty input or duplicate + * IDs; OPRT_UNINITIALIZED if the DP context is unavailable; + * OPRT_DP_INVALID_ID for an unknown/out-of-range ID or loose mode; + * OPRT_DP_TYPE_MISMATCH / OPRT_DP_VALUE_OUT_OF_RANGE for schema violations; + * OPRT_MALLOC_FAILED if staging or notification allocation fails. + */ +IOT_API int iot_client_apply_local_dps(iot_client_t *client, const char *dps_json, size_t len); + /** * @brief Initialize IoT client via QR code on-boarding (first-time activation). * diff --git a/modules/iot-client/include/iot_dp.h b/modules/iot-client/include/iot_dp.h index 27b8711..a7e3a06 100644 --- a/modules/iot-client/include/iot_dp.h +++ b/modules/iot-client/include/iot_dp.h @@ -6,8 +6,8 @@ * * Sits on top of the existing transport layer: uplink reuses iot_client_publish() * (P2.3 encryption -> smart/device/out/{devid}); downlink is intercepted on the - * existing iot_client_process() receive path. No new encryption path, no new - * threads/timers. See the design doc for the full picture. + * existing iot_client_process() receive path. Authenticated LAN/BLE DP sets can + * enter through iot_client_apply_local_dps(). No new threads/timers. * * Persistence is NOT performed by the SDK. The SDK only provides the mechanism: * - export current DP state via iot_dp_dump_json() (pull) or the save callback (push); @@ -81,11 +81,11 @@ typedef struct { } iot_dp_value_t; /** - * @brief Cloud-to-device DP callback. + * @brief Application DP-set callback for cloud and authenticated local requests. * - * Fired once per changed DP after a downlink DP-set, after the internal lock is - * released. @p value (and any string/raw it points to) is valid only for the - * duration of the call. + * Fired once per supplied DP after a downlink DP-set or successful + * iot_client_apply_local_dps(), after the internal lock is released. @p value + * (and any string/raw it points to) is valid only for the duration of the call. */ typedef void (*iot_dp_callback_t)(uint8_t dp_id, const iot_dp_value_t *value, void *user_data); @@ -102,9 +102,10 @@ typedef void (*iot_schema_update_callback_t)(const char *schema_id, const char * * @brief DP-state-changed (persistence) callback. * * Fired (batched once per change event, after the internal lock is released) - * whenever a DP is written — via a downlink DP-set, iot_dp_set(), or - * iot_dp_report(). It fires even when the value written equals the current one - * (there is no value-equality check), so treat it as "a DP was written" rather + * whenever a DP is written — via a downlink DP-set, iot_dp_set(), + * iot_client_apply_local_dps(), or iot_dp_report(). It fires even when the value + * written equals the current one (there is no value-equality check), so treat + * it as "a DP was written" rather * than strictly "the value differs". @p dp_state_json is the current snapshot * {"dps":{...}}, * SDK-owned and valid only during the callback; write it (or copy it) before @@ -125,7 +126,7 @@ typedef void (*iot_dp_save_callback_t)(const char *dp_state_json, void *user_dat /* ---- Callback registration ---- */ /** - * @brief Register the cloud-to-device DP callback. cb/user_data may be NULL. + * @brief Register the application DP-set callback. cb/user_data may be NULL. * @return OPRT_OK, or OPRT_INVALID_PARAMETER if client is NULL, * OPRT_UNINITIALIZED if the DP context could not be allocated. */ @@ -161,8 +162,8 @@ IOT_API int iot_dp_set(iot_client_t *client, uint8_t dp_id, const iot_dp_value_t * * RISK — the returned STRING/RAW pointer aliases the live DP cache and has no * independent lifetime. The backing buffer is freed by the NEXT write to the same - * dp_id (iot_dp_set / iot_dp_report / a downlink DP-set), after which any retained - * pointer dangles (use-after-free if dereferenced): + * dp_id (iot_dp_set / iot_dp_report / iot_client_apply_local_dps / a downlink + * DP-set), after which any retained pointer dangles (use-after-free if dereferenced): * - Single-threaded: safe only if you consume or copy the value BEFORE the next * write to that dp_id; never stash the pointer across such a call. * - Multi-threaded: a concurrent write to the same dp_id on another thread can diff --git a/modules/iot-client/src/iot_dp.c b/modules/iot-client/src/iot_dp.c index 32e69ba..8f1dc98 100644 --- a/modules/iot-client/src/iot_dp.c +++ b/modules/iot-client/src/iot_dp.c @@ -16,6 +16,7 @@ #include "atop.h" /* atop_schema_newest_get */ #include "iot_dns.h" /* iot_region_to_host (via resolve helper) */ #include "cJSON.h" +#include "json_lexical.h" #include "mbedtls/base64.h" #include @@ -514,11 +515,9 @@ static int dp_add_value(cJSON *dps, const char *key, const iot_dp_entry_t *e, co * When include_raw is false, raw DPs are omitted: raw carries transient binary * frames that must not be persisted/restored. They are still reported uplink (see * dp_build_report_json) and preserved across a schema upgrade (include_raw=true). */ -static char *dp_build_state_json(iot_client_t *client, bool include_raw) +static char *dp_build_state_json_for(const pal_t *pal, struct iot_dp_context *ctx, + bool include_raw) { - struct iot_dp_context *ctx = client->dp; - const pal_t *pal = client->pal; - cJSON *root = cJSON_CreateObject(); if (!root) return NULL; cJSON *dps = cJSON_CreateObject(); @@ -542,6 +541,11 @@ static char *dp_build_state_json(iot_client_t *client, bool include_raw) return s; } +static char *dp_build_state_json(iot_client_t *client, bool include_raw) +{ + return dp_build_state_json_for(client->pal, client->dp, include_raw); +} + /* Build a {"protocol":3,"t":..,"data":{"dps":{...}}} report (lock held). */ static char *dp_build_report_json(iot_client_t *client, dp_sel_t sel, uint8_t one_id, size_t *count_out) { @@ -1005,6 +1009,164 @@ static void dp_value_snapshot_free(const pal_t *pal, iot_dp_value_t *v) pal->free((void *)v->value.raw.data); } +/* cJSON deliberately accepts some non-JSON numbers and whitespace. This local + * ingress accepts only a flat DP object, so validate its complete wire grammar + * before parsing, without allocating or reading outside the supplied slice. */ +static void dp_local_skip_space(const char *s, size_t len, size_t *pos) +{ + while (*pos < len && (s[*pos] == ' ' || s[*pos] == '\t' || + s[*pos] == '\r' || s[*pos] == '\n')) ++*pos; +} + +static int dp_local_validate_document(const char *s, size_t len, size_t *count) +{ + bool seen[256] = {false}; + size_t pos = 0; + *count = 0; + dp_local_skip_space(s, len, &pos); + if (pos == len || s[pos++] != '{') return OPRT_INVALID_PARAMETER; + for (;;) { + dp_local_skip_space(s, len, &pos); + if (pos == len || s[pos++] != '"') return OPRT_INVALID_PARAMETER; + if (pos == len || s[pos] < '1' || s[pos] > '9') return OPRT_INVALID_PARAMETER; + unsigned id = 0; + while (pos < len && s[pos] >= '0' && s[pos] <= '9') { + id = id * 10 + (unsigned)(s[pos++] - '0'); + if (id > 255) return OPRT_DP_INVALID_ID; + } + if (pos == len || s[pos++] != '"' || seen[id]) return OPRT_INVALID_PARAMETER; + seen[id] = true; + ++*count; + dp_local_skip_space(s, len, &pos); + if (pos == len || s[pos++] != ':') return OPRT_INVALID_PARAMETER; + dp_local_skip_space(s, len, &pos); + if (pos == len) return OPRT_INVALID_PARAMETER; + if (s[pos] == '"') { + if (!json_lexical_scan_string(s, len, &pos)) return OPRT_INVALID_PARAMETER; + } else if (s[pos] == '-' || (s[pos] >= '0' && s[pos] <= '9')) { + if (!json_lexical_scan_number(s, len, &pos)) return OPRT_INVALID_PARAMETER; + } else if (len - pos >= 4 && memcmp(s + pos, "true", 4) == 0) pos += 4; + else if (len - pos >= 5 && memcmp(s + pos, "false", 5) == 0) pos += 5; + else if (len - pos >= 4 && memcmp(s + pos, "null", 4) == 0) pos += 4; + else return OPRT_INVALID_PARAMETER; + dp_local_skip_space(s, len, &pos); + if (pos == len) return OPRT_INVALID_PARAMETER; + if (s[pos] == '}') { ++pos; break; } + if (s[pos++] != ',') return OPRT_INVALID_PARAMETER; + } + dp_local_skip_space(s, len, &pos); + return pos == len ? OPRT_OK : OPRT_INVALID_PARAMETER; +} + +int iot_client_apply_local_dps(iot_client_t *client, const char *dps_json, size_t len) +{ + if (!client || !dps_json || len == 0) return OPRT_INVALID_PARAMETER; + struct iot_dp_context *ctx = client->dp; + if (!ctx) return OPRT_UNINITIALIZED; + size_t count; + int rt = dp_local_validate_document(dps_json, len, &count); + if (rt != OPRT_OK) return rt; + /* Syntax and Unicode are already checked; parsing can now fail only when + * allocating. The buffer need not have a terminating NUL. */ + cJSON *root = cJSON_ParseWithLength(dps_json, len); + if (!root) return OPRT_MALLOC_FAILED; + const pal_t *pal = client->pal; + uint8_t changed[256]; + size_t nchanged = 0; + iot_dp_value_t *snap = NULL; + char *save_json = NULL; + iot_dp_callback_t cb = NULL; + iot_dp_save_callback_t save_cb = NULL; + void *cb_user = NULL, *save_user = NULL; + struct iot_dp_context staged; + memset(&staged, 0, sizeof(staged)); + + pal->mutex_lock(ctx->mutex); + if (ctx->loose || ctx->entry_count == 0) { rt = OPRT_DP_INVALID_ID; goto done; } + staged = *ctx; + staged.entries = pal->malloc(sizeof(*staged.entries) * ctx->entry_count); + if (!staged.entries) { rt = OPRT_MALLOC_FAILED; goto done; } + memcpy(staged.entries, ctx->entries, sizeof(*staged.entries) * ctx->entry_count); + cJSON *kv; + cJSON_ArrayForEach(kv, root) { + uint8_t id = (uint8_t)atoi(kv->string); /* canonical decimal checked above */ + iot_dp_entry_t *e = dp_find(&staged, id); + if (!e) { rt = OPRT_DP_INVALID_ID; goto done; } + e->str_buf = NULL; + e->raw_buf = NULL; + e->raw_len = 0; + changed[nchanged++] = id; + /* Local integer DPs must not silently truncate cJSON doubles, including + * range-less ENUMs. Keep the established MQTT coercion unchanged. */ + if (cJSON_IsNumber(kv) && (e->type == IOT_DP_TYPE_VALUE || e->type == IOT_DP_TYPE_ENUM)) { + double d = kv->valuedouble; + if (!(d >= INT32_MIN && d <= INT32_MAX) || d != (double)(int32_t)d) { + rt = OPRT_DP_VALUE_OUT_OF_RANGE; + goto done; + } + } + rt = dp_apply_json(&staged, pal, id, kv, true, false); + if (rt != OPRT_OK) goto done; + } + cb = ctx->dp_cb; + cb_user = ctx->dp_cb_user; + save_cb = ctx->save_cb; + save_user = ctx->save_cb_user; + if (cb) { + snap = pal->malloc(sizeof(*snap) * count); + if (!snap) { rt = OPRT_MALLOC_FAILED; goto done; } + memset(snap, 0, sizeof(*snap) * count); + for (size_t i = 0; i < count; ++i) { + iot_dp_entry_t *e = dp_find(&staged, changed[i]); + dp_value_snapshot(pal, e, &snap[i]); + if ((e->type == IOT_DP_TYPE_STRING && !snap[i].value.string) || + (e->type == IOT_DP_TYPE_RAW && snap[i].value.raw.len != e->raw_len)) { + rt = OPRT_MALLOC_FAILED; + goto done; + } + } + } + if (save_cb) { + save_json = dp_build_state_json_for(pal, &staged, false); + if (!save_json) { rt = OPRT_MALLOC_FAILED; goto done; } + } + /* Every fallible operation precedes the commit, including notification + * snapshots. Move owned buffers without allocating while holding the lock. */ + for (size_t i = 0; i < count; ++i) { + iot_dp_entry_t *e = dp_find(ctx, changed[i]); + iot_dp_entry_t *s = dp_find(&staged, changed[i]); + dp_entry_free_buffers(pal, e); + e->cur = s->cur; + e->str_buf = s->str_buf; + e->raw_buf = s->raw_buf; + e->raw_len = s->raw_len; + e->has_value = true; + e->dirty = true; + s->str_buf = NULL; + s->raw_buf = NULL; + } +done: + pal->mutex_unlock(ctx->mutex); + cJSON_Delete(root); + if (staged.entries) { + for (size_t i = 0; i < nchanged; ++i) + dp_entry_free_buffers(pal, dp_find(&staged, changed[i])); + pal->free(staged.entries); + } + /* Save the committed batch before an application DP callback can perform + * another write. Both notifications borrow independent, stable snapshots. */ + if (rt == OPRT_OK) { + if (save_cb) save_cb(save_json, save_user); + if (cb) for (size_t i = 0; i < count; ++i) cb(changed[i], &snap[i], cb_user); + } + if (save_json) pal->free(save_json); + if (snap) { + for (size_t i = 0; i < count; ++i) dp_value_snapshot_free(pal, &snap[i]); + pal->free(snap); + } + return rt; +} + bool iot_dp_dispatch_downlink(iot_client_t *client, const char *topic, size_t topic_len, const uint8_t *bytes, size_t len) { diff --git a/modules/iot-client/test/iot_dp_test.c b/modules/iot-client/test/iot_dp_test.c index 3406ff6..9fefd8d 100644 --- a/modules/iot-client/test/iot_dp_test.c +++ b/modules/iot-client/test/iot_dp_test.c @@ -57,6 +57,33 @@ static int tests_run = 0; static int tests_passed = 0; static char *g_cacert = NULL; +/* One process-wide cJSON binding, with allocation failures normally disabled. + * Every wrapper delegates to the default PAL's heap; legacy cases may continue + * using the default PAL directly without introducing a second heap. */ +static const pal_t *local_test_base_pal; +static pal_t local_test_pal; +static long local_alloc_fail_after = -1; +static int local_lock_depth; + +static void *local_test_malloc(size_t len) +{ + if (local_alloc_fail_after == 0) return NULL; + if (local_alloc_fail_after > 0) --local_alloc_fail_after; + return local_test_base_pal->malloc(len); +} + +static void local_test_lock(void *mutex) +{ + local_test_base_pal->mutex_lock(mutex); + ++local_lock_depth; +} + +static void local_test_unlock(void *mutex) +{ + --local_lock_depth; + local_test_base_pal->mutex_unlock(mutex); +} + #define RUN_TEST(fn) \ do { \ tests_run++; \ @@ -581,6 +608,248 @@ static int test_empty_reports_do_not_warn_when_utc_unavailable(void) return rc; } +/* A local DP set updates the shared cache without needing an MQTT connection. */ +static int test_local_dps_apply(void) +{ + const pal_t *pal = get_default_pal(); + iot_client_t *c = make_client(pal, TEST_SCHEMA); + int rc = -1; + if (!c) return -1; + reset_dp_cb(); + save_cb_count = 0; + iot_dp_set_callback(c, dp_callback, NULL); + iot_dp_set_save_callback(c, save_callback, NULL); + /* The final 'X' proves the slice need not have a terminating NUL. */ + const char request[] = " \n{\"1\":true,\"2\":250,\"3\":\"cold\"}\tX"; + if (iot_client_apply_local_dps(c, request, sizeof(request) - 2) != OPRT_OK || + dp_cb_count != 3 || save_cb_count != 1 || + dp_cb_ids[0] != 1 || dp_cb_ids[1] != 2 || dp_cb_ids[2] != 3 || + !dp_cb_vals[0].value.boolean || dp_cb_vals[1].value.integer != 250 || + dp_cb_vals[2].value.enum_index != 2 || + !strstr(save_cb_last, "\"2\":250") || !strstr(save_cb_last, "\"3\":\"cold\"")) { + printf(" local batch, notifications, or bounded input failed\n"); goto out; + } + /* An explicit report now attempts MQTT: local changes remained dirty. */ + if (iot_dp_report_all_dirty(c) == OPRT_OK) { + printf(" local batch was not marked dirty\n"); goto out; + } + rc = 0; +out: + destroy_client(c); + return rc; +} + +static int test_local_dps_reject_atomically(void) +{ + const pal_t *pal = get_default_pal(); + iot_client_t *c = make_client(pal, TEST_SCHEMA); + int rc = -1; + if (!c) return -1; + iot_dp_restore_json(c, "{\"dps\":{\"1\":false,\"2\":100,\"4\":\"old\"}}"); + reset_dp_cb(); + save_cb_count = 0; + iot_dp_set_callback(c, dp_callback, NULL); + iot_dp_set_save_callback(c, save_callback, NULL); + const struct { const char *json; int error; } cases[] = { + {"{\"1\":true,\"2\":1001}", OPRT_DP_VALUE_OUT_OF_RANGE}, + {"{\"1\":true,\"99\":1}", OPRT_DP_INVALID_ID}, + {"{\"1\":true,\"2\":false}", OPRT_DP_TYPE_MISMATCH}, + {"{\"4\":\"new\",\"5\":\"!bad!\"}", OPRT_DP_TYPE_MISMATCH}, + {"{\"4\":\"longer than sixteen bytes\"}", OPRT_DP_VALUE_OUT_OF_RANGE}, + {"{\"2\":1.5}", OPRT_DP_VALUE_OUT_OF_RANGE}, + {"{\"3\":1.5}", OPRT_DP_VALUE_OUT_OF_RANGE}, + {"{\"2\":1e400}", OPRT_DP_VALUE_OUT_OF_RANGE}, + {"{\"1\":null}", OPRT_DP_TYPE_MISMATCH}, + {"{}", OPRT_INVALID_PARAMETER}, + {"[]", OPRT_INVALID_PARAMETER}, + {"{\"01\":true}", OPRT_INVALID_PARAMETER}, + {"{\"0\":true}", OPRT_INVALID_PARAMETER}, + {"{\"256\":true}", OPRT_DP_INVALID_ID}, + {"{\"1x\":true}", OPRT_INVALID_PARAMETER}, + {"{\"\\u0031\":true}", OPRT_INVALID_PARAMETER}, + {"{\"1\":true,\"1\":false}", OPRT_INVALID_PARAMETER}, + {"{\"1\":true}garbage", OPRT_INVALID_PARAMETER}, + {"{\"1\":true},{}", OPRT_INVALID_PARAMETER}, + {"{\"1\":true,}", OPRT_INVALID_PARAMETER}, + {"{\"2\":01}", OPRT_INVALID_PARAMETER}, + {"{\"2\":1.}", OPRT_INVALID_PARAMETER}, + {"{\"2\":1e-}", OPRT_INVALID_PARAMETER}, + {"{\"4\":\"a\\u0000b\"}", OPRT_INVALID_PARAMETER}, + {"{\"4\":\"\\ud800\"}", OPRT_INVALID_PARAMETER}, + {"{\"4\":\"\xc0\x80\"}", OPRT_INVALID_PARAMETER}, + {"{\"4\":\"\xe0\x9f\xbf\"}", OPRT_INVALID_PARAMETER}, + {"{\"4\":\"\xf0\x8f\xbf\xbf\"}", OPRT_INVALID_PARAMETER}, + {"{\"4\":\"\xed\xa0\x80\"}", OPRT_INVALID_PARAMETER}, + {"{\"4\":\"\xf4\x90\x80\x80\"}", OPRT_INVALID_PARAMETER}, + {"{\"4\":\"\xe1\x80x\"}", OPRT_INVALID_PARAMETER}, + {"{\"4\":\"\\udc00\"}", OPRT_INVALID_PARAMETER}, + {"{\"4\":\"\\ud800\\u0041\"}", OPRT_INVALID_PARAMETER}, + {"{\"1\":true}\v", OPRT_INVALID_PARAMETER}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + int rt = iot_client_apply_local_dps(c, cases[i].json, strlen(cases[i].json)); + if (rt != cases[i].error) { + printf(" reject case %zu returned %d, expected %d\n", i, rt, cases[i].error); goto out; + } + } + const char embedded_nul[] = "{\"1\":true}\0{\"1\":false}"; + if (iot_client_apply_local_dps(c, embedded_nul, sizeof(embedded_nul) - 1) != OPRT_INVALID_PARAMETER || + iot_client_apply_local_dps(c, "{\"1\":true}", 9) != OPRT_INVALID_PARAMETER || + iot_client_apply_local_dps(c, NULL, 1) != OPRT_INVALID_PARAMETER || + iot_client_apply_local_dps(c, "", 0) != OPRT_INVALID_PARAMETER || + iot_client_apply_local_dps(NULL, "{\"1\":true}", 10) != OPRT_INVALID_PARAMETER) { + printf(" invalid slice accepted\n"); goto out; + } + iot_dp_value_t got; + if (iot_dp_get(c, 1, &got) != OPRT_OK || got.value.boolean || + iot_dp_get(c, 2, &got) != OPRT_OK || got.value.integer != 100 || + iot_dp_get(c, 4, &got) != OPRT_OK || strcmp(got.value.string, "old") != 0 || + dp_cb_count != 0 || save_cb_count != 0 || iot_dp_report_all_dirty(c) != OPRT_OK) { + printf(" rejected batch changed state, dirty flags, or notifications\n"); goto out; + } + rc = 0; +out: + destroy_client(c); + return rc; +} + +/* Shared lexical checks must retain every valid Unicode scalar, not only reject + * invalid UTF-8. Both literal UTF-8 and escaped surrogate pairs reach DP state. */ +static int test_local_dps_unicode_strings(void) +{ + const struct { const char *json; const char *decoded; } inputs[] = { + {"{\"4\":\"\xc2\xa2\"}", "\xc2\xa2"}, + {"{\"4\":\"\xe4\xb8\xad\"}", "\xe4\xb8\xad"}, + {"{\"4\":\"\xf0\x9f\x98\x80\"}", "\xf0\x9f\x98\x80"}, + {"{\"4\":\"\xed\x9f\xbf\"}", "\xed\x9f\xbf"}, + {"{\"4\":\"\xf4\x8f\xbf\xbf\"}", "\xf4\x8f\xbf\xbf"}, + {"{\"4\":\"\\uD83D\\uDE00\"}", "\xf0\x9f\x98\x80"}, + {"{\"4\":\"\\\\u0000\"}", "\\u0000"}, + }; + iot_client_t *c = make_client(get_default_pal(), TEST_SCHEMA); + int rc = -1; + if (!c) return -1; + for (size_t i = 0; i < sizeof(inputs) / sizeof(inputs[0]); ++i) { + iot_dp_value_t value; + if (iot_client_apply_local_dps(c, inputs[i].json, strlen(inputs[i].json)) != OPRT_OK || + iot_dp_get(c, 4, &value) != OPRT_OK || value.type != IOT_DP_TYPE_STRING || + strcmp(value.value.string, inputs[i].decoded) != 0) { + printf(" valid Unicode case %zu was lost or changed\n", i); + goto out; + } + } + rc = 0; +out: + destroy_client(c); + return rc; +} + +static int test_local_dps_requires_schema(void) +{ + const pal_t *pal = get_default_pal(); + iot_client_t *c = make_client(pal, NULL); + int rc = -1; + if (!c) return -1; + const char request[] = "{\"1\":true}"; + if (iot_client_apply_local_dps(c, request, sizeof(request) - 1) != OPRT_DP_INVALID_ID) goto out; + iot_dp_deinit(c); + if (iot_client_apply_local_dps(c, request, sizeof(request) - 1) != OPRT_UNINITIALIZED) goto out; + rc = 0; +out: + destroy_client(c); + return rc; +} + +typedef struct { + iot_client_t *client; + int callbacks; + bool failed; +} local_callback_state_t; + +static void local_ownership_save(const char *json, void *user) +{ + local_callback_state_t *state = user; + if (local_lock_depth != 0) state->failed = true; + save_callback(json, NULL); +} + +static void local_ownership_callback(uint8_t id, const iot_dp_value_t *value, void *user) +{ + local_callback_state_t *state = user; + if (local_lock_depth != 0) state->failed = true; + ++state->callbacks; + if (id == 4) { + /* Replacing both live buffers must not invalidate this callback or the + * raw callback that the original batch will invoke next. */ + iot_dp_value_t s = { .type = IOT_DP_TYPE_STRING, .value.string = "replacement" }; + const uint8_t bytes[] = {9}; + iot_dp_value_t r = { .type = IOT_DP_TYPE_RAW, .value.raw = {bytes, sizeof(bytes)} }; + if (save_cb_count != 1 || iot_dp_set(state->client, 4, &s) != OPRT_OK || + iot_dp_set(state->client, 5, &r) != OPRT_OK || + strcmp(value->value.string, "original") != 0) state->failed = true; + } else if (id == 5) { + const uint8_t expected[] = {0, 1, 255}; + if (value->value.raw.len != sizeof(expected) || + memcmp(value->value.raw.data, expected, sizeof(expected)) != 0) state->failed = true; + } else state->failed = true; +} + +static int test_local_dps_callback_ownership(void) +{ + iot_client_t *c = make_client(&local_test_pal, TEST_SCHEMA); + if (!c) return -1; + local_callback_state_t state = {.client = c}; + save_cb_count = 0; + iot_dp_set_callback(c, local_ownership_callback, &state); + iot_dp_set_save_callback(c, local_ownership_save, &state); + char request[] = "{\"4\":\"original\",\"5\":\"AAH/\"}"; + int rt = iot_client_apply_local_dps(c, request, sizeof(request) - 1); + memset(request, 'x', sizeof(request)); + iot_dp_value_t got; + int ok = rt == OPRT_OK && state.callbacks == 2 && !state.failed && local_lock_depth == 0 && + iot_dp_get(c, 4, &got) == OPRT_OK && strcmp(got.value.string, "replacement") == 0 && + iot_dp_get(c, 5, &got) == OPRT_OK && got.value.raw.len == 1 && got.value.raw.data[0] == 9 && + !strstr(save_cb_last, "\"5\""); + destroy_client(c); + return ok ? 0 : -1; +} + +/* Exhaust every allocation prefix, including cJSON parsing and persistence + * snapshots. An OOM at any stage must leave the original clean batch intact. */ +static int test_local_dps_allocation_rollback(void) +{ + int failures = 0; + for (long fail_at = 0; fail_at < 128; ++fail_at) { + iot_client_t *c = make_client(&local_test_pal, TEST_SCHEMA); + if (!c) return -1; + iot_dp_restore_json(c, "{\"dps\":{\"1\":false,\"4\":\"old\"}}"); + reset_dp_cb(); + save_cb_count = 0; + iot_dp_set_callback(c, dp_callback, NULL); + iot_dp_set_save_callback(c, save_callback, NULL); + const char request[] = "{\"1\":true,\"4\":\"original\",\"5\":\"AAH/\"}"; + local_alloc_fail_after = fail_at; + int rt = iot_client_apply_local_dps(c, request, sizeof(request) - 1); + local_alloc_fail_after = -1; + if (rt == OPRT_OK) { + int ok = failures > 4 && dp_cb_count == 3 && save_cb_count == 1; + destroy_client(c); + return ok ? 0 : -1; + } + ++failures; + iot_dp_value_t got; + int ok = rt == OPRT_MALLOC_FAILED && dp_cb_count == 0 && save_cb_count == 0 && + iot_dp_get(c, 1, &got) == OPRT_OK && !got.value.boolean && + iot_dp_get(c, 4, &got) == OPRT_OK && strcmp(got.value.string, "old") == 0 && + iot_dp_get(c, 5, &got) == OPRT_INVALID_RESULT && + iot_dp_report_all_dirty(c) == OPRT_OK && local_lock_depth == 0; + destroy_client(c); + if (!ok) { printf(" allocation rollback failed at prefix %ld (%d)\n", fail_at, rt); return -1; } + } + printf(" never reached a successful allocation prefix\n"); + return -1; +} + /* ============================================================================ * Mock-backed tests * ============================================================================ */ @@ -799,7 +1068,12 @@ int main(void) printf("========== IoT DP Test Suite ==========\n"); const pal_t *pal = get_default_pal(); - iot_init(pal); + local_test_base_pal = pal; + local_test_pal = *pal; + local_test_pal.malloc = local_test_malloc; + local_test_pal.mutex_lock = local_test_lock; + local_test_pal.mutex_unlock = local_test_unlock; + iot_init(&local_test_pal); g_cacert = load_file(pal, TEST_CONFIG_DIR "/root_cert.pem"); if (!g_cacert) fprintf(stderr, "Warning: CA cert not loaded; mock tests may fail\n"); @@ -822,6 +1096,12 @@ int main(void) RUN_TEST(test_writability_modes); RUN_TEST(test_validate_json); RUN_TEST(test_empty_reports_do_not_warn_when_utc_unavailable); + RUN_TEST(test_local_dps_apply); + RUN_TEST(test_local_dps_reject_atomically); + RUN_TEST(test_local_dps_unicode_strings); + RUN_TEST(test_local_dps_requires_schema); + RUN_TEST(test_local_dps_callback_ownership); + RUN_TEST(test_local_dps_allocation_rollback); /* mock-backed */ RUN_TEST(test_report_roundtrip); diff --git a/modules/tuya-lan/CONTEXT.md b/modules/tuya-lan/CONTEXT.md new file mode 100644 index 0000000..1197ddb --- /dev/null +++ b/modules/tuya-lan/CONTEXT.md @@ -0,0 +1,87 @@ +# Tuya LAN (Device ↔ App, Local Control) + +An application-driven Tuya LAN 3.5 service for an activated device. It owns +discovery, TCP Sessions, authenticated Frames, local DP control and replies. +It does not own the device's schema or DP state: the application connects its +callbacks to the [IoT Client](../iot-client/CONTEXT.md). + +## Language + +**Discovery**: encrypted UDP announcements and App discovery requests. The +published discovery key identifies the protocol; it does not authorize control. +_Avoid_: Pairing (BLE), Activation (cloud). + +**Session**: one accepted App TCP connection, with its own handshake, session +key, sequence counters, receive buffer and send queue. It is unrelated to an +AI Foundation Session. _Avoid_: MQTT connection, TAI Session. + +**Frame**: LAN 3.5 header, nonce, AES-GCM ciphertext/tag and tail. Its header +is authenticated additional data. _Avoid_: MQTT envelope, BLE Packet. + +**local_key**: the 16-byte credential returned by Activation, used to +authenticate the three-step LAN handshake. _Avoid_: secret_key (MQTT/cloud +authentication), auth_key (the factory credential), AI session token. + +**Session key**: the per-Session AES-GCM key derived from the two handshake +nonces after mutual HMAC validation. Its first byte may be zero; only the +explicit authenticated state establishes readiness. + +**Local DP control**: an authenticated App request to apply a `dps` object. +The application supplies the schema and controls execution through the public +`iot_client_apply_local_dps()` interface or its own callback implementation. +_Avoid_: cloud downlink (which has different dirty/reporting semantics). + +**Report**: an application-requested LAN status notification to authenticated +Sessions. It neither publishes MQTT nor clears the cloud's dirty bits. + +**Extension**: authenticated opaque data identified by a LAN command, including +`0x20` for P2P signaling. A signaling callback does not implement P2P media. + +## Invariants + +- LAN is opt-in at build time: CMake `AGENTIC_KIT_ENABLE_LAN` and ESP-IDF + `CONFIG_AGENTIC_KIT_ENABLE_LAN` both default off. The build exports the + `AGENTIC_KIT_ENABLE_LAN` availability macro as 0 or 1 to SDK consumers. + Disabled builds omit LAN and its socket port, tests and example; the IoT + local DP ingress remains available. Runtime start/stop controls listening + only after LAN has been included in the build. +- One application owner drives start/stop/process/report/send and every callback. + There is no worker, sleep, automatic MQTT publication or reconnect. Callbacks + may send/report; teardown and recursive process calls must wait until they return. +- Initialize the process-wide PAL/RNG/cJSON binding once before JSON processing + (`iot_init()` already does this). All SDK contexts use that same allocator. +- `pal_t` is the single platform capability table. Its optional server/datagram + callbacks are required only for LAN and checked by `pal_has_server_network()`. + The fields remain present in disabled builds; base `pal_is_valid()` does not + require them. Accepted TCP connections share the existing TCP I/O and close + callbacks, using timeout zero in the LAN loop. LAN I/O is nonblocking; partial + sends and backpressure retain queued bytes until accepted or the Session closes. +- Only security commands 3 and 5 use local_key before authentication. DP, + heartbeat and extensions require an authenticated Session; there is no + compatibility allowance for unauthenticated local_key commands. +- Verify Frame geometry and GCM authentication before committing sequence or + application state. Replayed Frames, invalid authentication and oversize + Frames close only their own Session. Entropy failure cannot establish one. +- Handshake and liveness deadlines use monotonic `pal_t.time_ms`; report time + uses UTC `pal_t.wal_clock_time`. The application owns UTC synchronization. +- Discovery replies use the received source address, validated against the + request, rather than trusting an arbitrary JSON destination. +- LAN and local DP input share the bounded string/number lexical checks in + `common/json_lexical.h`: reject invalid UTF-8, malformed escapes, unpaired + surrogates and decoded NUL before cJSON parsing. JSON structure and DP-key + rules remain with the consumers. +- Config strings outlive the service. Stop/destroy LAN before clearing, + replacing or freeing activation credentials; recreate it after a key change. +- LAN and cloud share one DP state. Apply local controls with dirty=true, + persist through the existing save callback, and let the application decide + when to report to each channel. Query/report never clear MQTT dirty bits. +- The existing DP parser does not enforce schema access mode. Product-specific + authorization belongs in the application callback before applying a request. + +## Reference baseline + +The wire format is based on TuyaOpen commit +`68f76c966ef6154c319ce122506d4e0934b2979b`, `src/tuya_cloud_service/lan/` and +`src/tuya_cloud_service/protocol/tuya_protocol.c`. This module intentionally +uses agentic-kit's PAL and application-owned lifecycle. It implements LAN 3.5; +legacy LAN versions, AP provisioning and P2P media are separate capabilities. diff --git a/modules/tuya-lan/README.md b/modules/tuya-lan/README.md new file mode 100644 index 0000000..291ea46 --- /dev/null +++ b/modules/tuya-lan/README.md @@ -0,0 +1,18 @@ +# Tuya LAN + +`tuya-lan` 为已激活设备提供 Tuya LAN 3.5 本地控制:UDP 发现、TCP 会话、三步鉴权、 +心跳、DP 查询、本地 DP 控制和显式状态上报。应用调用 `tuya_lan_process()` 驱动服务, +并通过回调复用 IoT Client 的 schema、DP state 和业务处理。 + +LAN 默认不参与构建;主机通过 `AGENTIC_KIT_ENABLE_LAN`,ESP-IDF 通过 +`CONFIG_AGENTIC_KIT_ENABLE_LAN` 开启。LAN 与 MQTT 连接独立;P2P 信令可经扩展命令 +传递,P2P 协商与媒体传输由其他模块负责。 + +## 文档入口 + +- [架构与功能说明](../../docs-site/docs/guides/tuya-lan-architecture.md):模块关系、功能边界、数据流和集成方式。 +- [接口与集成参考](../../docs-site/docs/reference/tuya-lan.md):构建开关、配置、回调、生命周期和验证命令。 +- [领域模型](CONTEXT.md):LAN 术语与实现不变量。 +- [架构决策:应用驱动并共享 DP 状态](docs/adr/0001-lan-is-application-driven-and-shares-dp-state.md)。 +- 公共接口:[tuya_lan.h](include/tuya_lan.h) 与统一平台接口 [pal.h](../../pal/pal.h)。 +- [POSIX 示例](../../examples/posix/lan/lan_demo.c):使用离线 DP registry 运行本地控制服务。 diff --git a/modules/tuya-lan/docs/adr/0001-lan-is-application-driven-and-shares-dp-state.md b/modules/tuya-lan/docs/adr/0001-lan-is-application-driven-and-shares-dp-state.md new file mode 100644 index 0000000..9b1a472 --- /dev/null +++ b/modules/tuya-lan/docs/adr/0001-lan-is-application-driven-and-shares-dp-state.md @@ -0,0 +1,52 @@ +# LAN is application-driven and shares the IoT DP state + +LAN controls need the same schema, values and hardware callbacks as cloud +controls. Routing them through the MQTT downlink dispatcher would silently +mark them clean, while using `iot_dp_set()` alone would omit the control callback. + +The LAN module therefore exposes bounded DP/query callbacks, without importing +IoT private headers. Applications use `iot_client_apply_local_dps()` to validate +and apply local controls transactionally, marking them dirty for a later cloud +report. Query and explicit LAN reports use the same DP state document. + +Both inputs share length-bounded JSON lexical checks in `common/json_lexical`. +This keeps UTF-8, escape, surrogate and number rules in one implementation. +cJSON still parses JSON structure; the IoT Client owns canonical DP IDs and +duplicate-ID checks, while LAN validates command envelopes and uses the bounded +nesting precheck. + +The application pumps LAN and owns all reporting. LAN does not add a second +MQTT owner or change the AI/BLE threading models. All platform capabilities live +in one `pal_t`. Optional listening/datagram callbacks extend that table, and +accepted TCP connections reuse the existing send/receive/poll/close callbacks. +LAN uses timeout zero for nonblocking TCP I/O. The implementations live directly +in `pal_posix.c` and `pal_freertos.c`, using BSD sockets and lwIP respectively. +There is no separate network capability table or socket implementation file. + +The optional fields are always present, independent of the build option, so all +consumers must rebuild after this layout change. `pal_is_valid()` continues to +require only the base callbacks; LAN additionally requires the complete group +checked by `pal_has_server_network()`. `tuya_lan_config_t` takes only the shared +`pal`, with no separate network configuration. + +LAN is an optional build capability. The host CMake option +`AGENTIC_KIT_ENABLE_LAN` and ESP-IDF `CONFIG_AGENTIC_KIT_ENABLE_LAN` default off, +so existing products do not acquire the service or optional server/UDP code by +default. The built-in PAL factories leave those callbacks NULL when disabled. +The build exports `AGENTIC_KIT_ENABLE_LAN=0/1` for conditional application code. +The general IoT local DP ingress remains available independently of LAN. + +P2P signaling may be carried as an authenticated extension. The P2P module owns +negotiation/media; the application connects that module to LAN or MQTT. + +## Consequences + +- Existing IoT initialization and cloud-report APIs retain their behavior. +- Products explicitly enable LAN when configuring their build; runtime + start/stop manages the compiled service's listening state. +- Applications must call `tuya_lan_process()` frequently, decide when to report, + and stop LAN before replacing device credentials. +- A product can run LAN using persisted credentials and schema while MQTT is + disconnected. Discovery supplies no control authorization by itself. +- Platform ports without listening/datagram support continue to work for the + existing SDK contexts, and implement the optional `pal_t` group when using LAN. diff --git a/modules/tuya-lan/include/tuya_lan.h b/modules/tuya-lan/include/tuya_lan.h new file mode 100644 index 0000000..53078a4 --- /dev/null +++ b/modules/tuya-lan/include/tuya_lan.h @@ -0,0 +1,104 @@ +/* Tuya LAN 3.5 device service. All calls and callbacks share one owner thread. */ +#ifndef TUYA_LAN_H +#define TUYA_LAN_H + +#include +#include +#include +#include "pal.h" +#include "tuya_lan_config_defaults.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct tuya_lan tuya_lan_t; + +enum { + LAN_OK = 0, + LAN_ERR_PARAM = -1, + LAN_ERR_MEMORY = -2, + LAN_ERR_NET = -3, + LAN_ERR_PROTOCOL = -4, + LAN_ERR_AUTH = -5, + LAN_ERR_TIMEOUT = -6, + LAN_ERR_STATE = -8, + LAN_ERR_LIMIT = -9, + LAN_ERR_CRYPTO = -10, + LAN_ERR_NOT_SUPPORTED = -11 +}; + +#define TUYA_LAN_TCP_PORT 6668 +#define TUYA_LAN_UDP_PORT 7000 +#define TUYA_LAN_DISCOVERY_PORT 6667 +#define TUYA_LAN_MAX_FRAME_SIZE AGENTIC_KIT_LAN_MAX_FRAME_SIZE +#define TUYA_LAN_CMD_CONTROL 0x07u +#define TUYA_LAN_CMD_REPORT 0x08u +#define TUYA_LAN_CMD_HEARTBEAT 0x09u +#define TUYA_LAN_CMD_QUERY 0x0au +#define TUYA_LAN_CMD_CONTROL_NEW 0x0du +#define TUYA_LAN_CMD_QUERY_NEW 0x10u +#define TUYA_LAN_CMD_P2P 0x20u + +/* The dps object (e.g. {"1":true}) is borrowed for this callback only. + * Return LAN_OK after applying it; any other result produces an error ACK. */ +typedef int (*tuya_lan_dp_callback_t)(const char *dps_json, size_t len, void *user); +/* Write a complete DP state document {"dps":{...}}, without requiring a NUL + * terminator. Set *len <= capacity. The service never clears DP dirty bits. */ +typedef int (*tuya_lan_query_callback_t)(char *json, size_t capacity, size_t *len, void *user); +/* Opaque authenticated extension data, borrowed for the callback only. Use + * tuya_lan_send to reply to this session. Return LAN_OK to keep the session; + * any other result closes it. No P2P transport is implemented here. */ +typedef int (*tuya_lan_command_callback_t)(uint32_t session_id, uint32_t command, + const uint8_t *data, size_t len, void *user); + +typedef struct { + /* Shared SDK PAL, including its optional server/datagram callbacks. */ + const pal_t *pal; + /* Borrowed for the entire service lifetime; local_key must be exactly + * 16 bytes plus a terminating NUL. Destroy/recreate after credentials change. */ + const char *device_id; + const char *uuid; + const char *product_key; + const char *local_key; + const char *ipv4; + const char *bind_ip; + const char *broadcast_ip; + /* Use config_init for defaults; port zero requests an ephemeral listener. */ + uint16_t tcp_port; + uint16_t udp_port; + uint16_t discovery_port; + size_t max_clients; + uint32_t handshake_timeout_ms; + uint32_t heartbeat_timeout_ms; + uint32_t discovery_interval_ms; + tuya_lan_dp_callback_t on_dp; + tuya_lan_query_callback_t on_query; + tuya_lan_command_callback_t on_command; + void *user; +} tuya_lan_config_t; + +/* Initialize shared RNG and the process-wide cJSON/PAL allocator at startup + * (iot_init/iot_init_default already do this). All SDK modules use the same PAL. */ +void tuya_lan_config_init(tuya_lan_config_t *config); +int tuya_lan_create(const tuya_lan_config_t *config, tuya_lan_t **out); +int tuya_lan_start(tuya_lan_t *lan); +int tuya_lan_stop(tuya_lan_t *lan); +void tuya_lan_destroy(tuya_lan_t *lan); +/* Nonblocking service pump. Call frequently from the same application thread + * as report/send. Callbacks may report/send; do not destroy/stop/process or + * disconnect_all recursively from a callback. No worker or automatic uplink. */ +int tuya_lan_process(tuya_lan_t *lan); +/* Explicitly report {"dps":{...}} to authenticated sessions. */ +int tuya_lan_report(tuya_lan_t *lan, const char *json, size_t len); +int tuya_lan_send(tuya_lan_t *lan, uint32_t session_id, uint32_t command, + uint32_t return_code, const uint8_t *data, size_t len); +int tuya_lan_disconnect_all(tuya_lan_t *lan); +size_t tuya_lan_client_count(const tuya_lan_t *lan); +uint16_t tuya_lan_tcp_port(const tuya_lan_t *lan); +uint16_t tuya_lan_udp_port(const tuya_lan_t *lan); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/modules/tuya-lan/include/tuya_lan_config_defaults.h b/modules/tuya-lan/include/tuya_lan_config_defaults.h new file mode 100644 index 0000000..523d6c8 --- /dev/null +++ b/modules/tuya-lan/include/tuya_lan_config_defaults.h @@ -0,0 +1,17 @@ +#ifndef TUYA_LAN_CONFIG_DEFAULTS_H +#define TUYA_LAN_CONFIG_DEFAULTS_H +#include "log.h" +#ifndef AGENTIC_KIT_LAN_MAX_FRAME_SIZE +#define AGENTIC_KIT_LAN_MAX_FRAME_SIZE 4096 +#endif +#if AGENTIC_KIT_LAN_MAX_FRAME_SIZE < 128 || AGENTIC_KIT_LAN_MAX_FRAME_SIZE > 65536 +#error "AGENTIC_KIT_LAN_MAX_FRAME_SIZE must be between 128 and 65536" +#endif +#ifndef AGENTIC_KIT_LAN_LOG_LEVEL +#define AGENTIC_KIT_LAN_LOG_LEVEL AGENTIC_KIT_LOG_LEVEL +#endif +#if AGENTIC_KIT_LAN_LOG_LEVEL > AGENTIC_KIT_LOG_LEVEL +#undef AGENTIC_KIT_LAN_LOG_LEVEL +#define AGENTIC_KIT_LAN_LOG_LEVEL AGENTIC_KIT_LOG_LEVEL +#endif +#endif diff --git a/modules/tuya-lan/src/tuya_lan.c b/modules/tuya-lan/src/tuya_lan.c new file mode 100644 index 0000000..d8088a2 --- /dev/null +++ b/modules/tuya-lan/src/tuya_lan.c @@ -0,0 +1,323 @@ +#include "tuya_lan.h" +#include "tuya_lan_protocol.h" +#include "bounded_string.h" + +#include + +#define LAN_CLIENT_LIMIT 3u +#define LAN_IO_BUDGET 4u +#define LAN_TX_CAPACITY (2u * TUYA_LAN_MAX_FRAME_SIZE) + +typedef struct { + struct tuya_lan *owner; + void *socket; + lan_protocol_t *protocol; + uint32_t id; + bool failed; + uint8_t *tx; + size_t tx_begin; + size_t tx_end; +} lan_peer_t; + +struct tuya_lan { + tuya_lan_config_t config; + void *listener; + void *udp; + lan_peer_t *peers; + uint8_t *discovery; + uint8_t *udp_rx; + size_t discovery_len; + uint64_t last_discovery; + uint32_t next_id; + uint16_t tcp_port; + uint16_t udp_port; + bool started; + bool processing; + bool announced; +}; + +static bool ipv4_valid(const char *ip) +{ + if (!ip || !*ip) return false; + for (unsigned part = 0; part < 4; part++) { + unsigned value = 0, digits = 0; + while (*ip >= '0' && *ip <= '9') { + value = value * 10u + (unsigned)(*ip++ - '0'); + if (++digits > 3 || value > 255) return false; + } + if (!digits) return false; + if (part == 3) return *ip == '\0'; + if (*ip++ != '.') return false; + } + return false; +} + +void tuya_lan_config_init(tuya_lan_config_t *c) +{ + if (!c) return; + memset(c, 0, sizeof(*c)); + c->tcp_port = TUYA_LAN_TCP_PORT; + c->udp_port = TUYA_LAN_UDP_PORT; + c->discovery_port = TUYA_LAN_DISCOVERY_PORT; + c->broadcast_ip = "255.255.255.255"; + c->max_clients = LAN_CLIENT_LIMIT; + c->handshake_timeout_ms = 10000; + c->heartbeat_timeout_ms = 30000; + c->discovery_interval_ms = 5000; +} + +static int peer_emit(void *user, const uint8_t *frame, size_t len) +{ + lan_peer_t *peer = user; + if (peer->failed || !frame || !len || len > LAN_TX_CAPACITY - + (peer->tx_end - peer->tx_begin)) { + peer->failed = true; + return LAN_ERR_LIMIT; + } + /* Compact only when the end cannot fit; partial writes preserve all bytes. */ + if (len > LAN_TX_CAPACITY - peer->tx_end) { + memmove(peer->tx, peer->tx + peer->tx_begin, peer->tx_end - peer->tx_begin); + peer->tx_end -= peer->tx_begin; + peer->tx_begin = 0; + } + memcpy(peer->tx + peer->tx_end, frame, len); + peer->tx_end += len; + return LAN_OK; +} + +static void peer_close(lan_peer_t *peer) +{ + const tuya_lan_config_t *c = &peer->owner->config; + if (peer->socket) c->pal->tcp_close(peer->socket); + if (peer->protocol) lan_protocol_destroy(peer->protocol); + if (peer->tx) { + /* Queued authentication frames may contain nonces/proofs. */ + volatile uint8_t *p = peer->tx; + for (size_t i = 0; i < LAN_TX_CAPACITY; i++) p[i] = 0; + c->pal->free(peer->tx); + } + struct tuya_lan *owner = peer->owner; + memset(peer, 0, sizeof(*peer)); + peer->owner = owner; +} + +int tuya_lan_create(const tuya_lan_config_t *c, tuya_lan_t **out) +{ + if (!out) return LAN_ERR_PARAM; + *out = NULL; + /* LAN maxima exclude the NUL; bounded-string capacities include it. */ + if (!c || !pal_is_valid(c->pal) || + !pal_has_server_network(c->pal) || !bounded_string_nonempty(c->device_id, 64u + 1u) || + !bounded_string_nonempty(c->uuid, 64u + 1u) || + !bounded_string_nonempty(c->product_key, 64u + 1u) || + !bounded_string_nonempty(c->local_key, 16u + 1u) || strlen(c->local_key) != 16 || + !ipv4_valid(c->ipv4) || (c->bind_ip && !ipv4_valid(c->bind_ip)) || + (c->broadcast_ip && !ipv4_valid(c->broadcast_ip)) || + !c->discovery_port || !c->max_clients || c->max_clients > LAN_CLIENT_LIMIT || + !c->handshake_timeout_ms || !c->heartbeat_timeout_ms) + return LAN_ERR_PARAM; + tuya_lan_t *lan = c->pal->malloc(sizeof(*lan)); + if (!lan) return LAN_ERR_MEMORY; + memset(lan, 0, sizeof(*lan)); + lan->config = *c; + lan->peers = c->pal->malloc(c->max_clients * sizeof(*lan->peers)); + lan->discovery = c->pal->malloc(TUYA_LAN_MAX_FRAME_SIZE); + lan->udp_rx = c->pal->malloc(TUYA_LAN_MAX_FRAME_SIZE); + if (!lan->peers || !lan->discovery || !lan->udp_rx) { + c->pal->free(lan->peers); c->pal->free(lan->discovery); + c->pal->free(lan->udp_rx); c->pal->free(lan); + return LAN_ERR_MEMORY; + } + memset(lan->peers, 0, c->max_clients * sizeof(*lan->peers)); + for (size_t i = 0; i < c->max_clients; i++) lan->peers[i].owner = lan; + *out = lan; + return LAN_OK; +} + +int tuya_lan_start(tuya_lan_t *lan) +{ + if (!lan) return LAN_ERR_PARAM; + if (lan->started || lan->processing) return LAN_ERR_STATE; + const tuya_lan_config_t *c = &lan->config; + int rc = lan_discovery_build(c, lan->discovery, TUYA_LAN_MAX_FRAME_SIZE, + &lan->discovery_len); + if (rc) return rc; + if (c->pal->tcp_listen(c->pal, c->bind_ip, c->tcp_port, + (unsigned)c->max_clients, &lan->listener) || + c->pal->udp_bind(c->pal, c->bind_ip, c->udp_port, true, &lan->udp) || + c->pal->socket_local_port(lan->listener, &lan->tcp_port) || + c->pal->socket_local_port(lan->udp, &lan->udp_port)) { + if (lan->listener) c->pal->tcp_close(lan->listener); + if (lan->udp) c->pal->udp_close(lan->udp); + lan->listener = NULL; lan->udp = NULL; + lan->tcp_port = 0; lan->udp_port = 0; + return LAN_ERR_NET; + } + lan->started = true; + lan->announced = false; + return LAN_OK; +} + +int tuya_lan_disconnect_all(tuya_lan_t *lan) +{ + if (!lan) return LAN_ERR_PARAM; + if (lan->processing) return LAN_ERR_STATE; + for (size_t i = 0; i < lan->config.max_clients; i++) peer_close(&lan->peers[i]); + return LAN_OK; +} + +int tuya_lan_stop(tuya_lan_t *lan) +{ + if (!lan) return LAN_ERR_PARAM; + if (lan->processing) return LAN_ERR_STATE; + tuya_lan_disconnect_all(lan); + if (lan->listener) lan->config.pal->tcp_close(lan->listener); + if (lan->udp) lan->config.pal->udp_close(lan->udp); + lan->listener = NULL; lan->udp = NULL; + lan->tcp_port = 0; lan->udp_port = 0; lan->started = false; + return LAN_OK; +} + +void tuya_lan_destroy(tuya_lan_t *lan) +{ + if (!lan || lan->processing) return; + tuya_lan_stop(lan); + const pal_t *pal = lan->config.pal; + pal->free(lan->peers); pal->free(lan->discovery); pal->free(lan->udp_rx); + pal->free(lan); +} + +static void accept_clients(tuya_lan_t *lan) +{ + const tuya_lan_config_t *c = &lan->config; + for (unsigned n = 0; n < LAN_CLIENT_LIMIT; n++) { + void *socket = NULL; + int rc = c->pal->tcp_accept(lan->listener, &socket, NULL); + if (rc) break; + lan_peer_t *peer = NULL; + for (size_t i = 0; i < c->max_clients; i++) + if (!lan->peers[i].socket) { peer = &lan->peers[i]; break; } + if (!peer) { c->pal->tcp_close(socket); continue; } + peer->socket = socket; + /* Session IDs are never zero; a full wrap is harmless with <=3 peers. */ + do { peer->id = ++lan->next_id; } while (!peer->id); + peer->tx = c->pal->malloc(LAN_TX_CAPACITY); + if (!peer->tx || lan_protocol_create(c, peer->id, peer_emit, peer, + &peer->protocol)) peer_close(peer); + } +} + +static void flush_peer(lan_peer_t *peer) +{ + const pal_t *pal = peer->owner->config.pal; + for (unsigned i = 0; i < LAN_IO_BUDGET && peer->tx_begin < peer->tx_end; i++) { + size_t remain = peer->tx_end - peer->tx_begin; + int n = pal->tcp_send(peer->socket, peer->tx + peer->tx_begin, remain, 0); + if (n == PAL_ERR_AGAIN) return; + if (n <= 0 || (size_t)n > remain) { peer->failed = true; return; } + peer->tx_begin += (size_t)n; + } + if (peer->tx_begin == peer->tx_end) peer->tx_begin = peer->tx_end = 0; +} + +static void process_peer(lan_peer_t *peer) +{ + if (peer->failed) { peer_close(peer); return; } + if (lan_protocol_tick(peer->protocol)) { peer_close(peer); return; } + flush_peer(peer); + uint8_t rx[1024]; + for (unsigned i = 0; i < LAN_IO_BUDGET && !peer->failed; i++) { + int n = peer->owner->config.pal->tcp_recv(peer->socket, rx, sizeof(rx), 0); + if (n == PAL_ERR_AGAIN) break; + if (n <= 0 || (size_t)n > sizeof(rx) || + lan_protocol_feed(peer->protocol, rx, (size_t)n)) peer->failed = true; + } + if (!peer->failed) flush_peer(peer); + if (peer->failed) peer_close(peer); +} + +static void process_discovery(tuya_lan_t *lan) +{ + const tuya_lan_config_t *c = &lan->config; + uint64_t now = c->pal->time_ms(); + if (c->discovery_interval_ms && c->broadcast_ip && + (!lan->announced || now - lan->last_discovery >= c->discovery_interval_ms)) { + pal_addr_t destination; + memset(&destination, 0, sizeof(destination)); + memcpy(destination.ip, c->broadcast_ip, strlen(c->broadcast_ip) + 1); + destination.port = c->discovery_port; + int n = c->pal->udp_sendto(lan->udp, &destination, lan->discovery, + lan->discovery_len); + if (n != PAL_ERR_AGAIN) { + lan->announced = true; + lan->last_discovery = now; + } + } + for (unsigned i = 0; i < LAN_IO_BUDGET; i++) { + pal_addr_t source; + int n = c->pal->udp_recvfrom(lan->udp, &source, lan->udp_rx, + TUYA_LAN_MAX_FRAME_SIZE); + if (n == PAL_ERR_AGAIN) break; + if (n <= 0 || (size_t)n > TUYA_LAN_MAX_FRAME_SIZE) continue; + char advertised[16]; + if (lan_discovery_parse(c, lan->udp_rx, (size_t)n, advertised, + sizeof(advertised)) == LAN_OK && + !strcmp(advertised, source.ip)) + c->pal->udp_sendto(lan->udp, &source, lan->discovery, lan->discovery_len); + } +} + +int tuya_lan_process(tuya_lan_t *lan) +{ + if (!lan) return LAN_ERR_PARAM; + if (!lan->started || lan->processing) return LAN_ERR_STATE; + lan->processing = true; + accept_clients(lan); + for (size_t i = 0; i < lan->config.max_clients; i++) + if (lan->peers[i].socket) process_peer(&lan->peers[i]); + process_discovery(lan); + lan->processing = false; + return LAN_OK; +} + +int tuya_lan_report(tuya_lan_t *lan, const char *json, size_t len) +{ + if (!lan || !json || !len) return LAN_ERR_PARAM; + if (!lan->started) return LAN_ERR_STATE; + int rc = lan_protocol_validate_state(&lan->config, json, len); + if (rc) return rc; + int result = LAN_OK; + for (size_t i = 0; i < lan->config.max_clients; i++) { + lan_peer_t *peer = &lan->peers[i]; + if (!peer->socket || peer->failed || !lan_protocol_authenticated(peer->protocol)) continue; + rc = lan_protocol_report(peer->protocol, json, len); + if (rc) { peer->failed = true; result = rc; } + } + return result; +} + +int tuya_lan_send(tuya_lan_t *lan, uint32_t session_id, uint32_t command, + uint32_t return_code, const uint8_t *data, size_t len) +{ + if (!lan || (!data && len) || !session_id) return LAN_ERR_PARAM; + if (!lan->started) return LAN_ERR_STATE; + for (size_t i = 0; i < lan->config.max_clients; i++) { + lan_peer_t *peer = &lan->peers[i]; + if (peer->socket && !peer->failed && peer->id == session_id) { + int rc = lan_protocol_send(peer->protocol, command, return_code, data, len); + if (rc && rc != LAN_ERR_PARAM && rc != LAN_ERR_AUTH) peer->failed = true; + return rc; + } + } + return LAN_ERR_STATE; +} + +size_t tuya_lan_client_count(const tuya_lan_t *lan) +{ + size_t count = 0; + if (lan) for (size_t i = 0; i < lan->config.max_clients; i++) + if (lan->peers[i].socket && !lan->peers[i].failed) count++; + return count; +} +uint16_t tuya_lan_tcp_port(const tuya_lan_t *lan) { return lan ? lan->tcp_port : 0; } +uint16_t tuya_lan_udp_port(const tuya_lan_t *lan) { return lan ? lan->udp_port : 0; } diff --git a/modules/tuya-lan/src/tuya_lan_protocol.c b/modules/tuya-lan/src/tuya_lan_protocol.c new file mode 100644 index 0000000..b08a04b --- /dev/null +++ b/modules/tuya-lan/src/tuya_lan_protocol.c @@ -0,0 +1,557 @@ +/* TuyaOpen LAN 3.5 wire-compatible framing and device-side session protocol. + * The service owns the sockets; this file owns only authenticated messages. */ +#include "tuya_lan_protocol.h" +#include "rng.h" +#include "json_lexical.h" +#include "cJSON.h" +#include "mbedtls/gcm.h" +#include "mbedtls/md.h" +#include "mbedtls/md5.h" +#include "mbedtls/platform_util.h" +#include +#include +#include +#include + +#define FRAME_HEADER 18u +#define FRAME_OVERHEAD 50u +#define INNER_HEADER 15u +#define RETURN_CODE 4u +#define MAX_PLAINTEXT (TUYA_LAN_MAX_FRAME_SIZE - FRAME_OVERHEAD) +#define CMD_SECURITY_START 3u +#define CMD_SECURITY_RESPONSE 4u +#define CMD_SECURITY_FINISH 5u +#define CMD_DISCOVERY 0x23u +#define CMD_DISCOVERY_QUERY 0x25u + +#if AGENTIC_KIT_LAN_LOG_LEVEL >= LOG_WARN +#define LAN_LOGW(...) log_tag_warn("lan", __VA_ARGS__) +#else +#define LAN_LOGW(...) ((void)0) +#endif + +struct lan_protocol { + tuya_lan_config_t config; + uint32_t id; + lan_protocol_emit_t emit; + void *emit_user; + uint8_t local_key[16]; + uint8_t session_key[16]; + uint8_t rand_a[16]; + uint8_t rand_b[16]; + uint8_t rx[TUYA_LAN_MAX_FRAME_SIZE]; + size_t used; + uint32_t tx_sequence; + uint32_t rx_sequence; + bool have_rx_sequence; + bool authenticated; + unsigned int handshake_state; + int error; + uint64_t created; + uint64_t last_message; +}; + +static uint32_t get32(const uint8_t *p) +{ + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | + ((uint32_t)p[2] << 8) | (uint32_t)p[3]; +} + +static void put32(uint8_t *p, uint32_t n) +{ + p[0] = (uint8_t)(n >> 24); p[1] = (uint8_t)(n >> 16); + p[2] = (uint8_t)(n >> 8); p[3] = (uint8_t)n; +} + +static int fail(lan_protocol_t *p, int error) +{ + if (!p->error) { + p->error = error; + p->authenticated = false; + mbedtls_platform_zeroize(p->session_key, sizeof(p->session_key)); + LAN_LOGW("session %lu closed, error %d", (unsigned long)p->id, error); + } + return p->error; +} + +static int hmac(const uint8_t key[16], const uint8_t random[16], uint8_t out[32]) +{ + const mbedtls_md_info_t *md = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + return md && mbedtls_md_hmac(md, key, 16, random, 16, out) == 0 ? LAN_OK : LAN_ERR_CRYPTO; +} + +static bool equal_secret(const uint8_t *a, const uint8_t *b, size_t n) +{ + uint8_t different = 0; + size_t i; + for (i = 0; i < n; ++i) different |= (uint8_t)(a[i] ^ b[i]); + return different == 0; +} + +static int encrypt(const pal_t *pal, const uint8_t key[16], uint32_t sequence, + uint32_t command, const uint8_t *data, size_t len, + uint8_t *out, size_t capacity, size_t *out_len) +{ + mbedtls_gcm_context gcm; + int result; + if (!out || !out_len || (!data && len)) return LAN_ERR_PARAM; + if (len > MAX_PLAINTEXT || capacity < FRAME_OVERHEAD + len) return LAN_ERR_LIMIT; + memset(out, 0, FRAME_HEADER); + put32(out, 0x6699u); + put32(out + 6, sequence); + put32(out + 10, command); + put32(out + 14, (uint32_t)(28u + len)); + if (rng_bytes(pal, out + FRAME_HEADER, 12) != 0) return LAN_ERR_CRYPTO; + mbedtls_gcm_init(&gcm); + result = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key, 128); + if (!result) result = mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT, + len, out + FRAME_HEADER, 12, out + 4, 14, + data, out + FRAME_HEADER + 12, 16, + out + FRAME_HEADER + 12 + len); + mbedtls_gcm_free(&gcm); + if (result) return LAN_ERR_CRYPTO; + put32(out + FRAME_HEADER + 28 + len, 0x9966u); + *out_len = FRAME_OVERHEAD + len; + return LAN_OK; +} + +static int decrypt(const uint8_t key[16], const uint8_t *frame, size_t len, + uint8_t *out, size_t *out_len) +{ + mbedtls_gcm_context gcm; + uint32_t length; + size_t plaintext; + int result; + if (len < FRAME_OVERHEAD || len > TUYA_LAN_MAX_FRAME_SIZE || + get32(frame) != 0x6699u || frame[4] || frame[5]) return LAN_ERR_PROTOCOL; + length = get32(frame + 14); + if (length < 28u || length > TUYA_LAN_MAX_FRAME_SIZE - FRAME_HEADER - 4u || + len != (size_t)length + FRAME_HEADER + 4u || + get32(frame + len - 4u) != 0x9966u) return LAN_ERR_PROTOCOL; + plaintext = length - 28u; + mbedtls_gcm_init(&gcm); + result = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key, 128); + if (!result) result = mbedtls_gcm_auth_decrypt(&gcm, plaintext, frame + FRAME_HEADER, + 12, frame + 4, 14, frame + FRAME_HEADER + 12 + plaintext, + 16, frame + FRAME_HEADER + 12, out); + mbedtls_gcm_free(&gcm); + if (result) return LAN_ERR_AUTH; + *out_len = plaintext; + return LAN_OK; +} + +static bool json_finite(const cJSON *node) +{ + const cJSON *child; + if (cJSON_IsNumber(node) && !isfinite(node->valuedouble)) return false; + for (child = node->child; child; child = child->next) + if (!json_finite(child)) return false; + return true; +} + +/* Explicit length plus a copied terminator prevents cJSON looking into an + * adjacent frame. Exact parse-end rejects embedded NULs and trailing garbage. */ +static cJSON *parse_json(const tuya_lan_config_t *config, const char *data, size_t len) +{ + char *copy; + const char *end = NULL; + cJSON *root; + if (!data || !len || len > MAX_PLAINTEXT || !json_lexical_validate(data, len)) return NULL; + copy = config->pal->malloc(len + 1); + if (!copy) return NULL; + memcpy(copy, data, len); + copy[len] = 0; + root = cJSON_ParseWithLengthOpts(copy, len + 1, &end, true); + /* Printing a nonfinite cJSON number produces JSON null. Reject values the + * parser cannot represent before that conversion can change a DP command. */ + if (!root || end != copy + len || !json_finite(root)) { + cJSON_Delete(root); + root = NULL; + } + config->pal->free(copy); + return root; +} + +static cJSON *unique_member(cJSON *root, const char *name) +{ + cJSON *child, *found = NULL; + if (!cJSON_IsObject(root)) return NULL; + cJSON_ArrayForEach(child, root) { + if (child->string && strcmp(child->string, name) == 0) { + if (found) return NULL; + found = child; + } + } + return found; +} + +static bool state_valid(cJSON *root) +{ + return cJSON_IsObject(root) && cJSON_IsObject(unique_member(root, "dps")); +} + +int lan_protocol_validate_state(const tuya_lan_config_t *config, const char *json, size_t len) +{ + cJSON *root; + bool valid; + if (!config || !config->pal || !config->pal->malloc || !config->pal->free) + return LAN_ERR_PARAM; + root = parse_json(config, json, len); + valid = state_valid(root); + cJSON_Delete(root); + return valid ? LAN_OK : LAN_ERR_PARAM; +} + +static int send_frame(lan_protocol_t *p, uint32_t command, uint32_t return_code, + const uint8_t *data, size_t len, const uint8_t key[16]) +{ + uint8_t *frame, *plaintext; + size_t frame_len = 0; + int result; + if (p->error) return p->error; + if ((!data && len) || len > MAX_PLAINTEXT - RETURN_CODE) return LAN_ERR_LIMIT; + if (p->tx_sequence == UINT32_MAX) return fail(p, LAN_ERR_LIMIT); + frame = p->config.pal->malloc(FRAME_OVERHEAD + RETURN_CODE + len); + plaintext = p->config.pal->malloc(RETURN_CODE + len); + if (!frame || !plaintext) { + p->config.pal->free(frame); p->config.pal->free(plaintext); + return fail(p, LAN_ERR_MEMORY); + } + put32(plaintext, return_code); + if (len) memcpy(plaintext + RETURN_CODE, data, len); + result = encrypt(p->config.pal, key, p->tx_sequence++, command, plaintext, + RETURN_CODE + len, frame, FRAME_OVERHEAD + RETURN_CODE + len, + &frame_len); + if (!result) result = p->emit(p->emit_user, frame, frame_len); + mbedtls_platform_zeroize(plaintext, RETURN_CODE + len); + p->config.pal->free(plaintext); p->config.pal->free(frame); + return result ? fail(p, result < 0 ? result : LAN_ERR_NET) : LAN_OK; +} + +int lan_protocol_send(lan_protocol_t *p, uint32_t command, uint32_t return_code, + const uint8_t *data, size_t len) +{ + if (!p || (!data && len)) return LAN_ERR_PARAM; + if (p->error) return p->error; + if (!p->authenticated) return LAN_ERR_STATE; + if (command == CMD_SECURITY_START || command == CMD_SECURITY_RESPONSE || + command == CMD_SECURITY_FINISH) return LAN_ERR_PARAM; + return send_frame(p, command, return_code, data, len, p->session_key); +} + +int lan_protocol_report(lan_protocol_t *p, const char *json, size_t len) +{ + uint8_t *payload; + char prefix[96]; + int prefix_len, result; + size_t payload_len; + if (!p) return LAN_ERR_PARAM; + if (p->error) return p->error; + if (!p->authenticated) return LAN_ERR_STATE; + if (lan_protocol_validate_state(&p->config, json, len) != LAN_OK) return LAN_ERR_PARAM; + prefix_len = snprintf(prefix, sizeof(prefix), "{\"protocol\":4,\"t\":%llu,\"data\":", + (unsigned long long)(p->config.pal->wal_clock_time() / 1000u)); + if (prefix_len < 0 || (size_t)prefix_len >= sizeof(prefix)) return LAN_ERR_LIMIT; + payload_len = INNER_HEADER + (size_t)prefix_len + len + 1u; + if (payload_len > MAX_PLAINTEXT - RETURN_CODE) return LAN_ERR_LIMIT; + payload = p->config.pal->malloc(payload_len); + if (!payload) return fail(p, LAN_ERR_MEMORY); + memset(payload, 0, INNER_HEADER); + memcpy(payload, "3.5", 3); + put32(payload + 7, p->tx_sequence); + put32(payload + 11, 1); + memcpy(payload + INNER_HEADER, prefix, (size_t)prefix_len); + memcpy(payload + INNER_HEADER + prefix_len, json, len); + payload[payload_len - 1] = '}'; + result = lan_protocol_send(p, TUYA_LAN_CMD_REPORT, 0, payload, payload_len); + p->config.pal->free(payload); + return result; +} + +static int control(lan_protocol_t *p, uint32_t command, const uint8_t *data, size_t len) +{ + cJSON *root = NULL, *body, *dps; + char *dps_text = NULL; + int status = LAN_ERR_PARAM; + if (len <= INNER_HEADER || memcmp(data, "3.5", 3) != 0) goto done; + root = parse_json(&p->config, (const char *)data + INNER_HEADER, len - INNER_HEADER); + body = unique_member(root, "data"); + dps = unique_member(body, "dps"); + if (!cJSON_IsObject(dps)) goto done; + if (!p->config.on_dp) { status = LAN_ERR_NOT_SUPPORTED; goto done; } + dps_text = cJSON_PrintUnformatted(dps); + if (!dps_text) { status = LAN_ERR_MEMORY; goto done; } + status = p->config.on_dp(dps_text, strlen(dps_text), p->config.user); + done: + cJSON_free(dps_text); cJSON_Delete(root); + return lan_protocol_send(p, command, status == LAN_OK ? 0u : 1u, NULL, 0); +} + +static int query(lan_protocol_t *p, uint32_t command, const uint8_t *data, size_t len) +{ + cJSON *request; + char *output; + size_t out_len = 0; + int result; + /* Query payloads are plain JSON in TuyaOpen; tolerate the LPV35 inner + * header used by some applications without accepting arbitrary prefixes. */ + if (len >= INNER_HEADER && memcmp(data, "3.5", 3) == 0) { + data += INNER_HEADER; len -= INNER_HEADER; + } + request = parse_json(&p->config, (const char *)data, len); + if (!cJSON_IsObject(request) || !p->config.on_query) { + cJSON_Delete(request); + return lan_protocol_send(p, command, 1, NULL, 0); + } + cJSON_Delete(request); + output = p->config.pal->malloc(MAX_PLAINTEXT - RETURN_CODE); + if (!output) return fail(p, LAN_ERR_MEMORY); + result = p->config.on_query(output, MAX_PLAINTEXT - RETURN_CODE, &out_len, p->config.user); + if (!result && out_len <= MAX_PLAINTEXT - RETURN_CODE && + lan_protocol_validate_state(&p->config, output, out_len) == LAN_OK) + result = lan_protocol_send(p, command, 0, (const uint8_t *)output, out_len); + else + result = lan_protocol_send(p, command, 1, NULL, 0); + p->config.pal->free(output); + return result; +} + +static int handshake(lan_protocol_t *p, uint32_t command, const uint8_t *data, size_t len) +{ + uint8_t response[48], expected[32], mixed[16], tag[16]; + mbedtls_gcm_context gcm; + int result = LAN_ERR_AUTH; + size_t i; + if (command == CMD_SECURITY_START) { + if (p->handshake_state || p->authenticated || len != 16u) return fail(p, LAN_ERR_AUTH); + memcpy(p->rand_a, data, 16); + if (rng_bytes(p->config.pal, p->rand_b, 16) != 0) return fail(p, LAN_ERR_CRYPTO); + memcpy(response, p->rand_b, 16); + if (hmac(p->local_key, p->rand_a, response + 16)) return fail(p, LAN_ERR_CRYPTO); + p->handshake_state = 1; + result = send_frame(p, CMD_SECURITY_RESPONSE, 0, response, sizeof(response), p->local_key); + mbedtls_platform_zeroize(response, sizeof(response)); + return result; + } + if (command != CMD_SECURITY_FINISH || p->handshake_state != 1 || p->authenticated || len != 32u) + return fail(p, LAN_ERR_AUTH); + if (hmac(p->local_key, p->rand_b, expected)) return fail(p, LAN_ERR_CRYPTO); + if (!equal_secret(expected, data, sizeof(expected))) goto done; + for (i = 0; i < 16; ++i) mixed[i] = (uint8_t)(p->rand_a[i] ^ p->rand_b[i]); + mbedtls_gcm_init(&gcm); + result = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, p->local_key, 128); + if (!result) result = mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT, 16, + p->rand_a, 12, NULL, 0, mixed, p->session_key, 16, tag); + mbedtls_gcm_free(&gcm); + if (result) result = LAN_ERR_CRYPTO; + else { + p->authenticated = true; + p->handshake_state = 2; + result = LAN_OK; + } + done: + mbedtls_platform_zeroize(expected, sizeof(expected)); + mbedtls_platform_zeroize(mixed, sizeof(mixed)); + mbedtls_platform_zeroize(tag, sizeof(tag)); + if (result) return fail(p, result); + mbedtls_platform_zeroize(p->rand_a, sizeof(p->rand_a)); + mbedtls_platform_zeroize(p->rand_b, sizeof(p->rand_b)); + return LAN_OK; +} + +static int process_frame(lan_protocol_t *p, const uint8_t *frame, size_t len) +{ + uint8_t *plaintext; + uint32_t command = get32(frame + 10), sequence = get32(frame + 6); + const uint8_t *key = p->session_key; + size_t plain_len = 0; + int result; + if (command == CMD_SECURITY_START || command == CMD_SECURITY_FINISH) key = p->local_key; + else if (!p->authenticated) return fail(p, LAN_ERR_AUTH); + plaintext = p->config.pal->malloc(len - FRAME_OVERHEAD + 1); + if (!plaintext) return fail(p, LAN_ERR_MEMORY); + result = decrypt(key, frame, len, plaintext, &plain_len); + if (result) goto done; + if (p->have_rx_sequence && sequence <= p->rx_sequence) { result = LAN_ERR_AUTH; goto done; } + /* Never commit an unauthenticated sequence number. */ + p->rx_sequence = sequence; p->have_rx_sequence = true; + p->last_message = p->config.pal->time_ms(); + if (command == CMD_SECURITY_START || command == CMD_SECURITY_FINISH) + result = handshake(p, command, plaintext, plain_len); + else if (command == CMD_SECURITY_RESPONSE) result = LAN_ERR_AUTH; + else if (command == TUYA_LAN_CMD_CONTROL || command == TUYA_LAN_CMD_CONTROL_NEW) + result = control(p, command, plaintext, plain_len); + else if (command == TUYA_LAN_CMD_QUERY || command == TUYA_LAN_CMD_QUERY_NEW) + result = query(p, command, plaintext, plain_len); + else if (command == TUYA_LAN_CMD_HEARTBEAT) + result = lan_protocol_send(p, command, 0, NULL, 0); + else if (p->config.on_command) + result = p->config.on_command(p->id, command, plaintext, plain_len, p->config.user); + else result = lan_protocol_send(p, command, 1, NULL, 0); + done: + mbedtls_platform_zeroize(plaintext, len - FRAME_OVERHEAD + 1); + p->config.pal->free(plaintext); + return result ? fail(p, result < 0 ? result : LAN_ERR_PROTOCOL) : LAN_OK; +} + +int lan_protocol_create(const tuya_lan_config_t *config, uint32_t id, + lan_protocol_emit_t emit, void *user, lan_protocol_t **out) +{ + lan_protocol_t *p; + uint8_t random[2]; + if (out) *out = NULL; + if (!config || !out || !emit || !config->pal || !config->pal->malloc || + !config->pal->free || !config->pal->time_ms || !config->pal->wal_clock_time || + !config->local_key || strlen(config->local_key) != 16u) return LAN_ERR_PARAM; + if (rng_bytes(config->pal, random, sizeof(random)) != 0) return LAN_ERR_CRYPTO; + p = config->pal->malloc(sizeof(*p)); + if (!p) return LAN_ERR_MEMORY; + memset(p, 0, sizeof(*p)); + p->config = *config; p->id = id; p->emit = emit; p->emit_user = user; + memcpy(p->local_key, config->local_key, 16); + p->tx_sequence = ((uint32_t)random[0] << 8) | random[1]; + p->created = p->last_message = config->pal->time_ms(); + *out = p; + return LAN_OK; +} + +void lan_protocol_destroy(lan_protocol_t *p) +{ + const pal_t *pal; + if (!p) return; + pal = p->config.pal; + mbedtls_platform_zeroize(p, sizeof(*p)); + pal->free(p); +} + +int lan_protocol_tick(lan_protocol_t *p) +{ + uint64_t now, since; + uint32_t timeout; + if (!p) return LAN_ERR_PARAM; + if (p->error) return p->error; + now = p->config.pal->time_ms(); + since = p->authenticated ? p->last_message : p->created; + timeout = p->authenticated ? p->config.heartbeat_timeout_ms : p->config.handshake_timeout_ms; + if (timeout && now - since >= timeout) return fail(p, LAN_ERR_TIMEOUT); + return LAN_OK; +} + +bool lan_protocol_authenticated(const lan_protocol_t *p) +{ + return p && p->authenticated && !p->error; +} + +int lan_protocol_feed(lan_protocol_t *p, const uint8_t *bytes, size_t len) +{ + int status; + if (!p || (!bytes && len)) return LAN_ERR_PARAM; + if (p->error) return p->error; + status = lan_protocol_tick(p); + if (status) return status; + while (len) { + size_t target, amount; + uint32_t length; + if (p->used < FRAME_HEADER) target = FRAME_HEADER; + else { + length = get32(p->rx + 14); + if (get32(p->rx) != 0x6699u || p->rx[4] || p->rx[5] || length < 28u || + length > TUYA_LAN_MAX_FRAME_SIZE - FRAME_HEADER - 4u) + return fail(p, LAN_ERR_PROTOCOL); + target = FRAME_HEADER + (size_t)length + 4u; + } + amount = target - p->used; + if (amount > len) amount = len; + memcpy(p->rx + p->used, bytes, amount); + p->used += amount; bytes += amount; len -= amount; + if (p->used >= FRAME_HEADER) { + length = get32(p->rx + 14); + if (get32(p->rx) != 0x6699u || p->rx[4] || p->rx[5] || length < 28u || + length > TUYA_LAN_MAX_FRAME_SIZE - FRAME_HEADER - 4u) + return fail(p, LAN_ERR_PROTOCOL); + target = FRAME_HEADER + (size_t)length + 4u; + if (p->used == target) { + int result = process_frame(p, p->rx, target); + p->used = 0; + if (result) return result; + } + } + } + return LAN_OK; +} + +static int discovery_key(uint8_t key[16]) +{ + static const uint8_t seed[] = "yGAdlopoPVldABfn"; + return mbedtls_md5(seed, sizeof(seed) - 1, key) ? LAN_ERR_CRYPTO : LAN_OK; +} + +static bool add_string(cJSON *root, const char *name, const char *value) +{ + return value && cJSON_AddStringToObject(root, name, value) != NULL; +} + +int lan_discovery_build(const tuya_lan_config_t *config, uint8_t *out, + size_t capacity, size_t *len) +{ + cJSON *root; + char *json = NULL; + uint8_t key[16], *plaintext = NULL; + size_t json_len; + int result = LAN_ERR_MEMORY; + if (len) *len = 0; + if (!config || !config->pal || !config->pal->malloc || !config->pal->free || + !out || !len || !config->device_id || !config->uuid || !config->ipv4 || + !config->product_key) return LAN_ERR_PARAM; + root = cJSON_CreateObject(); + if (!root) return LAN_ERR_MEMORY; + if (!add_string(root, "ip", config->ipv4) || !add_string(root, "gwId", config->device_id) || + !add_string(root, "uuid", config->uuid) || !add_string(root, "productKey", config->product_key) || + !add_string(root, "version", "3.5") || !cJSON_AddNumberToObject(root, "active", 2) || + !cJSON_AddNumberToObject(root, "ablilty", 0) || !cJSON_AddBoolToObject(root, "encrypt", true) || + !cJSON_AddNumberToObject(root, "sl", 1)) goto done; + json = cJSON_PrintUnformatted(root); + if (!json) goto done; + json_len = strlen(json); + if (json_len > MAX_PLAINTEXT - RETURN_CODE) { result = LAN_ERR_LIMIT; goto done; } + plaintext = config->pal->malloc(json_len + RETURN_CODE); + if (!plaintext) goto done; + put32(plaintext, 0); + memcpy(plaintext + RETURN_CODE, json, json_len); + result = discovery_key(key); + if (!result) result = encrypt(config->pal, key, 0, CMD_DISCOVERY, plaintext, + json_len + RETURN_CODE, out, capacity, len); + done: + mbedtls_platform_zeroize(key, sizeof(key)); + config->pal->free(plaintext); cJSON_free(json); cJSON_Delete(root); + return result; +} + +int lan_discovery_parse(const tuya_lan_config_t *config, const uint8_t *data, + size_t len, char *ipv4, size_t capacity) +{ + uint8_t key[16], *plaintext; + size_t plain_len = 0, ip_len; + cJSON *root = NULL, *ip, *from; + int result; + if (!config || !config->pal || !config->pal->malloc || !config->pal->free || + !data || !ipv4 || !capacity) return LAN_ERR_PARAM; + if (len < FRAME_OVERHEAD || len > TUYA_LAN_MAX_FRAME_SIZE || + get32(data + 10) != CMD_DISCOVERY_QUERY) return LAN_ERR_PROTOCOL; + plaintext = config->pal->malloc(len - FRAME_OVERHEAD + 1); + if (!plaintext) return LAN_ERR_MEMORY; + result = discovery_key(key); + if (!result) result = decrypt(key, data, len, plaintext, &plain_len); + if (result) goto done; + root = parse_json(config, (const char *)plaintext, plain_len); + ip = unique_member(root, "ip"); from = unique_member(root, "from"); + if (!cJSON_IsString(ip) || !ip->valuestring || !from) { result = LAN_ERR_PROTOCOL; goto done; } + ip_len = strlen(ip->valuestring); + if (!ip_len || ip_len >= capacity) { result = LAN_ERR_LIMIT; goto done; } + memcpy(ipv4, ip->valuestring, ip_len + 1); + done: + mbedtls_platform_zeroize(key, sizeof(key)); + mbedtls_platform_zeroize(plaintext, len - FRAME_OVERHEAD + 1); + config->pal->free(plaintext); cJSON_Delete(root); + return result; +} diff --git a/modules/tuya-lan/src/tuya_lan_protocol.h b/modules/tuya-lan/src/tuya_lan_protocol.h new file mode 100644 index 0000000..3c859de --- /dev/null +++ b/modules/tuya-lan/src/tuya_lan_protocol.h @@ -0,0 +1,26 @@ +#ifndef TUYA_LAN_PROTOCOL_H +#define TUYA_LAN_PROTOCOL_H +#include "tuya_lan.h" + +typedef struct lan_protocol lan_protocol_t; +/* Must COPY frame bytes before returning. Nonzero return closes this session. */ +typedef int (*lan_protocol_emit_t)(void *user, const uint8_t *frame, size_t len); +int lan_protocol_create(const tuya_lan_config_t *config, uint32_t session_id, + lan_protocol_emit_t emit, void *user, lan_protocol_t **out); +void lan_protocol_destroy(lan_protocol_t *protocol); +int lan_protocol_feed(lan_protocol_t *protocol, const uint8_t *bytes, size_t len); +int lan_protocol_tick(lan_protocol_t *protocol); +bool lan_protocol_authenticated(const lan_protocol_t *protocol); +int lan_protocol_report(lan_protocol_t *protocol, const char *json, size_t len); +int lan_protocol_send(lan_protocol_t *protocol, uint32_t command, uint32_t return_code, + const uint8_t *data, size_t len); +/* Both functions use caller-owned bounded buffers. Discovery input is verified + * with the published discovery key, never trusted as a network destination. + * parse returns the advertised IPv4 only for source-address comparison. */ +int lan_discovery_build(const tuya_lan_config_t *config, uint8_t *out, + size_t capacity, size_t *len); +int lan_discovery_parse(const tuya_lan_config_t *config, const uint8_t *data, + size_t len, char *ipv4, size_t capacity); +int lan_protocol_validate_state(const tuya_lan_config_t *config, const char *json, + size_t len); +#endif diff --git a/modules/tuya-lan/test/test_peer.py b/modules/tuya-lan/test/test_peer.py new file mode 100644 index 0000000..ccd6078 --- /dev/null +++ b/modules/tuya-lan/test/test_peer.py @@ -0,0 +1,269 @@ +"""Independent LAN 3.5 peer against the real socket service and IoT DP registry. + +Uses Python/PyCryptodome, not the production frame encoder or protocol helpers. +All sockets are loopback with ephemeral ports. No cloud credentials are needed. +""" +import hashlib +import hmac +import json +import os +import re +import socket +import struct +import subprocess +import sys +import tempfile +import time +import unittest +from Crypto.Cipher import AES + +KEY = b"0123456789abcdef" +DISCOVERY_KEY = hashlib.md5(b"yGAdlopoPVldABfn").digest() +HEAD = struct.Struct("!IHIII") + + +def encode(key, sequence, command, payload): + header = HEAD.pack(0x6699, 0, sequence, command, 28 + len(payload)) + nonce = os.urandom(12) + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + cipher.update(header[4:]) + encrypted, tag = cipher.encrypt_and_digest(payload) + return header + nonce + encrypted + tag + b"\0\0\x99\x66" + + +def decode(key, packet): + magic, reserved, seq, command, length = HEAD.unpack(packet[:18]) + assert magic == 0x6699 and reserved == 0 and len(packet) == 22 + length + assert packet[-4:] == b"\0\0\x99\x66" and 28 <= length <= 4096 + cipher = AES.new(key, AES.MODE_GCM, nonce=packet[18:30]) + cipher.update(packet[4:18]) + return seq, command, cipher.decrypt_and_verify(packet[30:-20], packet[-20:-4]) + + +def read_exact(sock, length): + result = bytearray() + while len(result) < length: + data = sock.recv(length - len(result)) + if not data: + raise AssertionError("unexpected EOF") + result.extend(data) + return bytes(result) + + +class Peer: + def __init__(self, port): + self.socket = socket.create_connection(("127.0.0.1", port), timeout=2) + self.socket.settimeout(2) + self.sequence = 0 + self.key = KEY + + def close(self): + self.socket.close() + + def packet(self, command, data=b""): + self.sequence += 1 + return encode(self.key, self.sequence, command, data) + + def send(self, command, data=b""): + self.socket.sendall(self.packet(command, data)) + + def receive(self): + header = read_exact(self.socket, 18) + length = HEAD.unpack(header)[4] + assert 28 <= length <= 4096 + _, command, payload = decode(self.key, header + read_exact(self.socket, length + 4)) + assert len(payload) >= 4 + return command, struct.unpack("!I", payload[:4])[0], payload[4:] + + def handshake(self, fragment=False): + rand_a = os.urandom(16) + request = self.packet(3, rand_a) + if fragment: + for chunk in (request[:2], request[2:17], request[17:31], request[31:]): + self.socket.sendall(chunk) + else: + self.socket.sendall(request) + command, ret, response = self.receive() + assert command == 4 and ret == 0 and len(response) == 48 + rand_b = response[:16] + assert hmac.compare_digest(response[16:], hmac.digest(KEY, rand_a, "sha256")) + self.send(5, hmac.digest(KEY, rand_b, "sha256")) + cipher = AES.new(KEY, AES.MODE_GCM, nonce=rand_a[:12]) + self.key = cipher.encrypt(bytes(a ^ b for a, b in zip(rand_a, rand_b))) + + def query(self): + self.send(0x10, b"{}") + command, ret, data = self.receive() + assert command == 0x10 and ret == 0 + return json.loads(data) + + +class LANPeerTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.logs = tempfile.TemporaryFile(mode="w+b") + cls.addClassCleanup(cls.logs.close) + env = dict(os.environ, TUYA_DEVICE_ID="lan-wire-test-device", + TUYA_LOCAL_KEY=KEY.decode(), TUYA_UUID="0123456789abcdef", + TUYA_PRODUCT_KEY="fedcba9876543210") + cls.device = subprocess.Popen([sys.argv[1], "127.0.0.1", "0", "0"], + env=env, stdout=cls.logs, stderr=cls.logs) + cls.addClassCleanup(cls.shutdown) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + cls.logs.seek(0) + logs = cls.logs.read().decode(errors="replace") + match = re.search(r"LAN listening: TCP (\d+), UDP (\d+)", logs) + if match: + cls.tcp_port, cls.udp_port = map(int, match.groups()) + return + if cls.device.poll() is not None: + raise AssertionError("device exited during startup:\n" + logs) + time.sleep(0.01) + raise AssertionError("device never started:\n" + logs) + + @classmethod + def shutdown(cls): + if cls.device.poll() is None: + cls.device.terminate() + try: + cls.device.wait(timeout=5) + except subprocess.TimeoutExpired: + cls.device.kill() + cls.device.wait(timeout=5) + + def peer(self): + peer = Peer(self.tcp_port) + self.addCleanup(peer.close) + return peer + + def assert_closed(self, peer): + try: + self.assertEqual(peer.socket.recv(1), b"") + except ConnectionResetError: + pass + + def test_control_updates_shared_dp_and_explicit_report(self): + peer = self.peer() + peer.handshake() + body = json.dumps({"protocol": 5, "data": {"dps": {"1": True}}}).encode() + peer.send(0x0D, b"3.5" + b"\0" * 12 + body) + frames = [peer.receive(), peer.receive()] + ack = next(frame for frame in frames if frame[0] == 0x0D) + report = next(frame for frame in frames if frame[0] == 0x08) + self.assertEqual(ack[1], 0) + self.assertEqual(report[1], 0) + self.assertEqual(report[2][:3], b"3.5") + envelope = json.loads(report[2][15:]) + self.assertEqual(envelope["data"]["dps"], {"1": True}) + self.assertEqual(peer.query()["dps"], {"1": True}) + + def test_invalid_batch_does_not_change_dp(self): + peer = self.peer() + peer.handshake() + before = peer.query() + body = b'{"data":{"dps":{"1":false,"255":1}}}' + peer.send(7, b"3.5" + b"\0" * 12 + body) + command, ret, _ = peer.receive() + self.assertEqual(command, 7) + self.assertNotEqual(ret, 0) + self.assertEqual(peer.query(), before) + + def test_fragmented_handshake_and_coalesced_messages(self): + peer = self.peer() + peer.handshake(fragment=True) + peer.socket.sendall(peer.packet(9) + peer.packet(0x10, b"{}")) + first, second = peer.receive(), peer.receive() + self.assertEqual((first[0], first[1], second[0], second[1]), (9, 0, 0x10, 0)) + + def test_escaped_nul_cannot_be_normalized_into_dp_id(self): + peer = self.peer() + peer.handshake() + before = peer.query() + peer.send(0x0D, b"3.5" + b"\0" * 12 + b'{"data":{"dps":{"1\\u0000x":true}}}') + command, ret, _ = peer.receive() + self.assertEqual(command, 0x0D) + self.assertNotEqual(ret, 0) + self.assertEqual(peer.query(), before) + + def test_query_unicode_lexical_contract(self): + peer = self.peer() + peer.handshake() + before = peer.query() + # Query metadata exercises authenticated JSON without requiring a string + # DP in the demo's product schema. Malformed Unicode must be rejected + # before the application callback; the Session remains usable. + invalid = ( + b"\xc0\x80", b"\xe0\x9f\xbf", b"\xf0\x8f\xbf\xbf", # overlong + b"\xed\xa0\x80", b"\xed\xbf\xbf", # UTF-16 surrogate range + b"\xf4\x90\x80\x80", b"\xf5\x80\x80\x80", # above U+10FFFF + b"\xe1\x80x", b"\x80", b"\xf0\x90\x80", # bad/truncated tails + b"\\uD800", b"\\uDC00", b"\\uD800\\u0041", b"\\u0000", + ) + for value in invalid: + with self.subTest(invalid=value): + peer.send(0x10, b'{"note":"' + value + b'"}') + command, ret, _ = peer.receive() + self.assertEqual(command, 0x10) + self.assertNotEqual(ret, 0) + self.assertEqual(peer.query(), before) + valid = ( + "¢".encode(), "中".encode(), "😀".encode(), + b"\xed\x9f\xbf", b"\xf4\x8f\xbf\xbf", # scalar boundaries + b"\\uD83D\\uDE00", b"\\\\u0000", # pair and literal escape text + ) + for value in valid: + with self.subTest(valid=value): + peer.send(0x10, b'{"note":"' + value + b'"}') + command, ret, data = peer.receive() + self.assertEqual((command, ret), (0x10, 0)) + self.assertEqual(json.loads(data), before) + + def test_control_requires_handshake(self): + peer = self.peer() + peer.send(9) + self.assert_closed(peer) + + def test_bad_tag_closes_only_its_session(self): + bad, good = self.peer(), self.peer() + bad.handshake() + good.handshake() + frame = bytearray(bad.packet(9)) + frame[-5] ^= 1 + bad.socket.sendall(frame) + self.assert_closed(bad) + self.assertIn("dps", good.query()) + + def test_replayed_frame_closes_session(self): + peer = self.peer() + peer.handshake() + packet = peer.packet(9) + peer.socket.sendall(packet) + self.assertEqual(peer.receive()[:2], (9, 0)) + peer.socket.sendall(packet) + self.assert_closed(peer) + + def test_oversized_frame_is_rejected_before_body(self): + peer = self.peer() + peer.socket.sendall(HEAD.pack(0x6699, 0, 1, 3, 0x7FFFFFFF)) + self.assert_closed(peer) + + def test_udp_discovery_uses_real_source(self): + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp: + udp.bind(("127.0.0.1", 0)) + udp.settimeout(2) + request = encode(DISCOVERY_KEY, 1, 0x25, b'{"ip":"127.0.0.1","from":"app"}') + udp.sendto(request, ("127.0.0.1", self.udp_port)) + response, _ = udp.recvfrom(4096) + _, command, data = decode(DISCOVERY_KEY, response) + self.assertEqual(command, 0x23) + self.assertEqual(data[:4], b"\0" * 4) + discovery = json.loads(data[4:]) + self.assertEqual(discovery["gwId"], "lan-wire-test-device") + self.assertEqual(discovery["version"], "3.5") + self.assertTrue(discovery["encrypt"]) + + +if __name__ == "__main__": + # Keep the executable path out of unittest's argument parser. + unittest.main(argv=[sys.argv[0]], verbosity=2) diff --git a/modules/tuya-lan/test/test_protocol.c b/modules/tuya-lan/test/test_protocol.c new file mode 100644 index 0000000..f83f9bd --- /dev/null +++ b/modules/tuya-lan/test/test_protocol.c @@ -0,0 +1,586 @@ +/* Independent encrypted peer: wire construction does not use SDK encoders. */ +#include "tuya_lan_protocol.h" +#include "rng.h" +#include "cJSON.h" +#include "mbedtls/gcm.h" +#include "mbedtls/md.h" +#include "mbedtls/md5.h" +#include +#include +#include + +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "%s:%d: %s\n", __func__, __LINE__, #x); return 1; } } while (0) +#define RUN(fn) do { ++total; if (fn()) ++failed; else ++passed; } while (0) +#define WIRE_MAX TUYA_LAN_MAX_FRAME_SIZE + +static uint64_t now_ms; +static unsigned random_counter; +static int random_failure; +static int allocation_failure; +static unsigned allocations; +static const uint8_t local_key[17] = "0123456789abcdef"; +static unsigned passed, failed, total; + +/* The test replaces the shared DRBG to make entropy failures and binary keys + * reproducible. Encryption/HMAC remain the real independently invoked mbedTLS. */ +int rng_bytes(const pal_t *pal, uint8_t *data, size_t len) +{ + size_t i; + (void)pal; + if (random_failure) return -1; + for (i = 0; i < len; ++i) data[i] = (uint8_t)random_counter++; + return 0; +} +static void *test_malloc(size_t n) +{ + void *p; + if (allocation_failure) return NULL; + p = malloc(n); + if (p) ++allocations; + return p; +} +static void test_free(void *p) { if (p) { --allocations; free(p); } } +static uint64_t test_time(void) { return now_ms; } +static uint64_t test_wall(void) { return 1700000000123ULL; } +static const pal_t pal = {.malloc = test_malloc, .free = test_free, + .time_ms = test_time, .wal_clock_time = test_wall}; +static uint32_t be32(const uint8_t *p) +{ + return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3]; +} +static void store32(uint8_t *p, uint32_t n) +{ + p[0] = (uint8_t)(n >> 24); p[1] = (uint8_t)(n >> 16); + p[2] = (uint8_t)(n >> 8); p[3] = (uint8_t)n; +} + +static size_t peer_encode(const uint8_t key[16], uint32_t sequence, uint32_t command, + const uint8_t *plaintext, size_t len, uint8_t *frame) +{ + mbedtls_gcm_context gcm; + int status; + size_t i; + memset(frame, 0, 18); + store32(frame, 0x6699); + store32(frame + 6, sequence); + store32(frame + 10, command); + store32(frame + 14, (uint32_t)(len + 28)); + for (i = 0; i < 12; ++i) frame[18 + i] = (uint8_t)(i + sequence * 13u); + mbedtls_gcm_init(&gcm); + status = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key, 128); + if (!status) status = mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT, + len, frame + 18, 12, frame + 4, 14, plaintext, + frame + 30, 16, frame + 30 + len); + mbedtls_gcm_free(&gcm); + if (status) return 0; + store32(frame + 46 + len, 0x9966); + return len + 50; +} + +static int peer_decode(const uint8_t key[16], const uint8_t *frame, size_t len, + uint8_t *plaintext, size_t *plain_len) +{ + mbedtls_gcm_context gcm; + int status; + if (len < 50 || be32(frame) != 0x6699 || be32(frame + len - 4) != 0x9966 || + be32(frame + 14) != len - 22) return -1; + *plain_len = len - 50; + mbedtls_gcm_init(&gcm); + status = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key, 128); + if (!status) status = mbedtls_gcm_auth_decrypt(&gcm, *plain_len, frame + 18, 12, + frame + 4, 14, frame + len - 20, 16, frame + 30, plaintext); + mbedtls_gcm_free(&gcm); + return status; +} + +static int peer_hmac(const uint8_t random[16], uint8_t out[32]) +{ + return mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), + local_key, 16, random, 16, out); +} +static int peer_session_key(const uint8_t a[16], const uint8_t b[16], uint8_t out[16]) +{ + mbedtls_gcm_context gcm; + uint8_t mixed[16], tag[16]; + size_t i; + int status; + for (i = 0; i < 16; ++i) mixed[i] = a[i] ^ b[i]; + mbedtls_gcm_init(&gcm); + status = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, local_key, 128); + if (!status) status = mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT, + 16, a, 12, NULL, 0, mixed, out, 16, tag); + mbedtls_gcm_free(&gcm); + return status; +} + +struct fixture { + tuya_lan_config_t config; + lan_protocol_t *protocol; + uint8_t frame[WIRE_MAX], plain[WIRE_MAX], key[16], a[16], b[16]; + size_t frame_len; + unsigned emissions, dp_calls, query_calls, command_calls; + int emit_error, dp_result, query_mode; + uint32_t command, id; + char dps[128]; + uint8_t opaque[128]; + size_t opaque_len; +}; +static int capture(void *user, const uint8_t *data, size_t len) +{ + struct fixture *f = user; + if (f->emit_error) return f->emit_error; + if (len > sizeof(f->frame)) return LAN_ERR_LIMIT; + memcpy(f->frame, data, len); f->frame_len = len; ++f->emissions; + return 0; +} +static int dp(const char *json, size_t len, void *user) +{ + struct fixture *f = user; + ++f->dp_calls; + if (len >= sizeof(f->dps)) return -1; + memcpy(f->dps, json, len); f->dps[len] = 0; + return f->dp_result; +} +static int query(char *json, size_t capacity, size_t *len, void *user) +{ + const char *state = "{\"dps\":{\"1\":true,\"2\":42}}"; + struct fixture *f = user; + ++f->query_calls; + if (f->query_mode == 1) { *len = capacity + 1; return 0; } + if (f->query_mode == 2) state = "{\"dps\":false}"; + *len = strlen(state); + if (*len > capacity) return -1; + memcpy(json, state, *len); + return 0; +} +static int command(uint32_t id, uint32_t cmd, const uint8_t *data, size_t len, void *user) +{ + struct fixture *f = user; + if (len > sizeof(f->opaque)) return -1; + ++f->command_calls; f->command = cmd; f->id = id; + memcpy(f->opaque, data, len); f->opaque_len = len; + return 0; +} +static int init(struct fixture *f) +{ + memset(f, 0, sizeof(*f)); + random_counter = 0; random_failure = allocation_failure = 0; now_ms = 0; + f->config.pal = &pal; + f->config.device_id = "device123"; f->config.uuid = "uuid123"; + f->config.product_key = "pk123"; f->config.local_key = (const char *)local_key; + f->config.ipv4 = "192.168.1.9"; + f->config.handshake_timeout_ms = 10000; f->config.heartbeat_timeout_ms = 30000; + f->config.on_dp = dp; f->config.on_query = query; f->config.on_command = command; + f->config.user = f; + memset(f->a, 0x37, sizeof(f->a)); + return lan_protocol_create(&f->config, 123, capture, f, &f->protocol); +} +static void cleanup(struct fixture *f) +{ + allocation_failure = random_failure = 0; + lan_protocol_destroy(f->protocol); f->protocol = NULL; +} +static int exchange(struct fixture *f, uint32_t sequence, uint32_t cmd, + const uint8_t *data, size_t len) +{ + uint8_t encoded[WIRE_MAX]; + size_t n = peer_encode(f->key, sequence, cmd, data, len, encoded); + return n ? lan_protocol_feed(f->protocol, encoded, n) : -100; +} +static int authenticate(struct fixture *f, int fragmented) +{ + uint8_t encoded[128], expected[32]; + size_t n, plain_len, i; + int status; + n = peer_encode(local_key, 1, 3, f->a, 16, encoded); + if (fragmented) { + for (i = 0; i < n; ++i) if (lan_protocol_feed(f->protocol, encoded + i, 1)) return -1; + } else if (lan_protocol_feed(f->protocol, encoded, n)) return -2; + if (f->emissions != 1 || be32(f->frame + 10) != 4) return -3; + if (peer_decode(local_key, f->frame, f->frame_len, f->plain, &plain_len) || + plain_len != 52 || be32(f->plain) != 0) return -4; + memcpy(f->b, f->plain + 4, 16); + if (peer_hmac(f->a, expected) || memcmp(expected, f->plain + 20, 32)) return -5; + if (peer_hmac(f->b, expected) || peer_session_key(f->a, f->b, f->key)) return -6; + n = peer_encode(local_key, 2, 5, expected, 32, encoded); + status = lan_protocol_feed(f->protocol, encoded, n); + return status ? status : (lan_protocol_authenticated(f->protocol) ? 0 : -7); +} +static int ack(struct fixture *f, uint32_t cmd, uint32_t code) +{ + size_t n; + CHECK(be32(f->frame + 10) == cmd); + CHECK(peer_decode(f->key, f->frame, f->frame_len, f->plain, &n) == 0); + CHECK(n >= 4 && be32(f->plain) == code); + return 0; +} +static size_t inner(uint8_t *data, const char *json) +{ + memset(data, 0, 15); memcpy(data, "3.5", 3); + store32(data + 11, 2); memcpy(data + 15, json, strlen(json)); + return 15 + strlen(json); +} + +static int test_authenticated_control_query_report(void) +{ + struct fixture f; + uint8_t data[512]; + size_t n, length; + const char *state = "{\"dps\":{\"1\":false}}"; + cJSON *root; + CHECK(init(&f) == 0); + CHECK(!lan_protocol_authenticated(f.protocol)); + CHECK(authenticate(&f, 1) == 0); + CHECK(exchange(&f, 3, 9, NULL, 0) == 0); CHECK(ack(&f, 9, 0) == 0); + n = inner(data, "{\"protocol\":5,\"data\":{\"dps\":{\"1\":true,\"2\":42}}}"); + CHECK(exchange(&f, 4, 7, data, n) == 0); CHECK(ack(&f, 7, 0) == 0); + CHECK(f.dp_calls == 1 && strcmp(f.dps, "{\"1\":true,\"2\":42}") == 0); + f.dp_result = -9; + CHECK(exchange(&f, 5, 13, data, n) == 0); CHECK(ack(&f, 13, 1) == 0); + CHECK(exchange(&f, 6, 10, (const uint8_t *)"{}", 2) == 0); + CHECK(ack(&f, 10, 0) == 0 && f.query_calls == 1); + CHECK(peer_decode(f.key, f.frame, f.frame_len, f.plain, &length) == 0); + f.plain[length] = 0; + CHECK(strcmp((const char *)f.plain + 4, "{\"dps\":{\"1\":true,\"2\":42}}") == 0); + n = inner(data, "{}"); + CHECK(exchange(&f, 7, 16, data, n) == 0); CHECK(ack(&f, 16, 0) == 0); + CHECK(lan_protocol_report(f.protocol, state, strlen(state)) == 0); + CHECK(ack(&f, 8, 0) == 0); + CHECK(peer_decode(f.key, f.frame, f.frame_len, f.plain, &length) == 0); + CHECK(length > 19 && memcmp(f.plain + 4, "3.5", 3) == 0 && be32(f.plain + 15) == 1); + f.plain[length] = 0; + root = cJSON_Parse((const char *)f.plain + 19); + CHECK(root && cJSON_GetObjectItemCaseSensitive(root, "protocol")->valueint == 4); + CHECK(cJSON_GetObjectItemCaseSensitive(root, "t")->valuedouble == 1700000000.0); + CHECK(cJSON_IsObject(cJSON_GetObjectItemCaseSensitive(root, "data"))); + cJSON_Delete(root); + CHECK(exchange(&f, 8, 0x20, (const uint8_t *)"\0\1\2\3", 4) == 0); + CHECK(f.command_calls == 1 && f.command == 0x20 && f.id == 123 && f.opaque_len == 4); + CHECK(memcmp(f.opaque, "\0\1\2\3", 4) == 0); + CHECK(lan_protocol_send(f.protocol, 0x20, 0, (const uint8_t *)"reply", 5) == 0); + CHECK(ack(&f, 0x20, 0) == 0); + cleanup(&f); CHECK(allocations == 0); return 0; +} + +static int test_coalesced_frames(void) +{ + struct fixture f; + uint8_t frames[256]; + size_t n, next; + CHECK(init(&f) == 0 && authenticate(&f, 0) == 0); + n = peer_encode(f.key, 3, 9, NULL, 0, frames); + next = peer_encode(f.key, 4, 10, (const uint8_t *)"{}", 2, frames + n); + CHECK(lan_protocol_feed(f.protocol, frames, n + next) == 0); + CHECK(f.emissions == 3 && f.query_calls == 1 && ack(&f, 10, 0) == 0); + cleanup(&f); CHECK(allocations == 0); return 0; +} + +static int test_replay_and_bad_tag(void) +{ + struct fixture f; + uint8_t frame[256]; + size_t n; + CHECK(init(&f) == 0 && authenticate(&f, 0) == 0); + n = peer_encode(f.key, 3, 9, NULL, 0, frame); + CHECK(lan_protocol_feed(f.protocol, frame, n) == 0); + CHECK(lan_protocol_feed(f.protocol, frame, n) == LAN_ERR_AUTH); + CHECK(!lan_protocol_authenticated(f.protocol)); + CHECK(exchange(&f, 4, 9, NULL, 0) == LAN_ERR_AUTH); + cleanup(&f); CHECK(allocations == 0); + CHECK(init(&f) == 0 && authenticate(&f, 0) == 0); + n = peer_encode(f.key, 3, 9, NULL, 0, frame); + frame[n - 5] ^= 1; + CHECK(lan_protocol_feed(f.protocol, frame, n) == LAN_ERR_AUTH); + CHECK(f.emissions == 1 && !lan_protocol_authenticated(f.protocol)); + cleanup(&f); CHECK(allocations == 0); return 0; +} + +static int test_unauthenticated_and_out_of_order_handshake(void) +{ + struct fixture f; + uint8_t frame[256], mac[32] = {0}; + size_t n; + CHECK(init(&f) == 0); + CHECK(lan_protocol_report(f.protocol, "{\"dps\":{}}", 10) == LAN_ERR_STATE); + n = peer_encode(local_key, 1, 7, (const uint8_t *)"{}", 2, frame); + CHECK(lan_protocol_feed(f.protocol, frame, n) == LAN_ERR_AUTH); + CHECK(f.dp_calls == 0 && f.emissions == 0); + cleanup(&f); CHECK(allocations == 0); + CHECK(init(&f) == 0); + n = peer_encode(local_key, 1, 5, mac, 32, frame); + CHECK(lan_protocol_feed(f.protocol, frame, n) == LAN_ERR_AUTH); + cleanup(&f); CHECK(allocations == 0); + CHECK(init(&f) == 0); + n = peer_encode(local_key, 1, 3, f.a, 16, frame); + CHECK(lan_protocol_feed(f.protocol, frame, n) == 0); + n = peer_encode(local_key, 2, 3, f.a, 16, frame); + CHECK(lan_protocol_feed(f.protocol, frame, n) == LAN_ERR_AUTH); + cleanup(&f); CHECK(allocations == 0); + CHECK(init(&f) == 0); + n = peer_encode(local_key, 1, 3, f.a, 16, frame); + CHECK(lan_protocol_feed(f.protocol, frame, n) == 0); + n = peer_encode(local_key, 2, 5, mac, 32, frame); + CHECK(lan_protocol_feed(f.protocol, frame, n) == LAN_ERR_AUTH); + cleanup(&f); CHECK(allocations == 0); return 0; +} + +static int test_zero_leading_session_key(void) +{ + struct fixture f; + unsigned trial; + size_t i; + CHECK(init(&f) == 0); + for (i = 0; i < 16; ++i) f.b[i] = (uint8_t)(i + 2); + for (trial = 0; trial < 65536; ++trial) { + f.a[0] = (uint8_t)(trial >> 8); f.a[1] = (uint8_t)trial; + CHECK(peer_session_key(f.a, f.b, f.key) == 0); + if (!f.key[0]) break; + } + CHECK(trial < 65536); + CHECK(authenticate(&f, 0) == 0 && f.key[0] == 0); + CHECK(exchange(&f, 3, 9, NULL, 0) == 0 && ack(&f, 9, 0) == 0); + CHECK(lan_protocol_report(f.protocol, "{\"dps\":{}}", 10) == 0); + CHECK(ack(&f, 8, 0) == 0); + cleanup(&f); CHECK(allocations == 0); return 0; +} + +static int test_bad_lengths_headers_and_partial_timeout(void) +{ + struct fixture f; + uint8_t frame[256]; + size_t n; + CHECK(init(&f) == 0); + n = peer_encode(local_key, 1, 3, f.a, 16, frame); + CHECK(lan_protocol_feed(f.protocol, frame, 17) == 0); + now_ms = 9999; CHECK(lan_protocol_tick(f.protocol) == 0); + now_ms = 10000; CHECK(lan_protocol_tick(f.protocol) == LAN_ERR_TIMEOUT); + cleanup(&f); CHECK(allocations == 0); + CHECK(init(&f) == 0); + store32(frame + 14, UINT32_MAX); + CHECK(lan_protocol_feed(f.protocol, frame, 18) == LAN_ERR_PROTOCOL); + cleanup(&f); CHECK(allocations == 0); + CHECK(init(&f) == 0); + n = peer_encode(local_key, 1, 3, f.a, 16, frame); + frame[n - 1] ^= 1; + CHECK(lan_protocol_feed(f.protocol, frame, n) == LAN_ERR_PROTOCOL); + cleanup(&f); CHECK(allocations == 0); + CHECK(init(&f) == 0); + n = peer_encode(local_key, 1, 3, f.a, 16, frame); + frame[4] = 1; + CHECK(lan_protocol_feed(f.protocol, frame, n) == LAN_ERR_PROTOCOL); + cleanup(&f); CHECK(allocations == 0); + CHECK(init(&f) == 0 && authenticate(&f, 0) == 0); + now_ms = 29999; CHECK(lan_protocol_tick(f.protocol) == 0); + CHECK(exchange(&f, 3, 9, NULL, 0) == 0); + now_ms += 30000; CHECK(lan_protocol_tick(f.protocol) == LAN_ERR_TIMEOUT); + cleanup(&f); CHECK(allocations == 0); return 0; +} + +static int test_strict_dp_json_and_query_bounds(void) +{ + struct fixture f; + uint8_t payload[512]; + size_t n; + const char *bad[] = { + "{\"data\":{\"dps\":true}}", "{\"data\":{\"dps\":{}}}junk", + "{\"data\":{\"dps\":{}} ,\"data\":{\"dps\":{\"1\":true}}}", + "{\"data\":{\"dps\":{},\"dps\":{\"1\":true}}}", + "{\"data\":{\"dps\":{\"1\":\"unterminated}}}" + }; + unsigned i; + CHECK(init(&f) == 0 && authenticate(&f, 0) == 0); + for (i = 0; i < sizeof(bad) / sizeof(bad[0]); ++i) { + n = inner(payload, bad[i]); + CHECK(exchange(&f, 3 + i, 7, payload, n) == 0); + CHECK(ack(&f, 7, 1) == 0 && f.dp_calls == 0); + } + n = inner(payload, "{\"data\":{\"dps\":{}}}"); + payload[n++] = 0; + payload[n++] = 'x'; + CHECK(exchange(&f, 20, 7, payload, n) == 0 && ack(&f, 7, 1) == 0); + f.query_mode = 1; + CHECK(exchange(&f, 21, 10, (const uint8_t *)"{}", 2) == 0 && ack(&f, 10, 1) == 0); + f.query_mode = 2; + CHECK(exchange(&f, 22, 16, (const uint8_t *)"{}", 2) == 0 && ack(&f, 16, 1) == 0); + CHECK(lan_protocol_report(f.protocol, "{\"dps\":false}", 13) == LAN_ERR_PARAM); + CHECK(lan_protocol_report(f.protocol, "{\"dps\":{},\"dps\":{}}", 19) == LAN_ERR_PARAM); + cleanup(&f); CHECK(allocations == 0); return 0; +} + +static int test_authenticated_header_tamper_and_rekey(void) +{ + struct fixture f; + uint8_t frame[256]; + size_t n; + CHECK(init(&f) == 0 && authenticate(&f, 0) == 0); + n = peer_encode(f.key, 3, 9, NULL, 0, frame); + frame[9] ^= 1; + CHECK(lan_protocol_feed(f.protocol, frame, n) == LAN_ERR_AUTH); + cleanup(&f); CHECK(allocations == 0); + CHECK(init(&f) == 0 && authenticate(&f, 0) == 0); + n = peer_encode(local_key, 3, 3, f.a, 16, frame); + CHECK(lan_protocol_feed(f.protocol, frame, n) == LAN_ERR_AUTH); + cleanup(&f); CHECK(allocations == 0); return 0; +} + +static int test_control_lexical_validation_before_normalization(void) +{ + struct fixture f; + uint8_t payload[512]; + size_t n; + unsigned i; + cJSON *root, *value; + const char *bad[] = { + "{\"data\":{\"dps\":{\"1\":\"abc\\u0000def\"}}}", + "{\"data\":{\"dps\":{\"1\\u0000suffix\":true}}}", + "{\"data\":{\"dps\":{\"1\":01}}}", + "{\"data\":{\"dps\":{\"1\":-01}}}", + "{\"data\":{\"dps\":{\"1\":+1}}}", + "{\"data\":{\"dps\":{\"1\":1.}}}", + "{\"data\":{\"dps\":{\"1\":.1}}}", + "{\"data\":{\"dps\":{\"1\":1e}}}", + "{\"data\":{\"dps\":{\"1\":1e+}}}", + "{\"data\":{\"dps\":{\"1\":1e-}}}", + "{\"data\":{\"dps\":{\"1\":1e999}}}", + "{\"data\":{\"dps\":{\"1\":--1}}}", + "{\"data\":{\"dps\":{\"1\":truefalse}}}", + "{\"data\":{\"dps\":{\"1\":\vtrue}}}", + "{\"data\":{\"dps\":{\"1\":true\f}}}", + "{\"data\":{\"dps\":{\"1\":\"\xc0\x80\"}}}", + "{\"data\":{\"dps\":{\"1\":\"\xe0\x9f\xbf\"}}}", + "{\"data\":{\"dps\":{\"1\":\"\xf0\x8f\xbf\xbf\"}}}", + "{\"data\":{\"dps\":{\"1\":\"\xed\xa0\x80\"}}}", + "{\"data\":{\"dps\":{\"1\":\"\xf4\x90\x80\x80\"}}}", + "{\"data\":{\"dps\":{\"1\":\"\xe1\x80x\"}}}", + "{\"data\":{\"dps\":{\"1\":\"\\uD800\"}}}", + "{\"data\":{\"dps\":{\"1\":\"\\uDC00\"}}}", + "{\"data\":{\"dps\":{\"1\":\"\\uD800\\u0041\"}}}", + "{\"data\":{\"dps\":{\"1\":\"\\u00x0\"}}}" + }; + const char literal[] = "{\"data\":{\"dps\":{\"1\":\"abc\\\\u0000def\"}}}"; + const char valid[] = "{\"data\":{\"dps\":{\"1\":-0.125e+2,\"2\":\"\\uD83D\\uDE00\"," + "\"3\":\"\xc2\xa2\xe4\xb8\xad\xf0\x9f\x98\x80\"}}}"; + CHECK(init(&f) == 0 && authenticate(&f, 0) == 0); + for (i = 0; i < sizeof(bad) / sizeof(bad[0]); ++i) { + n = inner(payload, bad[i]); + CHECK(exchange(&f, 3 + i, 7, payload, n) == 0); + CHECK(ack(&f, 7, 1) == 0 && f.dp_calls == 0); + } + n = inner(payload, literal); + CHECK(exchange(&f, 100, 7, payload, n) == 0 && ack(&f, 7, 0) == 0); + CHECK(f.dp_calls == 1); + root = cJSON_Parse(f.dps); + value = cJSON_GetObjectItemCaseSensitive(root, "1"); + CHECK(cJSON_IsString(value) && strcmp(value->valuestring, "abc\\u0000def") == 0); + cJSON_Delete(root); + n = inner(payload, valid); + CHECK(exchange(&f, 101, 13, payload, n) == 0 && ack(&f, 13, 0) == 0); + CHECK(f.dp_calls == 2); + root = cJSON_Parse(f.dps); + value = cJSON_GetObjectItemCaseSensitive(root, "2"); + CHECK(cJSON_IsString(value) && strcmp(value->valuestring, "\xf0\x9f\x98\x80") == 0); + value = cJSON_GetObjectItemCaseSensitive(root, "3"); + CHECK(cJSON_IsString(value) && strcmp(value->valuestring, "\xc2\xa2\xe4\xb8\xad\xf0\x9f\x98\x80") == 0); + cJSON_Delete(root); + cleanup(&f); CHECK(allocations == 0); return 0; +} + +static int test_json_depth_and_late_packet(void) +{ + struct fixture f; + char deep[256]; + size_t n, i; + const char escaped[] = "{\"dps\":{\"1\":\"braces {}[] and \\\"quote\\\"\"}}"; + CHECK(init(&f) == 0 && authenticate(&f, 0) == 0); + CHECK(lan_protocol_validate_state(&f.config, escaped, strlen(escaped)) == 0); + memcpy(deep, "{\"dps\":{\"1\":", 12); n = 12; + for (i = 0; i < 40; ++i) deep[n++] = '['; + deep[n++] = '0'; + for (i = 0; i < 40; ++i) deep[n++] = ']'; + deep[n++] = '}'; deep[n++] = '}'; deep[n] = 0; + CHECK(lan_protocol_validate_state(&f.config, deep, n) == LAN_ERR_PARAM); + now_ms = 30000; + CHECK(exchange(&f, 3, 9, NULL, 0) == LAN_ERR_TIMEOUT); + CHECK(f.emissions == 1 && !lan_protocol_authenticated(f.protocol)); + cleanup(&f); CHECK(allocations == 0); return 0; +} + +static int test_entropy_allocation_and_emit_failure(void) +{ + struct fixture f; + uint8_t frame[256]; + size_t n; + CHECK(init(&f) == 0); cleanup(&f); + random_failure = 1; + CHECK(lan_protocol_create(&f.config, 1, capture, &f, &f.protocol) == LAN_ERR_CRYPTO); + CHECK(f.protocol == NULL); random_failure = 0; + CHECK(init(&f) == 0); + n = peer_encode(local_key, 1, 3, f.a, 16, frame); + random_failure = 1; + CHECK(lan_protocol_feed(f.protocol, frame, n) == LAN_ERR_CRYPTO); + CHECK(f.emissions == 0); cleanup(&f); CHECK(allocations == 0); + CHECK(init(&f) == 0 && authenticate(&f, 0) == 0); + allocation_failure = 1; + CHECK(exchange(&f, 3, 9, NULL, 0) == LAN_ERR_MEMORY); + cleanup(&f); CHECK(allocations == 0); + CHECK(init(&f) == 0 && authenticate(&f, 0) == 0); + random_failure = 1; + CHECK(lan_protocol_send(f.protocol, 0x20, 0, NULL, 0) == LAN_ERR_CRYPTO); + cleanup(&f); CHECK(allocations == 0); + CHECK(init(&f) == 0 && authenticate(&f, 0) == 0); + f.emit_error = LAN_ERR_LIMIT; + CHECK(exchange(&f, 3, 9, NULL, 0) == LAN_ERR_LIMIT); + CHECK(!lan_protocol_authenticated(f.protocol)); + cleanup(&f); CHECK(allocations == 0); return 0; +} + +static int test_discovery_independent_peer(void) +{ + struct fixture f; + uint8_t encoded[WIRE_MAX], plaintext[WIRE_MAX], key[16]; + const char request[] = "{\"ip\":\"192.168.1.2\",\"from\":\"app\"}"; + size_t len, plain_len; + char ip[16]; + cJSON *root; + CHECK(init(&f) == 0); + CHECK(mbedtls_md5((const uint8_t *)"yGAdlopoPVldABfn", 16, key) == 0); + CHECK(lan_discovery_build(&f.config, encoded, sizeof(encoded), &len) == 0); + CHECK(be32(encoded + 10) == 0x23 && be32(encoded + 6) == 0); + CHECK(peer_decode(key, encoded, len, plaintext, &plain_len) == 0); + CHECK(plain_len > 4 && be32(plaintext) == 0); plaintext[plain_len] = 0; + root = cJSON_Parse((const char *)plaintext + 4); + CHECK(root && strcmp(cJSON_GetObjectItemCaseSensitive(root, "gwId")->valuestring, "device123") == 0); + CHECK(strcmp(cJSON_GetObjectItemCaseSensitive(root, "version")->valuestring, "3.5") == 0); + CHECK(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "encrypt"))); + CHECK(cJSON_GetObjectItemCaseSensitive(root, "active")->valueint == 2); + cJSON_Delete(root); + len = peer_encode(key, 7, 0x25, (const uint8_t *)request, strlen(request), encoded); + CHECK(lan_discovery_parse(&f.config, encoded, len, ip, sizeof(ip)) == 0); + CHECK(strcmp(ip, "192.168.1.2") == 0); + CHECK(lan_discovery_parse(&f.config, encoded, len - 1, ip, sizeof(ip)) == LAN_ERR_PROTOCOL); + CHECK(lan_discovery_parse(&f.config, encoded, len, ip, 4) == LAN_ERR_LIMIT); + encoded[len - 5] ^= 1; + CHECK(lan_discovery_parse(&f.config, encoded, len, ip, sizeof(ip)) == LAN_ERR_AUTH); + CHECK(lan_discovery_build(&f.config, encoded, 16, &len) == LAN_ERR_LIMIT && len == 0); + cleanup(&f); CHECK(allocations == 0); return 0; +} + +int main(void) +{ + cJSON_Hooks hooks = {test_malloc, test_free}; + cJSON_InitHooks(&hooks); + RUN(test_authenticated_control_query_report); + RUN(test_coalesced_frames); + RUN(test_replay_and_bad_tag); + RUN(test_unauthenticated_and_out_of_order_handshake); + RUN(test_zero_leading_session_key); + RUN(test_bad_lengths_headers_and_partial_timeout); + RUN(test_strict_dp_json_and_query_bounds); + RUN(test_authenticated_header_tamper_and_rekey); + RUN(test_control_lexical_validation_before_normalization); + RUN(test_json_depth_and_late_packet); + RUN(test_entropy_allocation_and_emit_failure); + RUN(test_discovery_independent_peer); + printf("=== LAN protocol: %u passed, %u failed (%u total) ===\n", passed, failed, total); + return failed ? 1 : 0; +} diff --git a/modules/tuya-lan/test/test_service.c b/modules/tuya-lan/test/test_service.c new file mode 100644 index 0000000..8ad38f2 --- /dev/null +++ b/modules/tuya-lan/test/test_service.c @@ -0,0 +1,400 @@ +/* Deterministic service tests: the protocol double emits its input verbatim; + * the independent protocol suite verifies encryption and wire compatibility. */ +#include "tuya_lan_protocol.h" + +#include +#include +#include + +struct fake_socket { unsigned index; }; +struct lan_protocol { + tuya_lan_config_t config; + lan_protocol_emit_t emit; + void *user; + bool authenticated; + uint64_t created; +}; + +static unsigned failures, cases, allocations, alloc_calls, fail_alloc_at; +static uint64_t now; +static struct { + struct fake_socket handles[10]; + unsigned pending, next_accept, closed, live, accepts, reads, sends; + bool fail_udp, blocked; + size_t send_limit; + unsigned send_again_every; + uint8_t input[8][4096], output[8][32768]; + size_t input_len[8], input_pos[8], output_len[8]; + bool eof[8]; + bool udp_pending; + pal_addr_t udp_source, last_destination; + char udp_data[32]; + unsigned udp_sends, nonzero_timeouts, wrong_close; +} io; + +#define CHECK(e) do { if (!(e)) { fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #e); failures++; return; } } while (0) +#define RUN(fn) do { cases++; fn(); } while (0) +static void *test_malloc(size_t len) +{ + alloc_calls++; + if (fail_alloc_at && alloc_calls == fail_alloc_at) return NULL; + void *p = malloc(len); + if (p) allocations++; + return p; +} +static void test_free(void *p) { if (p) allocations--; free(p); } +static uint64_t test_now(void) { return now; } +static void *unused_connect(const char *h, uint16_t p, uint32_t t) +{ (void)h; (void)p; (void)t; return NULL; } +static int unused_poll(void *h, int e, uint32_t t) +{ (void)h; (void)e; (void)t; return PAL_ERR_NET; } +static void unused_close(void *h) { (void)h; } +static void *unused_mutex(void) { return NULL; } +static int unused_thread(void **h, void *(*fn)(void *), void *arg) +{ (void)h; (void)fn; (void)arg; return -1; } +static int unused_join(void *h) { (void)h; return -1; } +static void unused_sleep(uint32_t ms) { (void)ms; } + + +int lan_protocol_create(const tuya_lan_config_t *c, uint32_t id, + lan_protocol_emit_t emit, void *user, lan_protocol_t **out) +{ + (void)id; + *out = c->pal->malloc(sizeof(**out)); + if (!*out) return LAN_ERR_MEMORY; + **out = (lan_protocol_t){ .config = *c, .emit = emit, .user = user, .created = now }; + return LAN_OK; +} +void lan_protocol_destroy(lan_protocol_t *p) { p->config.pal->free(p); } +int lan_protocol_feed(lan_protocol_t *p, const uint8_t *data, size_t len) +{ + p->authenticated = true; + p->created = now; + return p->emit(p->user, data, len); +} +int lan_protocol_tick(lan_protocol_t *p) +{ + uint32_t timeout = p->authenticated ? p->config.heartbeat_timeout_ms : p->config.handshake_timeout_ms; + return now - p->created >= timeout ? LAN_ERR_TIMEOUT : LAN_OK; +} +bool lan_protocol_authenticated(const lan_protocol_t *p) { return p->authenticated; } +int lan_protocol_report(lan_protocol_t *p, const char *data, size_t len) +{ + return p->authenticated ? p->emit(p->user, (const uint8_t *)data, len) : LAN_ERR_AUTH; +} +int lan_protocol_send(lan_protocol_t *p, uint32_t command, uint32_t code, + const uint8_t *data, size_t len) +{ + (void)command; (void)code; + return p->authenticated ? p->emit(p->user, data, len) : LAN_ERR_AUTH; +} +int lan_protocol_validate_state(const tuya_lan_config_t *c, const char *json, size_t len) +{ + (void)c; + return json && len && *json == '{' ? LAN_OK : LAN_ERR_PARAM; +} +int lan_discovery_build(const tuya_lan_config_t *c, uint8_t *out, size_t capacity, size_t *len) +{ + (void)c; + if (capacity < 8) return LAN_ERR_LIMIT; + memcpy(out, "announce", 8); *len = 8; return LAN_OK; +} +int lan_discovery_parse(const tuya_lan_config_t *c, const uint8_t *data, size_t len, + char *ip, size_t capacity) +{ + (void)c; + if (len >= capacity) return LAN_ERR_PROTOCOL; + memcpy(ip, data, len); ip[len] = 0; return LAN_OK; +} + +static int net_listen(const pal_t *pal, const char *ip, uint16_t port, + unsigned backlog, void **out) +{ + (void)pal; (void)ip; (void)port; (void)backlog; + *out = &io.handles[0]; io.live++; return 0; +} +static int net_accept(void *listener, void **out, pal_addr_t *peer) +{ + (void)listener; (void)peer; + io.accepts++; + *out = NULL; + if (!io.pending) return PAL_ERR_AGAIN; + io.pending--; *out = &io.handles[2 + io.next_accept++]; io.live++; return 0; +} +static int net_send(void *handle, const uint8_t *data, size_t len, uint32_t timeout_ms) +{ + struct fake_socket *h = handle; + if (timeout_ms) io.nonzero_timeouts++; + unsigned i = h->index - 2; + io.sends++; + if (io.blocked || (io.send_again_every && io.sends % io.send_again_every == 0)) return PAL_ERR_AGAIN; + if (io.send_limit && len > io.send_limit) len = io.send_limit; + if (len > sizeof(io.output[i]) - io.output_len[i]) return PAL_ERR_NET; + memcpy(io.output[i] + io.output_len[i], data, len); io.output_len[i] += len; + return (int)len; +} +static int net_recv(void *handle, uint8_t *data, size_t capacity, uint32_t timeout_ms) +{ + struct fake_socket *h = handle; + if (timeout_ms) io.nonzero_timeouts++; + unsigned i = h->index - 2; + io.reads++; + if (io.input_pos[i] == io.input_len[i]) return io.eof[i] ? 0 : PAL_ERR_AGAIN; + size_t len = io.input_len[i] - io.input_pos[i]; + if (len > capacity) len = capacity; + memcpy(data, io.input[i] + io.input_pos[i], len); io.input_pos[i] += len; + return (int)len; +} +static int net_udp_bind(const pal_t *pal, const char *ip, uint16_t port, + bool broadcast, void **out) +{ + (void)pal; (void)ip; (void)port; (void)broadcast; + *out = NULL; + if (io.fail_udp) return PAL_ERR_NET; + *out = &io.handles[1]; io.live++; return 0; +} +static int net_sendto(void *h, const pal_addr_t *peer, + const uint8_t *data, size_t len) +{ + (void)h; (void)data; + io.last_destination = *peer; io.udp_sends++; return (int)len; +} +static int net_recvfrom(void *h, pal_addr_t *peer, + uint8_t *data, size_t capacity) +{ + (void)h; + if (!io.udp_pending) return PAL_ERR_AGAIN; + io.udp_pending = false; + size_t len = strlen(io.udp_data); + if (len > capacity) return PAL_ERR_NET; + *peer = io.udp_source; memcpy(data, io.udp_data, len); return (int)len; +} +static int net_port(void *handle, uint16_t *port) +{ + struct fake_socket *h = handle; + *port = (uint16_t)(12340 + h->index); return 0; +} +static void net_tcp_close(void *handle) +{ + struct fake_socket *h = handle; + if (h) { if (h->index == 1) io.wrong_close++; io.closed++; io.live--; } +} +static void net_udp_close(void *handle) +{ + struct fake_socket *h = handle; + if (h) { if (h->index != 1) io.wrong_close++; io.closed++; io.live--; } +} +static const pal_t test_pal = { + .tcp_connect = unused_connect, .tcp_send = net_send, .tcp_recv = net_recv, + .tcp_poll = unused_poll, .tcp_close = net_tcp_close, + .malloc = test_malloc, .free = test_free, + .time_ms = test_now, .wal_clock_time = test_now, + .mutex_create = unused_mutex, .mutex_lock = unused_close, + .mutex_unlock = unused_close, .mutex_destroy = unused_close, + .thread_create = unused_thread, .thread_join = unused_join, + .sleep_ms = unused_sleep, + .tcp_listen = net_listen, .tcp_accept = net_accept, + .udp_bind = net_udp_bind, .udp_sendto = net_sendto, + .udp_recvfrom = net_recvfrom, .socket_local_port = net_port, + .udp_close = net_udp_close +}; + +static tuya_lan_config_t config(void) +{ + memset(&io, 0, sizeof(io)); + for (unsigned i = 0; i < 10; i++) io.handles[i].index = i; + now = 0; fail_alloc_at = 0; alloc_calls = 0; + tuya_lan_config_t c; + tuya_lan_config_init(&c); + c.pal = &test_pal; + c.device_id = "device"; c.uuid = "uuid"; c.product_key = "product"; + c.local_key = "0123456789abcdef"; c.ipv4 = "127.0.0.1"; + c.bind_ip = "127.0.0.1"; c.broadcast_ip = "127.0.0.1"; + c.tcp_port = c.udp_port = 0; + c.discovery_interval_ms = 0; + return c; +} + +static void test_config_and_lifecycle(void) +{ + tuya_lan_config_t c = config(); + tuya_lan_t *lan = NULL; + pal_t incomplete = test_pal; + incomplete.udp_close = NULL; + c.pal = &incomplete; + CHECK(pal_is_valid(c.pal)); + CHECK(tuya_lan_create(&c, &lan) == LAN_ERR_PARAM && !lan); + incomplete = test_pal; incomplete.tcp_accept = NULL; + CHECK(tuya_lan_create(&c, &lan) == LAN_ERR_PARAM && !lan); + c.pal = &test_pal; + c.local_key = "short"; + CHECK(tuya_lan_create(&c, &lan) == LAN_ERR_PARAM && !lan); + c.local_key = "0123456789abcdef"; c.ipv4 = "127.0.0.256"; + CHECK(tuya_lan_create(&c, &lan) == LAN_ERR_PARAM); + c.ipv4 = "127.0.0.1"; + CHECK(tuya_lan_create(&c, &lan) == LAN_OK); + CHECK(tuya_lan_process(lan) == LAN_ERR_STATE); + CHECK(tuya_lan_start(lan) == LAN_OK); + CHECK(tuya_lan_start(lan) == LAN_ERR_STATE); + CHECK(tuya_lan_tcp_port(lan) == 12340 && tuya_lan_udp_port(lan) == 12341); + CHECK(tuya_lan_stop(lan) == LAN_OK && io.live == 0); + CHECK(tuya_lan_tcp_port(lan) == 0 && tuya_lan_udp_port(lan) == 0); + CHECK(tuya_lan_stop(lan) == LAN_OK); + CHECK(tuya_lan_start(lan) == LAN_OK); + tuya_lan_destroy(lan); + CHECK(io.live == 0 && allocations == 0); + CHECK(io.nonzero_timeouts == 0 && io.wrong_close == 0); +} + +static void test_config_string_boundaries(void) +{ + tuya_lan_config_t c = config(); + tuya_lan_t *lan = NULL; + char identity[65], oversized[66]; + memset(identity, 'a', sizeof(identity) - 1); identity[sizeof(identity) - 1] = '\0'; + memset(oversized, 'a', sizeof(oversized) - 1); oversized[sizeof(oversized) - 1] = '\0'; + c.device_id = c.uuid = c.product_key = identity; + CHECK(tuya_lan_create(&c, &lan) == LAN_OK && lan); + tuya_lan_destroy(lan); + CHECK(allocations == 0); + + const char **fields[] = { &c.device_id, &c.uuid, &c.product_key }; + for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); ++i) { + *fields[i] = oversized; + CHECK(tuya_lan_create(&c, &lan) == LAN_ERR_PARAM && !lan); + CHECK(allocations == 0); + *fields[i] = identity; + } + c.local_key = NULL; + CHECK(tuya_lan_create(&c, &lan) == LAN_ERR_PARAM && !lan); + c.local_key = "0123456789abcde"; + CHECK(tuya_lan_create(&c, &lan) == LAN_ERR_PARAM && !lan); + c.local_key = "0123456789abcdefg"; + CHECK(tuya_lan_create(&c, &lan) == LAN_ERR_PARAM && !lan); + c.local_key = "0123456789abcdef"; + CHECK(tuya_lan_create(&c, &lan) == LAN_OK && lan); + tuya_lan_destroy(lan); + CHECK(allocations == 0 && io.live == 0); +} + +static void test_start_failure_and_allocations(void) +{ + for (unsigned i = 1; i <= 4; i++) { + tuya_lan_config_t c = config(); + tuya_lan_t *lan = NULL; fail_alloc_at = i; + CHECK(tuya_lan_create(&c, &lan) == LAN_ERR_MEMORY && !lan); + CHECK(allocations == 0); + } + tuya_lan_config_t c = config(); + tuya_lan_t *lan = NULL; + CHECK(tuya_lan_create(&c, &lan) == LAN_OK); + io.fail_udp = true; + CHECK(tuya_lan_start(lan) == LAN_ERR_NET && io.live == 0); + io.fail_udp = false; + CHECK(tuya_lan_start(lan) == LAN_OK); + io.pending = 1; fail_alloc_at = alloc_calls + 1; + CHECK(tuya_lan_process(lan) == LAN_OK && tuya_lan_client_count(lan) == 0); + CHECK(io.live == 2); + io.pending = 1; fail_alloc_at = alloc_calls + 2; + CHECK(tuya_lan_process(lan) == LAN_OK && tuya_lan_client_count(lan) == 0); + CHECK(io.live == 2); + tuya_lan_destroy(lan); + CHECK(allocations == 0 && io.live == 0); + CHECK(io.nonzero_timeouts == 0 && io.wrong_close == 0); +} + +static void test_partial_send_and_would_block(void) +{ + tuya_lan_config_t c = config(); + tuya_lan_t *lan = NULL; + CHECK(tuya_lan_create(&c, &lan) == LAN_OK && tuya_lan_start(lan) == LAN_OK); + io.pending = 1; io.blocked = true; + io.input_len[0] = 100; + for (size_t i = 0; i < io.input_len[0]; i++) io.input[0][i] = (uint8_t)i; + CHECK(tuya_lan_process(lan) == LAN_OK && tuya_lan_client_count(lan) == 1); + CHECK(io.output_len[0] == 0); + io.blocked = false; io.send_limit = 7; io.send_again_every = 2; + for (unsigned i = 0; i < 40 && io.output_len[0] < 100; i++) + CHECK(tuya_lan_process(lan) == LAN_OK); + CHECK(io.output_len[0] == 100 && !memcmp(io.output[0], io.input[0], 100)); + CHECK(tuya_lan_report(lan, "{\"dps\":{}}", 10) == LAN_OK); + for (unsigned i = 0; i < 4; i++) CHECK(tuya_lan_process(lan) == LAN_OK); + CHECK(io.output_len[0] == 110 && !memcmp(io.output[0] + 100, "{\"dps\":{}}", 10)); + tuya_lan_destroy(lan); + CHECK(allocations == 0 && io.live == 0); + CHECK(io.nonzero_timeouts == 0 && io.wrong_close == 0); +} + +static void test_capacity_isolation_and_timeout(void) +{ + tuya_lan_config_t c = config(); + tuya_lan_t *lan = NULL; + CHECK(tuya_lan_create(&c, &lan) == LAN_OK && tuya_lan_start(lan) == LAN_OK); + io.pending = 5; + CHECK(tuya_lan_process(lan) == LAN_OK && tuya_lan_client_count(lan) == 3); + CHECK(io.accepts == 3); + CHECK(tuya_lan_process(lan) == LAN_OK && tuya_lan_client_count(lan) == 3); + CHECK(io.closed == 2); + io.eof[0] = true; + CHECK(tuya_lan_process(lan) == LAN_OK && tuya_lan_client_count(lan) == 2); + io.input[1][0] = 'x'; io.input_len[1] = 1; + now = 9000; + CHECK(tuya_lan_process(lan) == LAN_OK); + now = 10000; + CHECK(tuya_lan_process(lan) == LAN_OK && tuya_lan_client_count(lan) == 1); + now = 39000; + CHECK(tuya_lan_process(lan) == LAN_OK && tuya_lan_client_count(lan) == 0); + tuya_lan_destroy(lan); + CHECK(allocations == 0 && io.live == 0); + CHECK(io.nonzero_timeouts == 0 && io.wrong_close == 0); +} + +static void test_queue_overflow_disconnects_only_target(void) +{ + tuya_lan_config_t c = config(); + tuya_lan_t *lan = NULL; + CHECK(tuya_lan_create(&c, &lan) == LAN_OK && tuya_lan_start(lan) == LAN_OK); + io.pending = 2; io.input[0][0] = 'x'; io.input_len[0] = 1; + CHECK(tuya_lan_process(lan) == LAN_OK && tuya_lan_client_count(lan) == 2); + io.blocked = true; + char frame[TUYA_LAN_MAX_FRAME_SIZE]; + memset(frame, 'x', sizeof(frame)); frame[0] = '{'; + CHECK(tuya_lan_send(lan, 1, 0x20, 0, (uint8_t *)frame, sizeof(frame)) == LAN_OK); + CHECK(tuya_lan_send(lan, 1, 0x20, 0, (uint8_t *)frame, sizeof(frame)) == LAN_OK); + CHECK(tuya_lan_send(lan, 1, 0x20, 0, (uint8_t *)frame, 1) == LAN_ERR_LIMIT); + CHECK(tuya_lan_process(lan) == LAN_OK && tuya_lan_client_count(lan) == 1); + CHECK(tuya_lan_send(lan, 2, 0x20, 0, (uint8_t *)frame, 1) == LAN_ERR_AUTH); + tuya_lan_destroy(lan); + CHECK(allocations == 0 && io.live == 0); + CHECK(io.nonzero_timeouts == 0 && io.wrong_close == 0); +} + +static void test_discovery_source_and_interval(void) +{ + tuya_lan_config_t c = config(); + tuya_lan_t *lan = NULL; c.discovery_interval_ms = 5000; + CHECK(tuya_lan_create(&c, &lan) == LAN_OK && tuya_lan_start(lan) == LAN_OK); + CHECK(tuya_lan_process(lan) == LAN_OK && io.udp_sends == 1); + CHECK(!strcmp(io.last_destination.ip, "127.0.0.1") && io.last_destination.port == 6667); + now = 4999; CHECK(tuya_lan_process(lan) == LAN_OK && io.udp_sends == 1); + now = 5000; CHECK(tuya_lan_process(lan) == LAN_OK && io.udp_sends == 2); + io.udp_source = (pal_addr_t){ "127.0.0.2", 2468 }; + strcpy(io.udp_data, "127.0.0.1"); io.udp_pending = true; + CHECK(tuya_lan_process(lan) == LAN_OK && io.udp_sends == 2); + strcpy(io.udp_data, "127.0.0.2"); io.udp_pending = true; + CHECK(tuya_lan_process(lan) == LAN_OK && io.udp_sends == 3); + CHECK(!strcmp(io.last_destination.ip, "127.0.0.2") && io.last_destination.port == 2468); + tuya_lan_destroy(lan); + CHECK(allocations == 0 && io.live == 0); + CHECK(io.nonzero_timeouts == 0 && io.wrong_close == 0); +} + +int main(void) +{ + RUN(test_config_and_lifecycle); RUN(test_config_string_boundaries); + RUN(test_start_failure_and_allocations); + RUN(test_partial_send_and_would_block); RUN(test_capacity_isolation_and_timeout); + RUN(test_queue_overflow_disconnects_only_target); RUN(test_discovery_source_and_interval); + printf("tuya_lan_service: %u cases, %u failures\n", cases, failures); + return failures ? 1 : 0; +} diff --git a/pal/pal.h b/pal/pal.h index e2c142f..1bdc9ba 100644 --- a/pal/pal.h +++ b/pal/pal.h @@ -42,16 +42,24 @@ extern "C" { * ------------------------------------------------------------------------- */ #define PAL_ERR_NET (-3) /* fatal network error */ #define PAL_ERR_AGAIN (-7) /* timed out / would block, try again later */ +#define PAL_ERR_INVALID (-2) /* invalid network arguments */ + +/* Numeric IPv4 endpoint used by the optional server/datagram operations. + * A NULL bind IP means INADDR_ANY. Addresses returned by the port are NUL-terminated. */ +typedef struct { + char ip[16]; + uint16_t port; +} pal_addr_t; /* ------------------------------------------------------------------------- * pal_t -- function pointer table * ------------------------------------------------------------------------- - * Every callback below is mandatory. pal_is_valid() rejects any pal_t - * that leaves a field NULL, so SDK call sites may dereference these - * pointers without re-checking. + * The base callbacks are mandatory. pal_is_valid() checks them at initialization. + * The optional server/datagram callbacks at the end are checked separately by + * their consumers; their fields exist even when the implementation is disabled. * - * ADDING, REMOVING OR REORDERING A MEMBER means editing four separate - * designated-initializer tables plus pal_is_valid() below: + * ADDING, REMOVING OR REORDERING A MEMBER requires reviewing four separate + * designated-initializer tables (and pal_is_valid() for base callbacks): * pal/pal_posix.c, pal/pal_freertos.c, * modules/rtc-tcp-client/test/tai_pal_loopback.c, and .../test/test_core.c * (test_core intentionally leaves networking/thread/sleep callbacks NULL because @@ -59,8 +67,9 @@ extern "C" { * C99 zero-fills omitted designated members. The host clock regression matrix * compiles pal_freertos.c against test shims and checks its table, but the real * FreeRTOS/lwIP build is still the ESP-IDF component. Keep that table in step: - * a missing callback means pal_is_valid() rejects the port or a direct caller - * dereferences NULL. Host shims do not verify the real device integration. + * a missing base callback means pal_is_valid() rejects the port; optional + * capabilities need their own completeness check. All consumers must rebuild + * after a table layout change. Host shims do not verify device integration. */ typedef struct pal { @@ -146,6 +155,35 @@ typedef struct pal { */ void (*sleep_ms)(uint32_t ms); + /* --- Optional IPv4 server and datagram operations --------------------- + * tcp_listen/udp_bind: bind a numeric IPv4 address (NULL = any address). + * Port 0 requests an ephemeral port. Use pal's allocator for the handle. + * Return 0 on success and set *out; errors leave *out NULL. + * tcp_accept: one nonblocking accept attempt; peer is optional. Accepted + * connections use the SAME handle/operations as tcp_connect connections: + * tcp_send/recv/poll/close. timeout_ms=0 on those calls never waits. + * udp_sendto/udp_recvfrom: one nonblocking datagram attempt. A receive of 0 + * is a valid empty datagram. Oversized datagrams are consumed and fail, + * never returned truncated. udp_close releases a UDP handle. + * socket_local_port: get a listening, accepted or UDP handle's bound port. + * Errors use PAL_ERR_INVALID/NET/AGAIN; AGAIN also includes an interrupted + * nonblocking attempt. Never mix TCP and UDP handles or platform ports. + * These callbacks may be NULL. LAN validates the complete group before use. + * The built-in ports enable them with AGENTIC_KIT_ENABLE_LAN; conditional + * compilation must never change the pal_t member layout. + */ + int (*tcp_listen)(const struct pal *pal, const char *ip, uint16_t port, + unsigned backlog, void **out); + int (*tcp_accept)(void *listener, void **out, pal_addr_t *peer); + int (*udp_bind)(const struct pal *pal, const char *ip, uint16_t port, + bool broadcast, void **out); + int (*udp_sendto)(void *socket, const pal_addr_t *peer, + const uint8_t *data, size_t len); + int (*udp_recvfrom)(void *socket, pal_addr_t *peer, + uint8_t *data, size_t capacity); + int (*socket_local_port)(void *socket, uint16_t *port); + void (*udp_close)(void *socket); + } pal_t; /* ------------------------------------------------------------------------- @@ -171,6 +209,14 @@ static inline bool pal_is_valid(const pal_t *p) && p->sleep_ms; } +/* Server/datagram capability is optional for IoT and RTC, required for LAN. */ +static inline bool pal_has_server_network(const pal_t *p) +{ + return p && p->tcp_listen && p->tcp_accept && p->tcp_send && p->tcp_recv + && p->tcp_close && p->tcp_poll && p->udp_bind && p->udp_sendto + && p->udp_recvfrom && p->socket_local_port && p->udp_close; +} + #ifdef __cplusplus } #endif diff --git a/pal/pal_freertos.c b/pal/pal_freertos.c index 9609f9b..d0e7605 100644 --- a/pal/pal_freertos.c +++ b/pal/pal_freertos.c @@ -4,8 +4,8 @@ * Targets: any FreeRTOS + lwIP system (ESP-IDF, NuttX, NXP MCUXpresso, * STM32CubeMX with FreeRTOS+lwIP middleware, etc.). * - * Provides only raw TCP socket operations -- TLS and crypto are handled - * internally by the SDK via mbedTLS. + * Provides raw TCP socket operations and optional IPv4 server/UDP operations + * when AGENTIC_KIT_ENABLE_LAN=1. TLS and crypto are handled by SDK mbedTLS. * * -------------------------------------------------------------------------- * Required FreeRTOSConfig.h flags @@ -68,6 +68,13 @@ #include "lwip/sockets.h" #include "lwip/netdb.h" +#ifndef AGENTIC_KIT_ENABLE_LAN +#define AGENTIC_KIT_ENABLE_LAN 0 +#endif +#if AGENTIC_KIT_ENABLE_LAN +#include "lwip/inet.h" +#endif + #include "pal.h" #include "log.h" @@ -82,13 +89,16 @@ #endif /* ------------------------------------------------------------------------- - * TCP socket handle + * Shared handle for TCP connections, listeners and UDP sockets * ------------------------------------------------------------------------- */ typedef struct { int fd; uint32_t last_rcvtimeo_ms; uint32_t last_sndtimeo_ms; -} fr_tcp_t; + /* NULL for tcp_connect's legacy FreeRTOS allocation; server/datagram + * sockets retain the long-lived PAL whose allocator created them. */ + const pal_t *pal; +} fr_socket_t; static void *pal_tcp_connect(const char *host, uint16_t port, uint32_t timeout_ms) { @@ -170,7 +180,7 @@ static void *pal_tcp_connect(const char *host, uint16_t port, uint32_t timeout_m int flag = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)); - fr_tcp_t *h = (fr_tcp_t *)pvPortMalloc(sizeof(fr_tcp_t)); + fr_socket_t *h = (fr_socket_t *)pvPortMalloc(sizeof(fr_socket_t)); if (!h) { log_tag_error("pal", "tcp handle alloc failed"); close(fd); @@ -179,13 +189,15 @@ static void *pal_tcp_connect(const char *host, uint16_t port, uint32_t timeout_m h->fd = fd; h->last_rcvtimeo_ms = UINT32_MAX; h->last_sndtimeo_ms = UINT32_MAX; + h->pal = NULL; return h; } static int pal_tcp_send(void *handle, const uint8_t *buf, size_t len, uint32_t timeout_ms) { - fr_tcp_t *h = (fr_tcp_t *)handle; + fr_socket_t *h = (fr_socket_t *)handle; + if (!h || !buf || !len || len > INT_MAX) return PAL_ERR_INVALID; int flags = MSG_NOSIGNAL; if (timeout_ms == 0) { @@ -202,11 +214,11 @@ static int pal_tcp_send(void *handle, const uint8_t *buf, size_t len, int n; do { n = send(h->fd, buf, len, flags); - } while (n < 0 && errno == EINTR); + } while (n < 0 && errno == EINTR && timeout_ms != 0); if (n > 0) return n; if (n == 0) return 0; - if (errno == EAGAIN || errno == EWOULDBLOCK) + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return PAL_ERR_AGAIN; log_tag_error("pal", "tcp_send failed: errno=%d", errno); return PAL_ERR_NET; @@ -215,7 +227,8 @@ static int pal_tcp_send(void *handle, const uint8_t *buf, size_t len, static int pal_tcp_recv(void *handle, uint8_t *buf, size_t buf_len, uint32_t timeout_ms) { - fr_tcp_t *h = (fr_tcp_t *)handle; + fr_socket_t *h = (fr_socket_t *)handle; + if (!h || !buf || !buf_len || buf_len > INT_MAX) return PAL_ERR_INVALID; int flags = 0; if (timeout_ms == 0) { @@ -232,11 +245,11 @@ static int pal_tcp_recv(void *handle, uint8_t *buf, size_t buf_len, int n; do { n = recv(h->fd, buf, buf_len, flags); - } while (n < 0 && errno == EINTR); + } while (n < 0 && errno == EINTR && timeout_ms != 0); if (n > 0) return n; if (n == 0) return 0; /* EOF */ - if (errno == EAGAIN || errno == EWOULDBLOCK) + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return PAL_ERR_AGAIN; log_tag_error("pal", "tcp_recv failed: errno=%d", errno); return PAL_ERR_NET; @@ -244,7 +257,8 @@ static int pal_tcp_recv(void *handle, uint8_t *buf, size_t buf_len, static int pal_tcp_poll(void *handle, int events, uint32_t timeout_ms) { - fr_tcp_t *h = (fr_tcp_t *)handle; + fr_socket_t *h = (fr_socket_t *)handle; + if (!h || !(events & 3) || (events & ~3)) return PAL_ERR_INVALID; fd_set rfds, wfds; FD_ZERO(&rfds); FD_ZERO(&wfds); @@ -259,7 +273,7 @@ static int pal_tcp_poll(void *handle, int events, uint32_t timeout_ms) (events & 1) ? &rfds : NULL, (events & 2) ? &wfds : NULL, NULL, &tv); - if (sel < 0) return -3; + if (sel < 0) return errno == EINTR ? PAL_ERR_AGAIN : PAL_ERR_NET; if (sel == 0) return 0; int result = 0; @@ -271,11 +285,186 @@ static int pal_tcp_poll(void *handle, int events, uint32_t timeout_ms) static void pal_tcp_close(void *handle) { if (!handle) return; - fr_tcp_t *h = (fr_tcp_t *)handle; + fr_socket_t *h = (fr_socket_t *)handle; close(h->fd); - vPortFree(h); + if (h->pal) h->pal->free(h); + else vPortFree(h); } +#if AGENTIC_KIT_ENABLE_LAN +/* ------------------------------------------------------------------------- + * Optional IPv4 server and datagram operations + * ------------------------------------------------------------------------- */ +static int pal_socket_error(void) +{ + return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR + ? PAL_ERR_AGAIN : PAL_ERR_NET; +} + +static int pal_socket_address(const char *ip, uint16_t port, + struct sockaddr_in *address) +{ + memset(address, 0, sizeof(*address)); + address->sin_family = AF_INET; + address->sin_port = htons(port); + if (!ip || !*ip) address->sin_addr.s_addr = htonl(INADDR_ANY); + else if (inet_pton(AF_INET, ip, &address->sin_addr) != 1) + return PAL_ERR_INVALID; + return 0; +} + +static int pal_socket_mode(int fd, bool nonblocking) +{ + int flags = lwip_fcntl(fd, F_GETFL, 0); + if (flags < 0 || lwip_fcntl(fd, F_SETFL, + nonblocking ? flags | O_NONBLOCK + : flags & ~O_NONBLOCK) < 0) + return PAL_ERR_NET; + return 0; +} + +static int pal_socket_handle(const pal_t *pal, int fd, void **out) +{ + fr_socket_t *h = pal->malloc(sizeof(*h)); + if (!h) { + log_tag_error("pal", "socket handle alloc failed"); + close(fd); + return PAL_ERR_NET; + } + h->fd = fd; + h->last_rcvtimeo_ms = UINT32_MAX; + h->last_sndtimeo_ms = UINT32_MAX; + h->pal = pal; + *out = h; + return 0; +} + +static int pal_socket_bind(const pal_t *pal, const char *ip, uint16_t port, + int type, bool broadcast, void **out) +{ + struct sockaddr_in address; + if (!out) return PAL_ERR_INVALID; + *out = NULL; + if (!pal || !pal->malloc || !pal->free || + pal_socket_address(ip, port, &address)) return PAL_ERR_INVALID; + int fd = socket(AF_INET, type, 0); + if (fd < 0) return PAL_ERR_NET; + int one = 1; + if (pal_socket_mode(fd, true) || + (type == SOCK_STREAM && setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, + &one, sizeof(one)) < 0) || + (broadcast && setsockopt(fd, SOL_SOCKET, SO_BROADCAST, + &one, sizeof(one)) < 0) || + bind(fd, (struct sockaddr *)&address, sizeof(address)) < 0) { + close(fd); + return PAL_ERR_NET; + } + return pal_socket_handle(pal, fd, out); +} + +static int pal_tcp_listen(const pal_t *pal, const char *ip, uint16_t port, + unsigned backlog, void **out) +{ + if (!out) return PAL_ERR_INVALID; + *out = NULL; + if (!backlog || backlog > INT_MAX) return PAL_ERR_INVALID; + int rc = pal_socket_bind(pal, ip, port, SOCK_STREAM, false, out); + if (rc) return rc; + fr_socket_t *h = *out; + if (listen(h->fd, (int)backlog) < 0) { + pal_tcp_close(h); + *out = NULL; + return PAL_ERR_NET; + } + return 0; +} + +static int pal_tcp_accept(void *listener, void **out, pal_addr_t *peer) +{ + struct sockaddr_in address; + socklen_t len = sizeof(address); + if (!out) return PAL_ERR_INVALID; + *out = NULL; + fr_socket_t *h = listener; + if (!h || !h->pal) return PAL_ERR_INVALID; + int fd = accept(h->fd, (struct sockaddr *)&address, &len); + if (fd < 0) return pal_socket_error(); + /* The shared TCP send/recv callbacks use MSG_DONTWAIT for timeout 0 and + * SO_*TIMEO for positive timeouts, so accepted sockets must be blocking. */ + if (pal_socket_mode(fd, false)) { close(fd); return PAL_ERR_NET; } + int one = 1; + if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)) < 0) { + close(fd); + return PAL_ERR_NET; + } + if (peer) { + memset(peer, 0, sizeof(*peer)); + if (!inet_ntop(AF_INET, &address.sin_addr, peer->ip, sizeof(peer->ip))) { + close(fd); + return PAL_ERR_NET; + } + peer->port = ntohs(address.sin_port); + } + return pal_socket_handle(h->pal, fd, out); +} + +static int pal_udp_bind(const pal_t *pal, const char *ip, uint16_t port, + bool broadcast, void **out) +{ + return pal_socket_bind(pal, ip, port, SOCK_DGRAM, broadcast, out); +} + +static int pal_udp_sendto(void *socket_handle, const pal_addr_t *peer, + const uint8_t *data, size_t len) +{ + struct sockaddr_in address; + if (!socket_handle || !peer || !data || len > INT_MAX || + pal_socket_address(peer->ip, peer->port, &address)) + return PAL_ERR_INVALID; + fr_socket_t *h = socket_handle; + int n = (int)sendto(h->fd, data, len, MSG_NOSIGNAL, + (struct sockaddr *)&address, sizeof(address)); + return n >= 0 ? n : pal_socket_error(); +} + +static int pal_udp_recvfrom(void *socket_handle, pal_addr_t *peer, + uint8_t *data, size_t capacity) +{ + if (!socket_handle || !peer || !data || !capacity || capacity >= INT_MAX) + return PAL_ERR_INVALID; + fr_socket_t *h = socket_handle; + struct sockaddr_in address; + /* lwIP recvmsg reports truncation while consuming the complete datagram. */ + struct iovec iov = { data, capacity }; + struct msghdr msg; + memset(&msg, 0, sizeof(msg)); + msg.msg_name = &address; + msg.msg_namelen = sizeof(address); + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + int n = (int)recvmsg(h->fd, &msg, 0); + if (n < 0) return pal_socket_error(); + if ((msg.msg_flags & MSG_TRUNC) || (size_t)n > capacity) return PAL_ERR_NET; + memset(peer, 0, sizeof(*peer)); + if (!inet_ntop(AF_INET, &address.sin_addr, peer->ip, sizeof(peer->ip))) + return PAL_ERR_NET; + peer->port = ntohs(address.sin_port); + return n; +} + +static int pal_socket_local_port(void *socket_handle, uint16_t *port) +{ + struct sockaddr_in address; + socklen_t len = sizeof(address); + if (!socket_handle || !port) return PAL_ERR_INVALID; + fr_socket_t *h = socket_handle; + if (getsockname(h->fd, (struct sockaddr *)&address, &len) < 0) + return PAL_ERR_NET; + *port = ntohs(address.sin_port); + return 0; +} +#endif + /* ------------------------------------------------------------------------- * Time * ------------------------------------------------------------------------- */ @@ -463,6 +652,15 @@ static const pal_t g_freertos_pal = { .thread_create = pal_thread_create, .thread_join = pal_thread_join, .sleep_ms = pal_sleep_ms, +#if AGENTIC_KIT_ENABLE_LAN + .tcp_listen = pal_tcp_listen, + .tcp_accept = pal_tcp_accept, + .udp_bind = pal_udp_bind, + .udp_sendto = pal_udp_sendto, + .udp_recvfrom = pal_udp_recvfrom, + .socket_local_port = pal_socket_local_port, + .udp_close = pal_tcp_close, +#endif }; const pal_t *tai_pal_freertos(void) diff --git a/pal/pal_posix.c b/pal/pal_posix.c index babab6e..369fd6e 100644 --- a/pal/pal_posix.c +++ b/pal/pal_posix.c @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -23,11 +24,16 @@ #include #include #include +#include #include #include "pal.h" #include "log.h" +#ifndef AGENTIC_KIT_ENABLE_LAN +#define AGENTIC_KIT_ENABLE_LAN 0 +#endif + /* Suppress SIGPIPE on send() when peer has closed the connection. * Linux uses MSG_NOSIGNAL per-call; macOS/BSD uses SO_NOSIGPIPE socket option. */ #ifndef MSG_NOSIGNAL @@ -35,13 +41,14 @@ #endif /* ------------------------------------------------------------------------- - * TCP socket handle + * Shared handle for TCP connections, listeners and UDP sockets * ------------------------------------------------------------------------- */ typedef struct { int fd; uint32_t last_rcvtimeo_ms; uint32_t last_sndtimeo_ms; -} posix_tcp_t; + const pal_t *pal; /* NULL for legacy tcp_connect handles. */ +} posix_socket_t; static void *pal_tcp_connect(const char *host, uint16_t port, uint32_t timeout_ms) { @@ -126,7 +133,7 @@ static void *pal_tcp_connect(const char *host, uint16_t port, uint32_t timeout_m setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &flag, sizeof(flag)); #endif - posix_tcp_t *h = (posix_tcp_t *)malloc(sizeof(posix_tcp_t)); + posix_socket_t *h = (posix_socket_t *)malloc(sizeof(posix_socket_t)); if (!h) { log_tag_error("pal", "tcp handle alloc failed"); close(fd); @@ -135,13 +142,15 @@ static void *pal_tcp_connect(const char *host, uint16_t port, uint32_t timeout_m h->fd = fd; h->last_rcvtimeo_ms = UINT32_MAX; h->last_sndtimeo_ms = UINT32_MAX; + h->pal = NULL; return h; } static int pal_tcp_send(void *handle, const uint8_t *buf, size_t len, uint32_t timeout_ms) { - posix_tcp_t *h = (posix_tcp_t *)handle; + posix_socket_t *h = (posix_socket_t *)handle; + if (!h || !buf || !len || len > INT_MAX) return PAL_ERR_INVALID; int flags = MSG_NOSIGNAL; if (timeout_ms == 0) { @@ -158,11 +167,12 @@ static int pal_tcp_send(void *handle, const uint8_t *buf, size_t len, ssize_t n; do { n = send(h->fd, buf, len, flags); - } while (n < 0 && errno == EINTR); + } while (n < 0 && errno == EINTR && timeout_ms != 0); if (n > 0) return (int)n; if (n == 0) return 0; - if (errno == EAGAIN || errno == EWOULDBLOCK) + if (errno == EAGAIN || errno == EWOULDBLOCK || + (errno == EINTR && timeout_ms == 0)) return PAL_ERR_AGAIN; log_tag_error("pal", "tcp_send failed: %s (errno=%d)", strerror(errno), errno); return PAL_ERR_NET; @@ -171,7 +181,8 @@ static int pal_tcp_send(void *handle, const uint8_t *buf, size_t len, static int pal_tcp_recv(void *handle, uint8_t *buf, size_t buf_len, uint32_t timeout_ms) { - posix_tcp_t *h = (posix_tcp_t *)handle; + posix_socket_t *h = (posix_socket_t *)handle; + if (!h || !buf || !buf_len || buf_len > INT_MAX) return PAL_ERR_INVALID; int flags = 0; if (timeout_ms == 0) { @@ -188,11 +199,12 @@ static int pal_tcp_recv(void *handle, uint8_t *buf, size_t buf_len, ssize_t n; do { n = recv(h->fd, buf, buf_len, flags); - } while (n < 0 && errno == EINTR); + } while (n < 0 && errno == EINTR && timeout_ms != 0); if (n > 0) return (int)n; if (n == 0) return 0; /* EOF */ - if (errno == EAGAIN || errno == EWOULDBLOCK) + if (errno == EAGAIN || errno == EWOULDBLOCK || + (errno == EINTR && timeout_ms == 0)) return PAL_ERR_AGAIN; log_tag_error("pal", "tcp_recv failed: %s (errno=%d)", strerror(errno), errno); return PAL_ERR_NET; @@ -200,7 +212,8 @@ static int pal_tcp_recv(void *handle, uint8_t *buf, size_t buf_len, static int pal_tcp_poll(void *handle, int events, uint32_t timeout_ms) { - posix_tcp_t *h = (posix_tcp_t *)handle; + posix_socket_t *h = (posix_socket_t *)handle; + if (!h || !(events & 3) || (events & ~3)) return PAL_ERR_INVALID; fd_set rfds, wfds; FD_ZERO(&rfds); FD_ZERO(&wfds); @@ -215,7 +228,8 @@ static int pal_tcp_poll(void *handle, int events, uint32_t timeout_ms) (events & 1) ? &rfds : NULL, (events & 2) ? &wfds : NULL, NULL, &tv); - if (sel < 0) return -3; + if (sel < 0) return errno == EINTR && timeout_ms == 0 + ? PAL_ERR_AGAIN : PAL_ERR_NET; if (sel == 0) return 0; int result = 0; @@ -227,11 +241,189 @@ static int pal_tcp_poll(void *handle, int events, uint32_t timeout_ms) static void pal_tcp_close(void *handle) { if (!handle) return; - posix_tcp_t *h = (posix_tcp_t *)handle; + posix_socket_t *h = (posix_socket_t *)handle; close(h->fd); - free(h); + if (h->pal) h->pal->free(h); + else free(h); } +#if AGENTIC_KIT_ENABLE_LAN +/* ------------------------------------------------------------------------- + * Optional TCP server and UDP operations, sharing the TCP handle and I/O. + * ------------------------------------------------------------------------- */ +static int pal_socket_error(void) +{ + return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR + ? PAL_ERR_AGAIN : PAL_ERR_NET; +} + +static int pal_socket_address(const char *ip, uint16_t port, + struct sockaddr_in *address) +{ + memset(address, 0, sizeof(*address)); + address->sin_family = AF_INET; + address->sin_port = htons(port); + if (!ip || !*ip) address->sin_addr.s_addr = htonl(INADDR_ANY); + else if (inet_pton(AF_INET, ip, &address->sin_addr) != 1) + return PAL_ERR_INVALID; + return 0; +} + +static int pal_socket_prepare(int fd, bool nonblocking) +{ + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0 || fcntl(fd, F_SETFL, nonblocking ? flags | O_NONBLOCK + : flags & ~O_NONBLOCK) < 0) + return PAL_ERR_NET; +#ifdef SO_NOSIGPIPE + int one = 1; + if (setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)) < 0) + return PAL_ERR_NET; +#endif + return 0; +} + +static int pal_socket_handle(const pal_t *pal, int fd, void **out) +{ + posix_socket_t *h = pal->malloc(sizeof(*h)); + if (!h) { + log_tag_error("pal", "socket handle alloc failed"); + close(fd); + return PAL_ERR_NET; + } + h->fd = fd; + h->last_rcvtimeo_ms = UINT32_MAX; + h->last_sndtimeo_ms = UINT32_MAX; + h->pal = pal; + *out = h; + return 0; +} + +static int pal_socket_bind(const pal_t *pal, const char *ip, uint16_t port, + int type, bool broadcast, void **out) +{ + struct sockaddr_in address; + if (!out) return PAL_ERR_INVALID; + *out = NULL; + if (!pal || !pal->malloc || !pal->free || + pal_socket_address(ip, port, &address)) return PAL_ERR_INVALID; + int fd = socket(AF_INET, type, 0); + if (fd < 0) return PAL_ERR_NET; + int one = 1; + if (pal_socket_prepare(fd, true) || + (type == SOCK_STREAM && setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, + &one, sizeof(one)) < 0) || + (broadcast && setsockopt(fd, SOL_SOCKET, SO_BROADCAST, + &one, sizeof(one)) < 0) || + bind(fd, (struct sockaddr *)&address, sizeof(address)) < 0) { + close(fd); + return PAL_ERR_NET; + } + return pal_socket_handle(pal, fd, out); +} + +static int pal_tcp_listen(const pal_t *pal, const char *ip, uint16_t port, + unsigned backlog, void **out) +{ + if (!out) return PAL_ERR_INVALID; + *out = NULL; + if (!backlog || backlog > INT_MAX) return PAL_ERR_INVALID; + int rc = pal_socket_bind(pal, ip, port, SOCK_STREAM, false, out); + if (rc) return rc; + posix_socket_t *h = *out; + if (listen(h->fd, (int)backlog) < 0) { + pal_tcp_close(h); + *out = NULL; + return PAL_ERR_NET; + } + return 0; +} + +static int pal_tcp_accept(void *handle, void **out, pal_addr_t *peer) +{ + posix_socket_t *h = handle; + struct sockaddr_in address; + socklen_t len = sizeof(address); + if (!out) return PAL_ERR_INVALID; + *out = NULL; + if (!h || !h->pal) return PAL_ERR_INVALID; + int fd = accept(h->fd, (struct sockaddr *)&address, &len); + if (fd < 0) return pal_socket_error(); + /* Accepted sockets retain bounded blocking I/O for timeout_ms > 0; + * tcp_send/recv use MSG_DONTWAIT for the LAN loop's timeout_ms == 0. */ + if (pal_socket_prepare(fd, false)) { close(fd); return PAL_ERR_NET; } + int one = 1; + if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)) < 0) { + close(fd); + return PAL_ERR_NET; + } + if (peer) { + memset(peer, 0, sizeof(*peer)); + if (!inet_ntop(AF_INET, &address.sin_addr, peer->ip, sizeof(peer->ip))) { + close(fd); + return PAL_ERR_NET; + } + peer->port = ntohs(address.sin_port); + } + return pal_socket_handle(h->pal, fd, out); +} + +static int pal_udp_bind(const pal_t *pal, const char *ip, uint16_t port, + bool broadcast, void **out) +{ + return pal_socket_bind(pal, ip, port, SOCK_DGRAM, broadcast, out); +} + +static int pal_udp_sendto(void *handle, const pal_addr_t *peer, + const uint8_t *data, size_t len) +{ + posix_socket_t *h = handle; + struct sockaddr_in address; + if (!h || !peer || !data || len > INT_MAX || + pal_socket_address(peer->ip, peer->port, &address)) return PAL_ERR_INVALID; + int n = (int)sendto(h->fd, data, len, MSG_NOSIGNAL, + (struct sockaddr *)&address, sizeof(address)); + return n >= 0 ? n : pal_socket_error(); +} + +static int pal_udp_recvfrom(void *handle, pal_addr_t *peer, + uint8_t *data, size_t capacity) +{ + posix_socket_t *h = handle; + if (!h || !peer || !data || !capacity || capacity >= INT_MAX) + return PAL_ERR_INVALID; + struct sockaddr_in address; + /* recvmsg consumes an oversized datagram while reporting truncation. */ + struct iovec iov = { data, capacity }; + struct msghdr msg; + memset(&msg, 0, sizeof(msg)); + msg.msg_name = &address; + msg.msg_namelen = sizeof(address); + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + int n = (int)recvmsg(h->fd, &msg, 0); + if (n < 0) return pal_socket_error(); + if ((msg.msg_flags & MSG_TRUNC) || (size_t)n > capacity) return PAL_ERR_NET; + memset(peer, 0, sizeof(*peer)); + if (!inet_ntop(AF_INET, &address.sin_addr, peer->ip, sizeof(peer->ip))) + return PAL_ERR_NET; + peer->port = ntohs(address.sin_port); + return n; +} + +static int pal_socket_local_port(void *handle, uint16_t *port) +{ + posix_socket_t *h = handle; + struct sockaddr_in address; + socklen_t len = sizeof(address); + if (!h || !port) return PAL_ERR_INVALID; + if (getsockname(h->fd, (struct sockaddr *)&address, &len) < 0) + return PAL_ERR_NET; + *port = ntohs(address.sin_port); + return 0; +} +#endif /* AGENTIC_KIT_ENABLE_LAN */ + /* ------------------------------------------------------------------------- * Time * ------------------------------------------------------------------------- */ @@ -324,6 +516,15 @@ static const pal_t g_posix_pal = { .thread_create = pal_thread_create, .thread_join = pal_thread_join, .sleep_ms = pal_sleep_ms, +#if AGENTIC_KIT_ENABLE_LAN + .tcp_listen = pal_tcp_listen, + .tcp_accept = pal_tcp_accept, + .udp_bind = pal_udp_bind, + .udp_sendto = pal_udp_sendto, + .udp_recvfrom = pal_udp_recvfrom, + .socket_local_port = pal_socket_local_port, + .udp_close = pal_tcp_close, +#endif }; const pal_t *tai_pal_posix(void) diff --git a/pal/test/lwip/inet.h b/pal/test/lwip/inet.h new file mode 100644 index 0000000..8958f67 --- /dev/null +++ b/pal/test/lwip/inet.h @@ -0,0 +1,7 @@ +/* Host IPv4 declarations for compiling the production FreeRTOS PAL. */ +#ifndef PAL_TEST_LWIP_INET_H +#define PAL_TEST_LWIP_INET_H + +#include + +#endif diff --git a/pal/test/test_freertos_time.c b/pal/test/test_freertos_time.c index 259587e..38aaa09 100644 --- a/pal/test/test_freertos_time.c +++ b/pal/test/test_freertos_time.c @@ -93,6 +93,12 @@ static uint64_t wrap_boundary(void) static int test_units_and_resolution(void) { CHECK(pal_is_valid(tai_pal_freertos())); + /* Clock targets compile the real port with optional LAN code disabled. */ + const pal_t *pal = tai_pal_freertos(); + CHECK(!pal_has_server_network(pal)); + CHECK(!pal->tcp_listen && !pal->tcp_accept && !pal->udp_bind && + !pal->udp_sendto && !pal->udp_recvfrom && !pal->socket_local_port && + !pal->udp_close); set_ticks(1); CHECK(tai_pal_freertos()->time_ms() == expected_time(1)); set_ticks(TEST_TICK_HZ); diff --git a/pal/test/test_net.c b/pal/test/test_net.c new file mode 100644 index 0000000..e442a76 --- /dev/null +++ b/pal/test/test_net.c @@ -0,0 +1,216 @@ +#include "pal.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef PAL_TEST_FREERTOS +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +extern const pal_t *tai_pal_freertos(void); +/* Network tests use the host monotonic clock, not the fake kernel clock. */ +void vTaskSetTimeOutState(TimeOut_t *snapshot) { memset(snapshot, 0, sizeof(*snapshot)); } +#define default_pal tai_pal_freertos +#else +extern const pal_t *tai_pal_posix(void); +#define default_pal tai_pal_posix +#endif + +static int failures, cases, live_handles, fail_alloc; +#define CHECK(e) do { if (!(e)) { fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #e); failures++; return; } } while (0) +#define RUN(fn) do { cases++; fn(); } while (0) +static void *tracked_malloc(size_t n) +{ + if (fail_alloc) return NULL; + void *p = malloc(n); + if (p) live_handles++; + return p; +} +static void tracked_free(void *p) { if (p) live_handles--; free(p); } +static const pal_t memory = { .malloc = tracked_malloc, .free = tracked_free }; + +static int wait_udp(void *h, pal_addr_t *from, uint8_t *buf, size_t len) +{ + for (int i = 0; i < 200; i++) { + int rc = default_pal()->udp_recvfrom(h, from, buf, len); + if (rc != PAL_ERR_AGAIN) return rc; + poll(NULL, 0, 1); + } + return PAL_ERR_AGAIN; +} +static int wait_accept(void *h, void **out, pal_addr_t *peer) +{ + for (int i = 0; i < 200; i++) { + int rc = default_pal()->tcp_accept(h, out, peer); + if (rc != PAL_ERR_AGAIN) return rc; + poll(NULL, 0, 1); + } + return PAL_ERR_AGAIN; +} +static int wait_tcp(void *h, uint8_t *buf, size_t len) +{ + for (int i = 0; i < 200; i++) { + int rc = default_pal()->tcp_recv(h, buf, len, 0); + if (rc != PAL_ERR_AGAIN) return rc; + poll(NULL, 0, 1); + } + return PAL_ERR_AGAIN; +} + +static void test_invalid(void) +{ + const pal_t *n = default_pal(); + void *h = (void *)1; + CHECK(pal_is_valid(n) && pal_has_server_network(n)); + CHECK(!pal_has_server_network(NULL)); + CHECK(n->udp_bind(&memory, "bad", 0, false, &h) == PAL_ERR_INVALID); + CHECK(h == NULL); + CHECK(n->tcp_listen(NULL, "127.0.0.1", 0, 3, &h) == PAL_ERR_INVALID); + CHECK(n->tcp_send(NULL, (const uint8_t *)"x", 1, 0) == PAL_ERR_INVALID); + h = (void *)1; + CHECK(n->tcp_accept(NULL, &h, NULL) == PAL_ERR_INVALID && h == NULL); + CHECK(n->tcp_listen(&memory, "127.0.0.1", 0, 0, &h) == PAL_ERR_INVALID && h == NULL); + n->tcp_close(NULL); n->udp_close(NULL); +} + +static void test_udp(void) +{ + const pal_t *n = default_pal(); + void *a = NULL, *b = NULL; + pal_addr_t peer = { "127.0.0.1", 0 }, from; + uint8_t buf[8] = {0}; + CHECK(n->udp_bind(&memory, "127.0.0.1", 0, true, &a) == 0); + CHECK(n->udp_bind(&memory, "127.0.0.1", 0, false, &b) == 0); + CHECK(n->socket_local_port(b, &peer.port) == 0 && peer.port != 0); + CHECK(n->udp_recvfrom(b, &from, buf, sizeof(buf)) == PAL_ERR_AGAIN); + CHECK(n->udp_sendto(a, &peer, (const uint8_t *)"hello", 5) == 5); + CHECK(wait_udp(b, &from, buf, sizeof(buf)) == 5); + CHECK(!memcmp(buf, "hello", 5) && !strcmp(from.ip, "127.0.0.1")); + uint16_t a_port; + CHECK(n->socket_local_port(a, &a_port) == 0 && from.port == a_port); + CHECK(n->udp_sendto(b, &from, (const uint8_t *)"", 0) == 0); + CHECK(wait_udp(a, &peer, buf, sizeof(buf)) == 0); + n->udp_close(a); n->udp_close(b); +} + +static void test_udp_oversize(void) +{ + const pal_t *n = default_pal(); + void *a = NULL, *b = NULL; + pal_addr_t peer = { "127.0.0.1", 0 }, from; + uint8_t guarded[5] = {0, 0, 0, 0, 0xa5}; + CHECK(n->udp_bind(&memory, "127.0.0.1", 0, false, &a) == 0); + CHECK(n->udp_bind(&memory, "127.0.0.1", 0, false, &b) == 0); + CHECK(n->socket_local_port(b, &peer.port) == 0); + CHECK(n->udp_sendto(a, &peer, (const uint8_t *)"oversize", 8) == 8); + CHECK(wait_udp(b, &from, guarded, 4) == PAL_ERR_NET); + CHECK(guarded[4] == 0xa5); + CHECK(n->udp_recvfrom(b, &from, guarded, 4) == PAL_ERR_AGAIN); + n->udp_close(a); n->udp_close(b); +} + +static void test_tcp(void) +{ + const pal_t *n = default_pal(); + void *listener = NULL, *accepted = NULL; + pal_addr_t peer; + uint16_t port; + uint8_t buf[8]; + CHECK(n->tcp_listen(&memory, "127.0.0.1", 0, 3, &listener) == 0); + CHECK(n->socket_local_port(listener, &port) == 0 && port != 0); + CHECK(n->tcp_accept(listener, &accepted, &peer) == PAL_ERR_AGAIN); + void *client = n->tcp_connect("127.0.0.1", port, 1000); + CHECK(client != NULL); + CHECK(wait_accept(listener, &accepted, &peer) == 0); + CHECK(!strcmp(peer.ip, "127.0.0.1") && peer.port != 0); + uint16_t local_port; + CHECK(n->socket_local_port(accepted, &local_port) == 0 && local_port == port); + CHECK(n->socket_local_port(client, &local_port) == 0 && local_port == peer.port); + CHECK(n->tcp_recv(accepted, buf, sizeof(buf), 0) == PAL_ERR_AGAIN); + CHECK(n->tcp_send(client, (const uint8_t *)"abc", 3, 1000) == 3); + CHECK(wait_tcp(accepted, buf, sizeof(buf)) == 3 && !memcmp(buf, "abc", 3)); + CHECK(n->tcp_send(accepted, (const uint8_t *)"def", 3, 0) == 3); + CHECK(n->tcp_recv(client, buf, sizeof(buf), 1000) == 3 && !memcmp(buf, "def", 3)); + n->tcp_close(client); + CHECK(wait_tcp(accepted, buf, sizeof(buf)) == 0); + n->tcp_close(accepted); n->tcp_close(listener); +} + +static uint64_t monotonic_ms(void) +{ + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) abort(); + return (uint64_t)now.tv_sec * 1000 + (uint64_t)now.tv_nsec / 1000000; +} + +/* A listening socket is nonblocking, but accepted and connected TCP must both + * honor positive timeouts, then return to nonblocking behavior at timeout 0. */ +static void test_tcp_timeouts(void) +{ + const pal_t *n = default_pal(); + void *listener = NULL, *accepted = NULL; + uint16_t port; + uint8_t byte; + CHECK(n->tcp_listen(&memory, "127.0.0.1", 0, 3, &listener) == 0); + CHECK(n->socket_local_port(listener, &port) == 0); + void *client = n->tcp_connect("127.0.0.1", port, 1000); + CHECK(client != NULL); + CHECK(wait_accept(listener, &accepted, NULL) == 0); + void *ends[] = { accepted, client }; + for (size_t i = 0; i < sizeof(ends) / sizeof(ends[0]); i++) { + uint64_t start = monotonic_ms(); + CHECK(n->tcp_recv(ends[i], &byte, 1, 40) == PAL_ERR_AGAIN); + CHECK(monotonic_ms() - start >= 10); + start = monotonic_ms(); + CHECK(n->tcp_recv(ends[i], &byte, 1, 0) == PAL_ERR_AGAIN); + CHECK(monotonic_ms() - start < 100); + CHECK(n->tcp_poll(ends[i], 1, 0) == 0); + CHECK(n->tcp_poll(ends[i], 2, 0) == 2); + } + n->tcp_close(client); n->tcp_close(accepted); n->tcp_close(listener); +} + +static void test_accept_allocation_failure(void) +{ + const pal_t *n = default_pal(); + void *listener = NULL, *accepted = (void *)1; + uint16_t port; + CHECK(n->tcp_listen(&memory, "127.0.0.1", 0, 3, &listener) == 0); + CHECK(n->socket_local_port(listener, &port) == 0); + void *client = n->tcp_connect("127.0.0.1", port, 1000); + CHECK(client != NULL); + fail_alloc = 1; + int rc = wait_accept(listener, &accepted, NULL); + fail_alloc = 0; + CHECK(rc == PAL_ERR_NET && accepted == NULL); + CHECK(live_handles == 1); + uint8_t byte; + CHECK(wait_tcp(client, &byte, 1) == 0); + n->tcp_close(client); n->tcp_close(listener); +} + +static void test_open_allocation_failure(void) +{ + const pal_t *n = default_pal(); + void *h = NULL; + fail_alloc = 1; + int rc = n->udp_bind(&memory, "127.0.0.1", 0, false, &h); + fail_alloc = 0; + CHECK(rc == PAL_ERR_NET && h == NULL); + CHECK(live_handles == 0); +} + +int main(void) +{ + RUN(test_invalid); RUN(test_udp); RUN(test_udp_oversize); + RUN(test_tcp); RUN(test_tcp_timeouts); RUN(test_accept_allocation_failure); + RUN(test_open_allocation_failure); + if (live_handles != 0) { fprintf(stderr, "leaked %d handles\n", live_handles); failures++; } + printf("pal_net: %d cases, %d failures\n", cases, failures); + return failures ? 1 : 0; +} diff --git a/pal/test/test_posix_time.c b/pal/test/test_posix_time.c index 78de481..f55cb85 100644 --- a/pal/test/test_posix_time.c +++ b/pal/test/test_posix_time.c @@ -49,6 +49,11 @@ static int test_table(void) { const pal_t *pal = tai_pal_posix(); CHECK(pal_is_valid(pal)); + /* This target compiles the real port with optional LAN code disabled. */ + CHECK(!pal_has_server_network(pal)); + CHECK(!pal->tcp_listen && !pal->tcp_accept && !pal->udp_bind && + !pal->udp_sendto && !pal->udp_recvfrom && !pal->socket_local_port && + !pal->udp_close); pal_t incomplete = *pal; incomplete.wal_clock_time = NULL; CHECK(!pal_is_valid(&incomplete)); diff --git a/tools/check_freertos_pal.py b/tools/check_freertos_pal.py index d725970..64e5507 100644 --- a/tools/check_freertos_pal.py +++ b/tools/check_freertos_pal.py @@ -134,7 +134,7 @@ def main(): command = compiler + [ "-std=c99", "-Wall", "-Wextra", "-Werror", "-fsyntax-only", f"-DTEST_KERNEL_MAJOR={major}", f"-DTEST_TICK_BITS={bits}", - "-DAGENTIC_KIT_LOG_LEVEL=0", + "-DAGENTIC_KIT_LOG_LEVEL=0", "-DAGENTIC_KIT_ENABLE_LAN=1", # Strict C99 hides POSIX declarations in glibc's netdb.h # (struct addrinfo, getaddrinfo); the shim passes through to # the host headers, so request POSIX.1-2001 explicitly.