From fc55bf1198571296aa79928933e6032d605b47ec 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 17:55:10 +0800 Subject: [PATCH] feat(pal)!: separate UTC timestamps from monotonic time Cloud signing and media timestamps need Unix UTC, while elapsed-time budgets and Connection liveness must survive clock corrections. Require PAL UTC in milliseconds for cloud signing, DP reports, AI role requests and media/Ping timestamps; retain time_ms for monotonic elapsed time. Applications own UTC synchronization and expose the same source to TLS certificate-time checks. Keep POSIX CLOCK_REALTIME. The FreeRTOS default UTC callback reads libc gettimeofday(), converts to Unix UTC milliseconds and returns zero on a failed read or non-positive Unix seconds. ESP-IDF examples use the factory PAL directly, shared across IoT and RTC. Applications maintain libc UTC through platform synchronization and may replace the callback when that source is unsuitable. Read coherent FreeRTOS tick/overflow snapshots and preserve wrap counts and fractional-millisecond tick frequencies. Warn at ATOP signing, nonempty DP reports and RTC Connection open when a real UTC callback returns zero. Empty DP report requests stay silent because they publish no payload. AI role requests use PAL UTC for both their body timestamp and synchronization gate, keeping them consistent with signing. Add clock, wire-timestamp and diagnostic regressions, bilingual porting guides, ADR 0003 and release notes. Check pinned upstream FreeRTOS headers in both CI paths, with download retries and absolute, immutable cache paths. Independently stage and atomically publish downloads so concurrent checks reuse a complete winner without deleting active headers. Register offline relative-path, concurrency and extraction-recovery regressions with CTest, and retain the business-client suite in the GitLab leak-check list. BREAKING CHANGE: Custom PALs must supply wal_clock_time as Unix UTC milliseconds and rebuild all SDK/PAL consumers. Both time callbacks are mandatory. FreeRTOS applications may use the default gettimeofday() callback when their platform exposes UTC through libc, or replace it before initialization when another source is needed. Verified: full serial CTest 29/29 suites; DP 18/18 cases; offline cache 6/6 cases; pinned FreeRTOS C99 checks 5/5; ASan/UBSan DP 1/1 suite (18/18 cases). Co-Authored-By: Claude Code Co-Authored-By: OpenAI Codex --- .github/workflows/ci.yml | 3 + .gitlab-ci.yml | 6 +- AGENTS.md | 22 +- CHANGELOG.md | 14 + CMakeLists.txt | 45 +++ docs-site/docs/concepts.md | 2 +- docs-site/docs/guides/atop-generic-call.md | 3 +- docs-site/docs/guides/ota-upgrade.md | 8 +- .../docs/guides/porting-to-new-platform.md | 108 +++++-- .../current/concepts.md | 2 +- .../current/guides/atop-generic-call.md | 3 +- .../current/guides/ota-upgrade.md | 9 +- .../current/guides/porting-to-new-platform.md | 107 +++++-- .../esp-idf/ai/rtc-tcp-client/main/main.c | 3 +- examples/esp-idf/ota-demo/main/main.c | 8 +- examples/esp-idf/pair/pair-by-ble/main/main.c | 1 + examples/esp-idf/pair/scan-by-app/main/main.c | 1 + modules/biz-client/src/biz_client.c | 11 +- modules/biz-client/test/ai_role_test.c | 6 +- modules/iot-client/CONTEXT.md | 17 + ...03-pal-separates-utc-and-monotonic-time.md | 95 ++++++ modules/iot-client/include/iot_atop.h | 3 +- modules/iot-client/src/atop.c | 17 +- modules/iot-client/src/atop_base.c | 5 + modules/iot-client/src/iot_atop.c | 3 +- modules/iot-client/src/iot_client.c | 5 +- modules/iot-client/src/iot_dp.c | 9 +- modules/iot-client/test/iot_atop_call_test.c | 102 ++++++ modules/iot-client/test/iot_dp_test.c | 115 ++++++- modules/iot-client/test/mock/atop_mock.py | 21 +- modules/rtc-tcp-client/CONTEXT.md | 11 + modules/rtc-tcp-client/src/tai_client.c | 7 + modules/rtc-tcp-client/src/tai_protocol.c | 6 +- .../rtc-tcp-client/test/tai_pal_loopback.c | 10 +- .../rtc-tcp-client/test/tai_pal_loopback.h | 8 +- modules/rtc-tcp-client/test/test_core.c | 6 +- .../rtc-tcp-client/test/test_integration.c | 92 +++++- pal/pal.h | 32 +- pal/pal_freertos.c | 76 ++++- pal/pal_posix.c | 8 + pal/test/freertos/FreeRTOS.h | 28 ++ pal/test/freertos/semphr.h | 26 ++ pal/test/freertos/task.h | 26 ++ pal/test/freertos_compat/FreeRTOSConfig.h | 31 ++ pal/test/freertos_compat/portmacro.h | 26 ++ pal/test/lwip/netdb.h | 7 + pal/test/lwip/sockets.h | 13 + pal/test/test_freertos_time.c | 296 ++++++++++++++++++ pal/test/test_posix_time.c | 120 +++++++ tools/check_freertos_pal.py | 157 ++++++++++ tools/test_check_freertos_pal.py | 182 +++++++++++ 51 files changed, 1805 insertions(+), 117 deletions(-) create mode 100644 modules/iot-client/docs/adr/0003-pal-separates-utc-and-monotonic-time.md create mode 100644 pal/test/freertos/FreeRTOS.h create mode 100644 pal/test/freertos/semphr.h create mode 100644 pal/test/freertos/task.h create mode 100644 pal/test/freertos_compat/FreeRTOSConfig.h create mode 100644 pal/test/freertos_compat/portmacro.h create mode 100644 pal/test/lwip/netdb.h create mode 100644 pal/test/lwip/sockets.h create mode 100644 pal/test/test_freertos_time.c create mode 100644 pal/test/test_posix_time.c create mode 100644 tools/check_freertos_pal.py create mode 100644 tools/test_check_freertos_pal.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 709a732..cefd566 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: - name: Build run: cmake --build build -j 4 + - name: FreeRTOS PAL header compatibility + run: cmake --build build --target check_freertos_pal + - name: Test run: ctest --test-dir build --output-on-failure --no-tests=error --timeout 180 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0d704ba..a46efff 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -69,6 +69,9 @@ test: echo "Route them through the log_tag_* macros in common/log.h instead." exit 1 fi + # Reproduce the pinned upstream-header checks; the host CTest matrix below + # separately exercises PAL behavior against test shims. + - cmake --build ${CMAKE_BUILD_DIR} --target check_freertos_pal # cd into the build dir rather than using `--test-dir` (the CI image's # ctest does not honour that flag and would silently find no tests). - cd ${CMAKE_BUILD_DIR} @@ -87,6 +90,7 @@ 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 # 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 @@ -96,7 +100,7 @@ test:memory-leak: # 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 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 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 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 a561623..9af6903 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,8 @@ ctest --test-dir build --output-on-failure --no-tests=error --timeout 180 # ne `run_leaks_check.sh ./build/iot_cipher_test` (macOS) — no-subprocess binaries only; under valgrind the Python mock handshakes time out. Both scripts **ignore the test's exit code**: a suite failing every assertion still prints "RESULT: NO MEMORY LEAKS DETECTED" and exits 0. - Coverage is 3 of 13 binaries, hand-listed in `.gitlab-ci.yml`; GitHub's sanitizer job sets + GitLab hand-lists the no-subprocess binaries, including PAL clock tests, in + `.gitlab-ci.yml`'s valgrind loop; GitHub's sanitizer job sets `detect_leaks=0`, so nothing else checks leaks anywhere. - **Examples** — a separate top-level project, and `AGENTIC_KIT_BUILD_EXAMPLES` defaults OFF, so a plain root `cmake --build build` builds none: @@ -98,9 +99,17 @@ ctest --test-dir build --output-on-failure --no-tests=error --timeout 180 # ne `TAI_LOG*`, `TUYA_BLE_HAL_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. The one deliberate gap: `pal_t` has - only a monotonic `time_ms`, so ATOP signing reads libc `time(NULL)` — a port needs a real-time - clock the C library can see, or every signed request carries a `t` the cloud rejects. + fails the pipeline. `pal_t.time_ms` supplies monotonic elapsed time; + `pal_t.wal_clock_time` supplies Unix UTC milliseconds for cloud signing and media + timestamps. POSIX reads `CLOCK_REALTIME`; the FreeRTOS default UTC callback + reads libc `gettimeofday()` and returns zero on a failed read or non-positive + Unix seconds. Its platform/libc must provide that API and a UTC source, maintained + by the app through RTC/SNTP; without the API, adapt the PAL or provide a libc shim. + If that source does not fit the platform, clone the factory PAL into one + long-lived table, replace its UTC callback before + initialization, and share that PAL across IoT and TAI. The app owns UTC + synchronization. When TLS certificate-time checks are enabled, the port must + also expose that UTC source to libc/mbedTLS. 6. **CHANGELOG entries are terse, and carry a PR number.** One line per change — `- — (#).` — with indented sub-bullets only for specifics a reader acts on (a new symbol, a changed default, a migration step). `## [0.3.0]` is the reference for @@ -132,8 +141,9 @@ ordinary changes. `CMakeLists.txt` — `agentic_kit_add_iot_test(name source)` for an iot-client suite, `add_executable` + `add_test` otherwise; a binary that spawns no subprocess also goes in `.gitlab-ci.yml`'s valgrind list. Each suite derives its count from the calls it actually made, - and no target in this project enables `-Wall`, so an uncalled `static` test is not even a - warning. Never delete `CMakeLists.txt`'s explicit `enable_testing()` (the comment above it says + and most suites do not enable `-Wall`, so an uncalled `static` test need not produce a + warning. PAL clock tests use `-Wall -Wextra -Werror`. Never delete `CMakeLists.txt`'s + explicit `enable_testing()` (the comment above it says why): re-configures would silently stop regenerating the ctest list, `ctest` exits **0** on an empty list, and GitLab's test stage has no `--no-tests=error`. diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f0e46..46b37fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Examples — `unbind_demo` checks cloud binding status before client initialization/reconnect and after a removal notice (#44). - Examples — `activate_demo --release` prints cloud binding status before and after reset (#44). +### Changed + +- **BREAKING** pal — mandatory `wal_clock_time` provides Unix UTC milliseconds; `time_ms` keeps its monotonic timing contract (#45). + - Custom PALs must supply `wal_clock_time` and rebuild SDK/PAL consumers. + - FreeRTOS defaults to libc `gettimeofday()`; platforms using another UTC source can override the callback. POSIX keeps `CLOCK_REALTIME`. +- iot-client — cloud signing and DP timestamps use `wal_clock_time()` directly, converting UTC milliseconds to seconds (#45). +- iot-client, rtc-tcp-client — ATOP signing, non-empty DP reports and `tai_connect()` warn when PAL UTC is unavailable instead of silently sending zero timestamps (#45). +- rtc-tcp-client — media and Ping timestamps use PAL UTC milliseconds instead of monotonic time (#45). + +### Fixed + +- 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). + ## [0.5.0] - 2026-09-24 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 124836b..f3d2aca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -308,6 +308,51 @@ if(AGENTIC_KIT_BUILD_TESTS AND AGENTIC_KIT_ENABLE_PROJECT_TESTS) 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") + # Compile the actual FreeRTOS PAL against host-only kernel/lwIP shims and + # exercise its clock callbacks. The test shims are never IDF AK_SRCS. + foreach(clock_config IN ITEMS "16_1000" "32_1000" "32_100" "32_128" "32_2000" "64_1000") + string(REPLACE "_" ";" clock_parts "${clock_config}") + list(GET clock_parts 0 clock_bits) + list(GET clock_parts 1 clock_hz) + set(clock_target "pal_freertos_time_${clock_config}_test") + add_executable(${clock_target} + "${PAL_DIR}/pal_freertos.c" "${PAL_DIR}/test/test_freertos_time.c") + target_include_directories(${clock_target} PRIVATE + "${PAL_DIR}/test" "${PAL_DIR}" "${COMMON_DIR}") + target_compile_definitions(${clock_target} PRIVATE + TEST_TICK_BITS=${clock_bits} TEST_TICK_HZ=${clock_hz} + AGENTIC_KIT_LOG_LEVEL=0 gettimeofday=pal_test_gettimeofday) + if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(${clock_target} PRIVATE -Wall -Wextra -Werror) + endif() + add_test(NAME ${clock_target} COMMAND ${clock_target}) + endforeach() + + # Inject clock_gettime only in this target to exercise the production + # POSIX PAL when UTC is unset, unavailable, or corrected. + add_executable(pal_posix_time_test + "${PAL_DIR}/pal_posix.c" "${PAL_DIR}/test/test_posix_time.c") + target_include_directories(pal_posix_time_test PRIVATE + "${PAL_DIR}" "${COMMON_DIR}") + target_compile_definitions(pal_posix_time_test PRIVATE + AGENTIC_KIT_LOG_LEVEL=0 clock_gettime=pal_test_clock_gettime) + target_link_libraries(pal_posix_time_test PRIVATE Threads::Threads) + if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(pal_posix_time_test PRIVATE -Wall -Wextra -Werror) + endif() + add_test(NAME pal_posix_time_test COMMAND pal_posix_time_test) + + # Offline cache/path/concurrency regressions run in both CI CTest jobs. + add_test(NAME freertos_pal_cache_test + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/test_check_freertos_pal.py") + + # Explicit networked check, separate from the offline CTest suite. + add_custom_target(check_freertos_pal + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/check_freertos_pal.py" + --cc "${CMAKE_C_COMPILER}" + COMMENT "Check the PAL against pinned official FreeRTOS 10/11 headers" + VERBATIM) + add_executable(tai_unit_tests ${AI_TCP_SOURCES} "${AI_TCP_TESTS_DIR}/test_core.c" diff --git a/docs-site/docs/concepts.md b/docs-site/docs/concepts.md index 21dd75d..8ed6f0a 100644 --- a/docs-site/docs/concepts.md +++ b/docs-site/docs/concepts.md @@ -55,7 +55,7 @@ PAL 是 Agentic-kit 的可移植边界。SDK 不直接绑定某个 RTOS 或芯 | --- | --- | --- | | 网络 | `connect / send / recv / close / poll` | 超时、部分收发、TLS 依赖、断网恢复 | | 并发 | `thread_create / join`、mutex | 线程栈、回调上下文、锁粒度 | -| 系统 | `time_ms`、`malloc / free` | 时钟单调性、内存峰值与碎片 | +| 系统 | `wal_clock_time`、`time_ms`、`malloc / free` | UTC 校时与单调计时、内存峰值与碎片 | ## 物理设备控制:DP 与端侧 MCP {#物理设备控制dp-与端侧-mcp} diff --git a/docs-site/docs/guides/atop-generic-call.md b/docs-site/docs/guides/atop-generic-call.md index 769094e..36adacb 100644 --- a/docs-site/docs/guides/atop-generic-call.md +++ b/docs-site/docs/guides/atop-generic-call.md @@ -92,10 +92,11 @@ void iot_atop_response_free(iot_client_t *client, iot_atop_response_t *response) ```c #include "iot_atop.h" +uint32_t timestamp = (uint32_t)(client->pal->wal_clock_time() / 1000U); char body[192]; snprintf(body, sizeof(body), "{\"schemaId\":\"%s\",\"version\":\"\",\"t\":%u}", - schema_id, (unsigned)time(NULL)); + schema_id, (unsigned)timestamp); iot_atop_request_t req = { .api = "tuya.device.schema.newest.get", .version = "1.0", diff --git a/docs-site/docs/guides/ota-upgrade.md b/docs-site/docs/guides/ota-upgrade.md index ed7700a..cbeeb0f 100644 --- a/docs-site/docs/guides/ota-upgrade.md +++ b/docs-site/docs/guides/ota-upgrade.md @@ -219,6 +219,8 @@ CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y ### 3. 初始化与升级查询 {#3-初始化与升级查询} +FreeRTOS PAL 默认通过 `gettimeofday()` 读取 libc UTC,应用需先通过平台 RTC/SNTP 完成校时。只有该 UTC 源不适用时才需要替换回调(见[FreeRTOS UTC 时钟](./porting-to-new-platform.md#freertos-wall-clock))。 + ```c #include "iot_client.h" #include "iot_ota.h" @@ -241,8 +243,12 @@ iot_client_config_t iot_cfg = { .cert_bundle_attach = (tls_cert_bundle_attach_fn)esp_crt_bundle_attach, }; +/* 默认 PAL 读取已由应用同步的 libc UTC,与其他 SDK 模块共用。 */ +const pal_t *pal = tai_pal_freertos(); /* iot_init(pal) 必须在 iot_client_init 前调用,否则 iot_client_init 返回 NULL */ -iot_init(tai_pal_freertos()); +if (iot_init(pal) != OPRT_OK) { + return; +} iot_client_t *iot = iot_client_init(&iot_cfg); diff --git a/docs-site/docs/guides/porting-to-new-platform.md b/docs-site/docs/guides/porting-to-new-platform.md index ace1fb5..93f3dee 100644 --- a/docs-site/docs/guides/porting-to-new-platform.md +++ b/docs-site/docs/guides/porting-to-new-platform.md @@ -15,10 +15,10 @@ RTC TCP Client 通过 PAL(Platform Abstraction Layer)实现跨平台移植 | TCP | `tcp_connect`, `tcp_send`, `tcp_recv`, `tcp_close`, `tcp_poll` | TCP 连接管理、收发与轮询 | | 线程 | `thread_create`, `thread_join` | 后台接收线程 | | 互斥锁 | `mutex_create`, `mutex_lock`, `mutex_unlock`, `mutex_destroy` | 线程同步 | -| 时间 | `time_ms` | 获取毫秒时间戳 | +| 时间 | `time_ms`, `wal_clock_time` | 单调毫秒计时用于超时;Unix UTC 毫秒时间戳 | | 内存 | `malloc`, `free` | 动态内存分配 | -> **注意:** PAL 只需实现原始 TCP(`tcp_*`)、轮询(`tcp_poll`)、互斥锁(`mutex_*`)和时间(`time_ms`)等接口,无需在 PAL 中实现 TLS。TLS 现位于共享库 `common/tls.c`(TLS-over-TCP,被 iot-client 的 mqtt/http 与 rtc-tcp-client 共用;rtc-tcp-client 旧的 `src/tai_tls.c` 已删除)。TLS 握手在内部复用 PAL 的 `tcp_poll` / `mutex_*` / `time_ms`,因此这些 PAL 接口必须正确实现。 +> **注意:** PAL 只需实现原始 TCP(`tcp_*`)、轮询(`tcp_poll`)、互斥锁(`mutex_*`)和时间(`wal_clock_time`、`time_ms`)等接口,无需在 PAL 中实现 TLS。TLS 现位于共享库 `common/tls.c`(TLS-over-TCP,被 iot-client 的 mqtt/http 与 rtc-tcp-client 共用;rtc-tcp-client 旧的 `src/tai_tls.c` 已删除)。TLS 握手在内部复用 PAL 的 `tcp_poll` / `mutex_*` / `time_ms`,因此这些 PAL 接口必须正确实现。 ### 随机数 (RNG) {#随机数-rng} @@ -42,20 +42,22 @@ static void *my_tcp_connect(const char *host, uint16_t port, uint32_t timeout_ms // ... 实现其他函数 ... const pal_t my_platform_pal = { - .tcp_connect = my_tcp_connect, - .tcp_send = my_tcp_send, - .tcp_recv = my_tcp_recv, - .tcp_close = my_tcp_close, - .tcp_poll = my_tcp_poll, - .time_ms = my_time_ms, - .malloc = my_malloc, - .free = my_free, - .mutex_create = my_mutex_create, - .mutex_lock = my_mutex_lock, - .mutex_unlock = my_mutex_unlock, - .mutex_destroy = my_mutex_destroy, - .thread_create = my_thread_create, - .thread_join = my_thread_join, + .tcp_connect = my_tcp_connect, + .tcp_send = my_tcp_send, + .tcp_recv = my_tcp_recv, + .tcp_close = my_tcp_close, + .tcp_poll = my_tcp_poll, + .time_ms = my_time_ms, + .wal_clock_time = my_wal_clock_time, + .malloc = my_malloc, + .free = my_free, + .mutex_create = my_mutex_create, + .mutex_lock = my_mutex_lock, + .mutex_unlock = my_mutex_unlock, + .mutex_destroy = my_mutex_destroy, + .thread_create = my_thread_create, + .thread_join = my_thread_join, + .sleep_ms = my_sleep_ms, }; ``` @@ -63,7 +65,7 @@ const pal_t my_platform_pal = { ```c tai_config_t cfg = { - .pal = &my_platform_pal, + .pal = &my_platform_pal, // ... 其他配置 }; ``` @@ -81,7 +83,7 @@ tai_config_t cfg = { | TCP | lwIP socket API | | 线程 | `xTaskCreate` / `vTaskDelete` | | 互斥锁 | `xSemaphoreCreateRecursiveMutex` | -| 时间 | `xTaskGetTickCount() * portTICK_PERIOD_MS` | +| 时间 | `time_ms`:FreeRTOS tick;`wal_clock_time`:libc `gettimeofday()` | | 内存 | `pvPortMalloc` / `vPortFree` | 现成实现参考:`pal/pal_freertos.c` 和 `examples/esp-idf/components/agentic_kit/` @@ -93,7 +95,7 @@ tai_config_t cfg = { | TCP | `socket` / `connect` / `send` / `recv` | | 线程 | `pthread_create` / `pthread_join` | | 互斥锁 | `pthread_mutex_*` | -| 时间 | `clock_gettime(CLOCK_MONOTONIC)` | +| 时间 | `time_ms`:`CLOCK_MONOTONIC`;`wal_clock_time`:`CLOCK_REALTIME`(均通过 `clock_gettime`) | | 内存 | `malloc` / `free` | 现成实现参考:`pal/pal_posix.c` @@ -104,12 +106,77 @@ tai_config_t cfg = { |----------|--------------| | 线程 | `xTaskCreate` | | 互斥锁 | `xSemaphoreCreateRecursiveMutex` | -| 时间 | `xTaskGetTickCount() * portTICK_PERIOD_MS` | +| 时间 | `time_ms` 可参考 `pal_freertos.c`;`wal_clock_time` 默认读取 libc `gettimeofday()`,可按平台替换 | > **注意:** `pal.h` 的 `mutex_create` 契约(见头文件注释)要求返回**递归锁**,因此必须使用 `xSemaphoreCreateRecursiveMutex`(配合 `xSemaphoreTakeRecursive` / `xSemaphoreGiveRecursive`),不能用非递归的 `xSemaphoreCreateMutex`,否则会在重入加锁路径上死锁。 TCP 部分取决于具体的网络协议栈(lwIP、AT 指令等)。 +### FreeRTOS 单调计时 {#freertos-tick-clock} + +FreeRTOS 计时实现位于 `pal/pal_freertos.c` 内部。应用通过 `pal_t` 的回调读取时间,无需包含独立的计时头文件: + +```c +#include "pal.h" + +static void measure_work(const pal_t *pal) +{ + uint64_t start_ms = pal->time_ms(); + /* 执行操作。 */ + uint64_t elapsed_ms = pal->time_ms() - start_ms; + (void)elapsed_ms; +} +``` + +- `time_ms` 在调度器启动后的任务上下文中调用,不支持 ISR。重启内核后重新建立计时起点。 +- 内部通过 `vTaskSetTimeOutState()` 读取 tick 和回绕次数,无需 tick hook 或计时任务;支持 16、32、64 位 `TickType_t`。快照一致性依赖 FreeRTOS V10.4.3/V11.1.0 的 `TimeOut_t` 布局和临界区实现,超时记账 API 不承诺稳定的扩展时钟语义;升级内核或使用厂商分支时需复核。16/32 位 tick 要求至少 32 位 `UBaseType_t`,且内核回绕计数仍可表示。 +- 根据 `configTICK_RATE_HZ` 换算毫秒,支持非整毫秒 tick 和高于 1000 Hz 的频率;避免中间乘法溢出,超出 64 位毫秒范围时饱和为 `UINT64_MAX`。 +- 分辨率由 tick 频率决定;Tickless idle 的睡眠时间取决于平台的内核 tick 补偿。 + +可通过 `cmake --build build --target check_freertos_pal` 复核官方头文件兼容性,或运行 `python3 tools/check_freertos_pal.py --cc cc`。脚本固定 V10.4.3/V11.1.0 的上游提交,检查五种 tick 位宽组合;它会下载头文件,只验证编译兼容性,不验证实际内核运行或临界区。离线检查可通过 `--kernels-dir` 指定两个固定版本的 Git 检出目录。 + +### FreeRTOS UTC 时钟 {#freertos-wall-clock} + +云端协议直接调用 `pal->wal_clock_time()` 获取 UTC 毫秒,并在协议使用处通过 `(uint32_t)(pal->wal_clock_time() / 1000U)` 转成 Unix 秒;RTC 媒体时间戳继续使用 UTC 毫秒。 + +`time_ms` 保留单调计时语义;新增的 `wal_clock_time` 返回自 1970-01-01 UTC 起的毫秒数。两项均为必填。自定义 PAL 保留原 `time_ms` 的 tick/uptime 实现,为 `wal_clock_time` 提供已同步的 UTC,并重新编译 SDK 与调用方。 + +`pal_freertos.c` 的默认 `pal_wal_clock_time()` 通过 libc `gettimeofday()` 读取 UTC,将秒和微秒换算为毫秒;读取失败或 Unix 秒数不大于 0 时返回 0。此实现要求平台/libc 提供 `gettimeofday()`(如 ESP-IDF 的 libc/lwIP 环境);FreeRTOS 内核本身不提供 UTC。应用通过 RTC/SNTP 维护该时钟,适用时可以直接使用工厂 PAL: + +```c +const pal_t *pal = tai_pal_freertos(); +if (iot_init(pal) != OPRT_OK) { + return; +} +tai_config_t cfg = { + .pal = pal, + /* ... */ +}; +``` + +如果平台提供 `gettimeofday()`,但它的 UTC 源不适用,可在初始化前替换回调。下面的 `app_utc_ms()` 由应用按平台实现,返回 Unix UTC 毫秒,时间不可用时返回 0: + +```c +static pal_t app_pal; + +/* 启动阶段执行一次,早于 SDK 和工作线程。 */ +app_pal = *tai_pal_freertos(); +app_pal.wal_clock_time = app_utc_ms; +if (iot_init(&app_pal) != OPRT_OK) { + return; +} +tai_config_t cfg = { + .pal = &app_pal, + /* ... */ +}; +``` + +自定义 PAL 必须在 SDK 使用期间保持有效,IoT 与 TAI 共用该 PAL,初始化后不再修改它。如果工具链没有 `gettimeofday()`,还需适配 `pal_freertos.c` 或补充 libc 兼容实现;仅在运行时替换回调不能消除该编译和链接依赖。自定义 UTC 源可直接接入硬件 RTC;没有 RTC 时也可以按“同步 UTC 基准 + 单调经过时间”计算,基准的读写需保证线程安全。 + +SDK 不联网校时,不创建校时任务,也不回退到单调时间;读取到正数也不代表已经完成校时,应用仍需确认 UTC 可用。POSIX 的 `CLOCK_REALTIME` 实现和默认初始化方式保持不变。 + +UTC 可向前或向后校正,不能用于计算超时。TLS、MQTT、RTC 的超时与 Keepalive 使用 `time_ms`;云端签名、DP 上报和 RTC 媒体时间戳使用 `wal_clock_time`。UTC 不加时区偏移,时区只在显示日期时处理。启用 TLS 证书时间校验时,还需让 libc/mbedTLS 使用同一 UTC 源。 + ## ESP-IDF 特殊注意事项 {#esp-idf-特殊注意事项} ### 内存规划 {#内存规划} @@ -152,6 +219,7 @@ CONFIG_FREERTOS_HZ=1000 - `tcp_recv` 应支持阻塞/超时语义(后台线程会循环调用) - `tcp_poll` 用于检查套接字的可读/可写状态,需正确实现 events 位掩码 - 如使用 Opus 编码,需额外集成 Opus 库 +- 云端签名与 DP 时间戳读取 PAL 的 UTC 回调,无需 libc `time(NULL)`。 ### PAL I/O 返回值契约 {#pal-io-返回值契约} diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/concepts.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/concepts.md index 149f111..fabdd44 100644 --- a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/concepts.md +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/concepts.md @@ -55,7 +55,7 @@ The PAL is Agentic-kit's portability boundary. Rather than binding directly to a | --- | --- | --- | | Network | `connect / send / recv / close / poll` | Timeouts, partial sends and receives, TLS dependencies, network recovery | | Concurrency | `thread_create / join`, mutex | Thread stack, callback context, lock granularity | -| System | `time_ms`, `malloc / free` | Clock monotonicity, peak memory use, and fragmentation | +| System | `wal_clock_time`, `time_ms`, `malloc / free` | UTC synchronization and monotonic timing, peak memory use, and fragmentation | ## Physical device control: DPs and device-side MCP {#物理设备控制dp-与端侧-mcp} diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/atop-generic-call.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/atop-generic-call.md index 225497d..e71e8b1 100644 --- a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/atop-generic-call.md +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/atop-generic-call.md @@ -95,10 +95,11 @@ void iot_atop_response_free(iot_client_t *client, iot_atop_response_t *response) ```c #include "iot_atop.h" +uint32_t timestamp = (uint32_t)(client->pal->wal_clock_time() / 1000U); char body[192]; snprintf(body, sizeof(body), "{\"schemaId\":\"%s\",\"version\":\"\",\"t\":%u}", - schema_id, (unsigned)time(NULL)); + schema_id, (unsigned)timestamp); iot_atop_request_t req = { .api = "tuya.device.schema.newest.get", .version = "1.0", diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/ota-upgrade.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/ota-upgrade.md index ba8db85..724f73d 100644 --- a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/ota-upgrade.md +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/ota-upgrade.md @@ -220,6 +220,8 @@ CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y ### 3. Initialization and Upgrade Query {#3-初始化与升级查询} +The FreeRTOS PAL defaults to reading libc UTC through `gettimeofday()`. The application must first synchronize that clock through platform RTC/SNTP. Replace the callback only when that UTC source is unsuitable (see [FreeRTOS UTC Clock](./porting-to-new-platform.md#freertos-wall-clock)). + ```c #include "iot_client.h" #include "iot_ota.h" @@ -244,8 +246,13 @@ iot_client_config_t iot_cfg = { .cert_bundle_attach = (tls_cert_bundle_attach_fn)esp_crt_bundle_attach, }; +/* The default PAL reads application-synchronized libc UTC. Share it with + other SDK modules. */ +const pal_t *pal = tai_pal_freertos(); /* iot_init(pal) must be called before iot_client_init; otherwise iot_client_init returns NULL */ -iot_init(tai_pal_freertos()); +if (iot_init(pal) != OPRT_OK) { + return; +} iot_client_t *iot = iot_client_init(&iot_cfg); 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 587f47a..dcf5c68 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 @@ -15,10 +15,10 @@ The RTC TCP Client uses a PAL (Platform Abstraction Layer) for cross-platform po | TCP | `tcp_connect`, `tcp_send`, `tcp_recv`, `tcp_close`, `tcp_poll` | TCP connection management, sending, receiving, and polling | | Threads | `thread_create`, `thread_join` | Background receive thread | | Mutexes | `mutex_create`, `mutex_lock`, `mutex_unlock`, `mutex_destroy` | Thread synchronization | -| Time | `time_ms` | Obtain a millisecond timestamp | +| Time | `time_ms`, `wal_clock_time` | Monotonic milliseconds for deadlines; Unix UTC milliseconds | | Memory | `malloc`, `free` | Dynamic memory allocation | -> **Note:** The PAL only needs to implement raw TCP (`tcp_*`), polling (`tcp_poll`), mutex (`mutex_*`), and time (`time_ms`) interfaces. TLS does not need to be implemented in the PAL. TLS now resides in the shared library `common/tls.c` (TLS-over-TCP shared by mqtt/http in iot-client and rtc-tcp-client; the old rtc-tcp-client `src/tai_tls.c` has been removed). Internally, the TLS handshake reuses the PAL's `tcp_poll` / `mutex_*` / `time_ms`, so these PAL interfaces must be implemented correctly. +> **Note:** The PAL only needs to implement raw TCP (`tcp_*`), polling (`tcp_poll`), mutex (`mutex_*`), and time (`wal_clock_time`, `time_ms`) interfaces. TLS does not need to be implemented in the PAL. TLS now resides in the shared library `common/tls.c` (TLS-over-TCP shared by mqtt/http in iot-client and rtc-tcp-client; the old rtc-tcp-client `src/tai_tls.c` has been removed). Internally, the TLS handshake reuses the PAL's `tcp_poll` / `mutex_*` / `time_ms`, so these PAL interfaces must be implemented correctly. ### Random Number Generation (RNG) {#随机数-rng} @@ -43,20 +43,22 @@ static void *my_tcp_connect(const char *host, uint16_t port, uint32_t timeout_ms // ... Implement the other functions ... const pal_t my_platform_pal = { - .tcp_connect = my_tcp_connect, - .tcp_send = my_tcp_send, - .tcp_recv = my_tcp_recv, - .tcp_close = my_tcp_close, - .tcp_poll = my_tcp_poll, - .time_ms = my_time_ms, - .malloc = my_malloc, - .free = my_free, - .mutex_create = my_mutex_create, - .mutex_lock = my_mutex_lock, - .mutex_unlock = my_mutex_unlock, - .mutex_destroy = my_mutex_destroy, - .thread_create = my_thread_create, - .thread_join = my_thread_join, + .tcp_connect = my_tcp_connect, + .tcp_send = my_tcp_send, + .tcp_recv = my_tcp_recv, + .tcp_close = my_tcp_close, + .tcp_poll = my_tcp_poll, + .time_ms = my_time_ms, + .wal_clock_time = my_wal_clock_time, + .malloc = my_malloc, + .free = my_free, + .mutex_create = my_mutex_create, + .mutex_lock = my_mutex_lock, + .mutex_unlock = my_mutex_unlock, + .mutex_destroy = my_mutex_destroy, + .thread_create = my_thread_create, + .thread_join = my_thread_join, + .sleep_ms = my_sleep_ms, }; ``` @@ -64,7 +66,7 @@ const pal_t my_platform_pal = { ```c tai_config_t cfg = { - .pal = &my_platform_pal, + .pal = &my_platform_pal, // ... Other configuration }; ``` @@ -82,7 +84,7 @@ Compile the source files under `modules/rtc-tcp-client/src/` together with your | TCP | lwIP socket API | | Threads | `xTaskCreate` / `vTaskDelete` | | Mutexes | `xSemaphoreCreateRecursiveMutex` | -| Time | `xTaskGetTickCount() * portTICK_PERIOD_MS` | +| Time | `time_ms`: FreeRTOS ticks; `wal_clock_time`: libc `gettimeofday()` | | Memory | `pvPortMalloc` / `vPortFree` | Reference implementations: `pal/pal_freertos.c` and `examples/esp-idf/components/agentic_kit/` @@ -94,7 +96,7 @@ Reference implementations: `pal/pal_freertos.c` and `examples/esp-idf/components | TCP | `socket` / `connect` / `send` / `recv` | | Threads | `pthread_create` / `pthread_join` | | Mutexes | `pthread_mutex_*` | -| Time | `clock_gettime(CLOCK_MONOTONIC)` | +| Time | `time_ms`: `CLOCK_MONOTONIC`; `wal_clock_time`: `CLOCK_REALTIME` (both via `clock_gettime`) | | Memory | `malloc` / `free` | Reference implementation: `pal/pal_posix.c` @@ -105,12 +107,77 @@ Reference implementation: `pal/pal_posix.c` |----------|--------------| | Threads | `xTaskCreate` | | Mutexes | `xSemaphoreCreateRecursiveMutex` | -| Time | `xTaskGetTickCount() * portTICK_PERIOD_MS` | +| Time | See `pal_freertos.c` for `time_ms`; `wal_clock_time` defaults to libc `gettimeofday()` and can be replaced for the platform | > **Note:** The `mutex_create` contract in `pal.h` (see the header comment) requires a **recursive mutex**. Therefore, you must use `xSemaphoreCreateRecursiveMutex` with `xSemaphoreTakeRecursive` / `xSemaphoreGiveRecursive`, not the non-recursive `xSemaphoreCreateMutex`; otherwise, reentrant locking paths deadlock. The TCP implementation depends on the specific network stack, such as lwIP or AT commands. +### 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: + +```c +#include "pal.h" + +static void measure_work(const pal_t *pal) +{ + uint64_t start_ms = pal->time_ms(); + /* Perform work. */ + uint64_t elapsed_ms = pal->time_ms() - start_ms; + (void)elapsed_ms; +} +``` + +- Call `time_ms` from task context after the scheduler starts, never from an ISR. Establish new timing origins after a kernel restart. +- Internally, `vTaskSetTimeOutState()` reads ticks and their overflow count without a tick hook or timing task. It supports 16/32/64-bit `TickType_t`. Snapshot consistency depends on the `TimeOut_t` layout and critical-section implementation in FreeRTOS V10.4.3/V11.1.0; the timeout bookkeeping API does not promise a stable extended clock. Recheck kernel upgrades and vendor forks. For 16/32-bit ticks, `UBaseType_t` must be at least 32 bits and the kernel overflow count must remain representable. +- Conversion uses `configTICK_RATE_HZ`, including fractional-millisecond ticks and rates above 1000 Hz. It avoids intermediate multiplication overflow and saturates at `UINT64_MAX` if the millisecond range is exhausted. +- Resolution follows the tick frequency; tickless-idle sleep time follows the port's kernel tick compensation. + +Reproduce the official-header checks with `cmake --build build --target check_freertos_pal`, or `python3 tools/check_freertos_pal.py --cc cc`. The script pins upstream V10.4.3/V11.1.0 commits and checks five tick-width configurations. It downloads headers and verifies compilation only, not kernel execution or critical sections. For offline checks, `--kernels-dir` accepts git checkouts of both pinned versions. + +### FreeRTOS UTC Clock {#freertos-wall-clock} + +Cloud protocols call `pal->wal_clock_time()` directly for UTC milliseconds and convert to Unix seconds at the protocol call sites with `(uint32_t)(pal->wal_clock_time() / 1000U)`; RTC media timestamps continue using UTC milliseconds. + +`time_ms` keeps its monotonic timing contract; the new `wal_clock_time` returns milliseconds since 1970-01-01 UTC. Both are mandatory. Custom PALs retain their existing tick/uptime implementation in `time_ms`, supply synchronized UTC in `wal_clock_time`, and rebuild the SDK and its consumers. + +The default `pal_wal_clock_time()` in `pal_freertos.c` reads UTC through libc `gettimeofday()` and converts seconds and microseconds to milliseconds. It returns zero if the read fails or Unix seconds are non-positive. This implementation requires a platform/libc that provides `gettimeofday()`, such as the ESP-IDF libc/lwIP environment; the FreeRTOS kernel itself provides no UTC clock. The application maintains that clock through RTC/SNTP and can use the factory PAL directly when this source is suitable: + +```c +const pal_t *pal = tai_pal_freertos(); +if (iot_init(pal) != OPRT_OK) { + return; +} +tai_config_t cfg = { + .pal = pal, + /* ... */ +}; +``` + +If the platform provides `gettimeofday()` but its UTC source is unsuitable, replace the callback before initialization. The application implements `app_utc_ms()` below to return Unix UTC milliseconds, or zero when time is unavailable: + +```c +static pal_t app_pal; + +/* Run once at startup, before the SDK or worker threads. */ +app_pal = *tai_pal_freertos(); +app_pal.wal_clock_time = app_utc_ms; +if (iot_init(&app_pal) != OPRT_OK) { + return; +} +tai_config_t cfg = { + .pal = &app_pal, + /* ... */ +}; +``` + +Keep a custom PAL alive throughout SDK use, share it between IoT and TAI, and do not modify it after initialization. If the toolchain lacks `gettimeofday()`, adapt `pal_freertos.c` or supply a libc compatibility implementation; replacing a callback at runtime does not remove that compile/link dependency. A custom UTC source can read a hardware RTC directly. Without an RTC, it can compute synchronized UTC plus elapsed monotonic time, protecting shared anchor reads and updates. + +The SDK performs no network synchronization or background clock work and never falls back to monotonic time. A positive reading does not prove synchronization has completed; the application must still establish valid UTC. The POSIX `CLOCK_REALTIME` implementation and default initialization behavior remain unchanged. + +UTC may step forwards or backwards and must not be used for timeouts. TLS/MQTT/RTC deadlines and Keepalive use `time_ms`; cloud signing, DP reports and RTC media timestamps use `wal_clock_time`. Store UTC without a timezone offset; apply timezones only when displaying calendar dates. When certificate-time checks are enabled, libc/mbedTLS must use the same UTC source. + ## ESP-IDF-Specific Considerations {#esp-idf-特殊注意事项} ### Memory Planning {#内存规划} diff --git a/examples/esp-idf/ai/rtc-tcp-client/main/main.c b/examples/esp-idf/ai/rtc-tcp-client/main/main.c index 1755a9a..3e987d6 100644 --- a/examples/esp-idf/ai/rtc-tcp-client/main/main.c +++ b/examples/esp-idf/ai/rtc-tcp-client/main/main.c @@ -38,6 +38,7 @@ extern const pal_t *tai_pal_freertos(void); const pal_t *get_default_pal(void) { + /* The application synchronizes the platform UTC clock via RTC/SNTP. */ return tai_pal_freertos(); } @@ -363,7 +364,7 @@ void app_main(void) ESP_LOGI(TAG, "tai_ctx_size=%u", (unsigned)tai_ctx_size()); /* 4. Build TAI context */ - const pal_t *pal = tai_pal_freertos(); + const pal_t *pal = get_default_pal(); tai_config_t tai_cfg = { .host = cp.host, diff --git a/examples/esp-idf/ota-demo/main/main.c b/examples/esp-idf/ota-demo/main/main.c index 701e252..ded67e3 100644 --- a/examples/esp-idf/ota-demo/main/main.c +++ b/examples/esp-idf/ota-demo/main/main.c @@ -48,6 +48,12 @@ static EventGroupHandle_t s_wifi_event_group; extern const pal_t *tai_pal_freertos(void); +const pal_t *get_default_pal(void) +{ + /* The application synchronizes the platform UTC clock via RTC/SNTP. */ + return tai_pal_freertos(); +} + /* ----------------------------------------------------------------------- */ /* WiFi */ /* ----------------------------------------------------------------------- */ @@ -331,7 +337,7 @@ void app_main(void) ESP_LOGI(TAG, "Current firmware version: %s", desc->version); /* 1. Initialize agentic-kit */ - const pal_t *pal = tai_pal_freertos(); + const pal_t *pal = get_default_pal(); iot_init(pal); /* 2. Initialize iot_client with persisted credentials */ diff --git a/examples/esp-idf/pair/pair-by-ble/main/main.c b/examples/esp-idf/pair/pair-by-ble/main/main.c index a7fef4b..94b3c0f 100644 --- a/examples/esp-idf/pair/pair-by-ble/main/main.c +++ b/examples/esp-idf/pair/pair-by-ble/main/main.c @@ -47,6 +47,7 @@ extern const pal_t *tai_pal_freertos(void); const pal_t *get_default_pal(void) { + /* The application synchronizes the platform UTC clock via RTC/SNTP. */ return tai_pal_freertos(); } diff --git a/examples/esp-idf/pair/scan-by-app/main/main.c b/examples/esp-idf/pair/scan-by-app/main/main.c index 92716ae..537df6b 100644 --- a/examples/esp-idf/pair/scan-by-app/main/main.c +++ b/examples/esp-idf/pair/scan-by-app/main/main.c @@ -23,6 +23,7 @@ extern const pal_t *tai_pal_freertos(void); const pal_t *get_default_pal(void) { + /* The application synchronizes the platform UTC clock via RTC/SNTP. */ return tai_pal_freertos(); } diff --git a/modules/biz-client/src/biz_client.c b/modules/biz-client/src/biz_client.c index ec78ce0..b18c6b7 100644 --- a/modules/biz-client/src/biz_client.c +++ b/modules/biz-client/src/biz_client.c @@ -1,7 +1,6 @@ #include "service_internal.h" #include "log.h" #include -#include bool biz_valid_string(const char *text, size_t capacity) { @@ -53,13 +52,15 @@ int biz_call(tuya_biz_client_t *ctx, const char *api, cJSON *body, { *result = NULL; if (!body) return OPRT_MALLOC_FAILED; - time_t now = time(NULL); - if (now < 1700000000) { - log_tag_warn("biz", "clock is not synchronized; refusing signed role request"); + /* Cloud timestamps come from PAL UTC (ADR 0003; 0 = unavailable), not libc + * 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"); cJSON_Delete(body); return OPRT_UNINITIALIZED; } - if (!cJSON_AddNumberToObject(body, "t", (double)now)) { + if (!cJSON_AddNumberToObject(body, "t", (double)(utc_ms / 1000U))) { cJSON_Delete(body); return OPRT_MALLOC_FAILED; } diff --git a/modules/biz-client/test/ai_role_test.c b/modules/biz-client/test/ai_role_test.c index db11c47..da77875 100644 --- a/modules/biz-client/test/ai_role_test.c +++ b/modules/biz-client/test/ai_role_test.c @@ -28,6 +28,9 @@ static void *allocate(size_t n) return p; } static void release(void *p) { if (p) { --live; free(p); } } +/* PAL contract (ADR 0003): wal_clock_time is mandatory. Fixed UTC above the + * Nov-2023 sync floor so the "t" gate in biz_call passes deterministically. */ +static uint64_t fixed_utc_ms(void) { return 1700000001000ULL; } int iot_atop_call(iot_client_t *client, const iot_atop_request_t *req, iot_atop_response_t *out) @@ -275,7 +278,8 @@ static int allocation_failures_release_everything(void) int main(void) { cJSON_Hooks hooks = {allocate, release}; cJSON_InitHooks(&hooks); - pal.malloc = allocate; pal.free = release; iot.pal = &pal; + pal.malloc = allocate; pal.free = release; pal.wal_clock_time = fixed_utc_ms; + iot.pal = &pal; if (tuya_biz_client_create(&iot, NULL, &biz) != OPRT_OK) return 1; int (*tests[])(void) = { lifecycle, bound_and_templates, list_shapes_and_pagination, bind_and_delete, template_edit_creates_and_binds, custom_edit_preserves_omitted_fields, diff --git a/modules/iot-client/CONTEXT.md b/modules/iot-client/CONTEXT.md index d8af23d..e56bad5 100644 --- a/modules/iot-client/CONTEXT.md +++ b/modules/iot-client/CONTEXT.md @@ -5,6 +5,23 @@ device state over MQTT: activation, the Data Point model, and DP reporting/downl ## Language +**Monotonic time**: +An elapsed-time clock exposed by `pal_t.time_ms`, used for deadlines and retry +intervals. Its epoch is local to the platform's current run; it does not name a +calendar date and must not be substituted for a UTC timestamp in a cloud request. +The FreeRTOS PAL's monotonic clock resolution follows the kernel tick frequency. +_Avoid_: wall-clock time, UTC timestamp (when referring to this elapsed-time clock). + +**UTC timestamp**: +Wall-clock time exposed by `pal_t.wal_clock_time` as milliseconds since the Unix epoch, +with no timezone offset. Cloud signing/reporting converts it to seconds; it may +step forwards or backwards when the application synchronizes the clock. The +application establishes UTC before cloud requests; the SDK never synchronizes +it in the background. The FreeRTOS default reads libc `gettimeofday()`; a platform +whose UTC source is not exposed there can replace the PAL callback before +initialization. See [ADR 0003](docs/adr/0003-pal-separates-utc-and-monotonic-time.md). +_Avoid_: uptime, monotonic time (when referring to a cloud timestamp). + **Data Point (DP)**: A single addressable unit of device state or capability, identified by a numeric id (1–255) and typed by the product schema. diff --git a/modules/iot-client/docs/adr/0003-pal-separates-utc-and-monotonic-time.md b/modules/iot-client/docs/adr/0003-pal-separates-utc-and-monotonic-time.md new file mode 100644 index 0000000..6ba7a82 --- /dev/null +++ b/modules/iot-client/docs/adr/0003-pal-separates-utc-and-monotonic-time.md @@ -0,0 +1,95 @@ +# PAL separates UTC timestamps from monotonic time + +Before this change, `pal_t.time_ms` provided monotonic milliseconds for TLS, +MQTT and RTC Connection deadlines, while cloud requests bypassed the PAL and +read libc `time(NULL)`. A FreeRTOS port could provide ticks without a UTC source +visible to signing; RTC media timestamps also had a platform-local epoch. + +We retain `pal_t.time_ms` for elapsed time, Connection deadlines, Keepalive and +drain budgets, and add mandatory `pal_t.wal_clock_time` for Unix UTC milliseconds +without a timezone offset. UTC may move forwards or backwards; its corrections +must never affect the monotonic clock. Zero denotes unavailable UTC. The +application synchronizes UTC before cloud requests or opening an RTC Connection; +the SDK performs no clock synchronization or background work. + +Cloud signing and DP reports call `pal->wal_clock_time()` directly and convert +UTC milliseconds to `uint32_t` Unix seconds at the protocol call sites. The PAL +exposes clock sources without protocol-specific conversion helpers. +RTC media and Ping payloads use `wal_clock_time` in +milliseconds; receive liveness uses `time_ms`. POSIX uses `CLOCK_REALTIME` for +UTC and `CLOCK_MONOTONIC` for elapsed time. Its UTC implementation treats +non-positive Unix seconds or a failed read as unavailable. + +The FreeRTOS PAL's default `pal_wal_clock_time` reads libc `gettimeofday()` and +converts seconds and microseconds to Unix UTC milliseconds. A failed read or +non-positive Unix seconds returns zero. This port requires a platform/libc that +provides `gettimeofday()`; FreeRTOS itself supplies no UTC clock. A toolchain +without that API must adapt the PAL implementation or supply a libc shim; +replacing a callback at runtime cannot remove the compiled symbol dependency. +Applications maintain the libc clock through platform RTC/SNTP synchronization +and may use the factory PAL directly, as the ESP-IDF examples do. If the libc source is +unsuitable, applications copy the factory table into one long-lived `pal_t`, +assign their platform UTC callback before SDK initialization, and share that PAL +across IoT and TAI. Custom callbacks also return zero when UTC is unavailable. +Cloud signing, DP reports and RTC Connection open log a warning when they would +otherwise carry that zero, so +the misconfiguration is not silent. Empty DP report requests stay silent because +they publish no payload. Applications own their UTC source, +concurrent reads/updates and transport-delay compensation; there is no fallback +to monotonic time. `iot_init_default()` and its `get_default_pal()` hook retain +their existing API and POSIX behavior. + +The FreeRTOS monotonic implementation reads `TimeOut_t` through +`vTaskSetTimeOutState()`. Upstream [V10.4.3](https://github.com/FreeRTOS/FreeRTOS-Kernel/blob/9c048e0c71ee43630394981a86f5265bc57131e4/tasks.c#L3239) +and [V11.1.0](https://github.com/FreeRTOS/FreeRTOS-Kernel/blob/dbf70559b27d39c1fdb68dfb9a32140b6a6777a0/tasks.c#L5012) +snapshot `xNumOfOverflows` and `xTickCount` inside a critical section. Using this +timeout bookkeeping as an extended clock depends on these implementations and +the `TimeOut_t` fields; it is not a stable extended-clock contract. Integrators +must recheck vendor forks and kernel upgrades, including critical-section behavior +on their port. For 16/32-bit ticks, the overflow count must remain representable +and `UBaseType_t` must be at least 32 bits. Native 64-bit ticks use their own count. + +The checked-in header probe fixes both upstream commit IDs and compiles the +actual PAL with V10.4.3 16/32-bit ticks and V11.1.0 16/32/64-bit ticks: + +```sh +cmake --build build --target check_freertos_pal +# Or independently of CMake: +python3 tools/check_freertos_pal.py --cc cc +``` + +This downloads official headers and runs five C99 syntax checks using a type-only +host port; downloads retry transient failures and are cached under +`~/.cache/agentic-kit/freertos-kernels`, so only the first run needs the network. +Cache paths are made absolute before creating compiler wrapper links. Each +download uses its own staging directory; atomic publication reuses a completed +concurrent winner without deleting another run's staging or published headers. +The offline CTest cache suite covers relative paths, concurrent writers and +failed extractions without downloading headers. +Offline use accepts pinned git checkouts through `--kernels-dir`. +GitHub and GitLab CI run the same check. It verifies header/type compatibility; +it does not execute the real kernel or validate its critical sections. Host +behavior tests separately compile the actual FreeRTOS PAL against a modeled +kernel snapshot, and the actual POSIX PAL against injected clock readings. + +## Consequences + +- The PAL API and ABI change: integrators must rebuild and provide both callbacks. + Existing tick/uptime callbacks remain in `time_ms`; `wal_clock_time` needs + synchronized UTC. `pal_is_valid` rejects either missing callback. +- FreeRTOS applications may use the default `gettimeofday()` callback when their + platform exposes UTC through libc, or replace it before initialization when + they need another source. Initialization only validates function pointers and + cannot detect whether a non-NULL callback supplies synchronized UTC. +- Tick-based readings retain kernel tick resolution. Tickless idle counts only + the sleep that the port compensates in kernel ticks. A kernel restart establishes + a new monotonic epoch, and the application restores UTC if it was not retained. +- Synchronizing a custom PAL UTC callback alone does not set libc/mbedTLS's + certificate validation clock. A port must connect those to the same UTC source + when its TLS configuration enables certificate-time checks. +- Regression coverage checks tick widths/frequencies/wraps, POSIX and FreeRTOS + unavailable/failed UTC reads, application UTC corrections and + Connection liveness. An independent ATOP peer + verifies the URL MD5 signature before echoing the UTC timestamp, and rejects + missing signatures, invalid digests and timestamp tampering. Host shims do not + replace device integration checks. diff --git a/modules/iot-client/include/iot_atop.h b/modules/iot-client/include/iot_atop.h index 52b66ec..f51ce18 100644 --- a/modules/iot-client/include/iot_atop.h +++ b/modules/iot-client/include/iot_atop.h @@ -36,10 +36,11 @@ * ## Example * * ```c + * uint32_t timestamp = (uint32_t)(client->pal->wal_clock_time() / 1000U); * char body[128]; * snprintf(body, sizeof(body), * "{\"schemaId\":\"%s\",\"version\":\"\",\"t\":%u}", - * schema_id, (unsigned)time(NULL)); + * schema_id, (unsigned)timestamp); * * iot_atop_request_t req = { .api = "tuya.device.schema.newest.get", * .version = "1.0", diff --git a/modules/iot-client/src/atop.c b/modules/iot-client/src/atop.c index 922d632..82e3fea 100644 --- a/modules/iot-client/src/atop.c +++ b/modules/iot-client/src/atop.c @@ -7,7 +7,6 @@ #include #include #include -#include #include @@ -87,7 +86,7 @@ int atop_activate_request(const pal_t *pal, const activite_request_t *request, a IOT_LOGE("post buffer malloc fail"); return OPRT_MALLOC_FAILED; } - uint32_t timestamp = (uint32_t)time(NULL); + uint32_t timestamp = (uint32_t)(pal->wal_clock_time() / 1000U); /* activate JSON format */ size_t offset = 0; @@ -311,7 +310,7 @@ int atop_device_meta_save(const pal_t *pal, const device_meta_save_request_t *re memset(response, 0, sizeof(device_meta_save_response_t)); - uint32_t timestamp = (uint32_t)time(NULL); + uint32_t timestamp = (uint32_t)(pal->wal_clock_time() / 1000U); /* {"t":12345,"metas":"{\"smain_network_sdk_full_version\":\"agentic-kit 0.1\"}"} */ size_t post_len = 64 + strlen(request->sdk_version) * 2; @@ -395,7 +394,7 @@ int atop_qrcode_info_get(const pal_t *pal, const qrcode_info_request_t *request, memset(response, 0, sizeof(qrcode_info_response_t)); - uint32_t timestamp = (uint32_t)time(NULL); + uint32_t timestamp = (uint32_t)(pal->wal_clock_time() / 1000U); cJSON *root = cJSON_CreateObject(); if (root == NULL) { @@ -499,7 +498,7 @@ int atop_ai_token_get(const pal_t *pal, const ai_token_request_t *request, ai_to memset(response, 0, sizeof(ai_token_response_t)); int rt = OPRT_OK; - uint32_t timestamp = (uint32_t)time(NULL); + uint32_t timestamp = (uint32_t)(pal->wal_clock_time() / 1000U); // Create request JSON cJSON *root = cJSON_CreateObject(); if (root == NULL) { @@ -612,7 +611,7 @@ int atop_schema_newest_get(const pal_t *pal, const schema_newest_request_t *requ memset(response, 0, sizeof(schema_newest_response_t)); - uint32_t timestamp = (uint32_t)time(NULL); + uint32_t timestamp = (uint32_t)(pal->wal_clock_time() / 1000U); cJSON *root = cJSON_CreateObject(); if (root == NULL) { @@ -748,7 +747,7 @@ int atop_upgrade_get(const pal_t *pal, const ota_upgrade_request_t *request, ota memset(response, 0, sizeof(ota_upgrade_response_t)); - uint32_t timestamp = (uint32_t)time(NULL); + uint32_t timestamp = (uint32_t)(pal->wal_clock_time() / 1000U); cJSON *root = cJSON_CreateObject(); if (root == NULL) { @@ -885,7 +884,7 @@ int atop_version_update(const pal_t *pal, const ota_version_update_request_t *re const char *pv = (request->pv != NULL && request->pv[0] != '\0') ? request->pv : "2.3"; const char *bv = (request->bv != NULL && request->bv[0] != '\0') ? request->bv : "2.0"; - uint32_t timestamp = (uint32_t)time(NULL); + uint32_t timestamp = (uint32_t)(pal->wal_clock_time() / 1000U); /* versions is a JSON string (doubly-escaped) containing an array */ #define VER_UPDATE_BUF_LEN 256 @@ -952,7 +951,7 @@ int atop_upgrade_status_update(const pal_t *pal, const ota_status_update_request return OPRT_INVALID_PARAMETER; } - uint32_t timestamp = (uint32_t)time(NULL); + uint32_t timestamp = (uint32_t)(pal->wal_clock_time() / 1000U); #define STATUS_UPD_BUF_LEN 128 char *post_data = (char *)pal->malloc(STATUS_UPD_BUF_LEN); diff --git a/modules/iot-client/src/atop_base.c b/modules/iot-client/src/atop_base.c index 5a6e90f..bf30e68 100644 --- a/modules/iot-client/src/atop_base.c +++ b/modules/iot-client/src/atop_base.c @@ -490,6 +490,11 @@ static int atop_response_result_parse_cjson(const uint8_t *input, size_t ilen, a params[idx].key = "et"; params[idx++].value = "3"; + /* t=0 means the PAL reports UTC unavailable (wal_clock_time contract); + * the cloud will likely reject the request, so say so instead of failing silently. */ + if (request->timestamp == 0) { + IOT_LOGW("atop request %s signed with t=0; PAL reports UTC unavailable", request->api); + } char ts_str[11]; snprintf(ts_str, sizeof(ts_str), "%" PRIu32, request->timestamp); params[idx].key = "t"; diff --git a/modules/iot-client/src/iot_atop.c b/modules/iot-client/src/iot_atop.c index cde8d72..3f5800c 100644 --- a/modules/iot-client/src/iot_atop.c +++ b/modules/iot-client/src/iot_atop.c @@ -22,7 +22,6 @@ #include #include -#include /* The public buffers mirror the internal envelope buffers. Static-assert rather * than re-deriving one from the other, so a future widening of either side @@ -98,7 +97,7 @@ int iot_atop_call(iot_client_t *client, .api = request->api, .version = request->version, .devid = client->devid, - .timestamp = (uint32_t)time(NULL), + .timestamp = (uint32_t)(client->pal->wal_clock_time() / 1000U), .data = (void *)body, .datalen = strlen(body), .host = host[0] ? host : NULL, diff --git a/modules/iot-client/src/iot_client.c b/modules/iot-client/src/iot_client.c index 6d4e45f..7f9c6f6 100644 --- a/modules/iot-client/src/iot_client.c +++ b/modules/iot-client/src/iot_client.c @@ -18,7 +18,6 @@ #include #include #include -#include static const pal_t *g_iot_pal = NULL; @@ -380,7 +379,7 @@ IOT_API int iot_client_reset(iot_client_t *client, int sn = snprintf(body, sizeof(body), "{\"resetFactory\":%s,\"t\":%" PRIu32 "}", (scope == IOT_RESET_FACTORY) ? "true" : "false", - (uint32_t)time(NULL)); + (uint32_t)(client->pal->wal_clock_time() / 1000U)); if (sn < 0 || (size_t)sn >= sizeof(body)) { return OPRT_COMMUNICATION_ERROR; } @@ -681,7 +680,7 @@ IOT_API int iot_get_binding_status(const iot_binding_status_request_t *request, return OPRT_UNINITIALIZED; } - uint32_t timestamp = (uint32_t)time(NULL); + uint32_t timestamp = (uint32_t)(pal->wal_clock_time() / 1000U); char body[32]; int body_len = snprintf(body, sizeof(body), "{\"t\":%" PRIu32 "}", timestamp); if (body_len < 0 || (size_t)body_len >= sizeof(body)) { diff --git a/modules/iot-client/src/iot_dp.c b/modules/iot-client/src/iot_dp.c index d1014a1..32e69ba 100644 --- a/modules/iot-client/src/iot_dp.c +++ b/modules/iot-client/src/iot_dp.c @@ -21,7 +21,6 @@ #include #include #include -#include /* ---- Tuya MQTT protocol numbers (centralised here on purpose) ---- */ #define DP_PROTO_REPORT 4 /* uplink DP report (device -> cloud) */ @@ -553,7 +552,8 @@ static char *dp_build_report_json(iot_client_t *client, dp_sel_t sel, uint8_t on cJSON *root = cJSON_CreateObject(); if (!root) return NULL; cJSON_AddNumberToObject(root, "protocol", DP_PROTO_REPORT); - cJSON_AddNumberToObject(root, "t", (double)(uint32_t)time(NULL)); + uint32_t timestamp = (uint32_t)(pal->wal_clock_time() / 1000U); + cJSON_AddNumberToObject(root, "t", (double)timestamp); cJSON *data = cJSON_CreateObject(); if (!data) { cJSON_Delete(root); return NULL; } if (!cJSON_AddItemToObject(root, "data", data)) { cJSON_Delete(data); cJSON_Delete(root); return NULL; } @@ -578,6 +578,11 @@ static char *dp_build_report_json(iot_client_t *client, dp_sel_t sel, uint8_t on char *s = cJSON_PrintUnformatted(root); cJSON_Delete(root); if (!s) return NULL; + if (cnt > 0 && timestamp == 0) { + /* Only a nonempty report carries a timestamp to the cloud. Empty + * batches are discarded by the caller without publishing. */ + IOT_LOGW("dp: report carries t=0; PAL reports UTC unavailable"); + } *count_out = cnt; return s; } diff --git a/modules/iot-client/test/iot_atop_call_test.c b/modules/iot-client/test/iot_atop_call_test.c index 887c77b..e698d28 100644 --- a/modules/iot-client/test/iot_atop_call_test.c +++ b/modules/iot-client/test/iot_atop_call_test.c @@ -26,8 +26,10 @@ #include #include "iot_atop.h" +#include "cJSON.h" #include "iot_client.h" #include "iot_internal.h" +#include "http_client_interface.h" #include "test_log.h" #include "log.h" @@ -692,6 +694,103 @@ static int test_binding_status_after_reset_destroys_client(void) return rt == OPRT_OK && status == IOT_BINDING_STATUS_UNBOUND ? 0 : -1; } +static uint64_t fixed_wall_ms(void) +{ + return 1600000000123ULL; +} + +static int test_signing_uses_pal_wall_clock(void) +{ + pal_t pal = *g_client.pal; + pal.wal_clock_time = fixed_wall_ms; + iot_client_t client = g_client; + client.pal = &pal; + iot_atop_request_t request = { + .api = "tuya.test.echo.timestamp", .version = "1.0", .data = "{}" + }; + iot_atop_response_t response = {0}; + int rc = iot_atop_call(&client, &request, &response); + int result = -1; + if (rc == OPRT_OK && response.result) { + cJSON *json = cJSON_Parse(response.result); + const cJSON *timestamp = cJSON_GetObjectItem(json, "timestamp"); + const cJSON *verified = cJSON_GetObjectItem(json, "signature_verified"); + if (cJSON_IsString(timestamp) && + strcmp(timestamp->valuestring, "1600000000") == 0 && + cJSON_IsTrue(verified)) result = 0; + cJSON_Delete(json); + } + iot_atop_response_free(&client, &response); + return result; +} + +static uint64_t unavailable_wall_ms(void) +{ + return 0; +} + +/* t=0 (PAL reports UTC unavailable) still signs and reaches the peer; the + * warning is the only device-visible trace of the misconfiguration. */ +static int test_signing_warns_when_utc_unavailable(void) +{ + pal_t pal = *g_client.pal; + pal.wal_clock_time = unavailable_wall_ms; + iot_client_t client = g_client; + client.pal = &pal; + iot_atop_request_t request = { + .api = "tuya.test.echo.timestamp", .version = "1.0", .data = "{}" + }; + iot_atop_response_t response = {0}; + test_log_capture_begin(http_log_capture, sizeof http_log_capture); + int rc = iot_atop_call(&client, &request, &response); + test_log_capture_end(); + + int result = -1; + if (rc == OPRT_OK && response.result) { + cJSON *json = cJSON_Parse(response.result); + const cJSON *timestamp = cJSON_GetObjectItem(json, "timestamp"); + if (cJSON_IsString(timestamp) && strcmp(timestamp->valuestring, "0") == 0 && + strstr(http_log_capture, "UTC unavailable") != NULL) result = 0; + cJSON_Delete(json); + } + iot_atop_response_free(&client, &response); + return result; +} + +static int test_peer_rejects_invalid_signature(void) +{ + static const char base[] = + "/d.json?a=tuya.test.echo.timestamp&devId=" TEST_DEVID "&et=3&t="; + const char *cases[] = { + "1600000000&v=1.0", /* Missing sign. */ + "1600000000&v=1.0&sign=00000000000000000000000000000000", + /* The signature is valid for t=1600000000, not this altered t. */ + "1600000001&v=1.0&sign=06962ff5a62cd85b7ce8d1577f82dec2", + }; + static const uint8_t body[] = "data=00"; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + char path[256]; + snprintf(path, sizeof(path), "%s%s", base, cases[i]); + http_client_request_t request = { + .cacert = g_cacert, .host = MOCK_HOST, .port = MOCK_PORT, + .method = "POST", .path = path, .body = body, + .body_length = sizeof(body) - 1, .timeout_ms = 3000, + .pal = g_client.pal, + }; + http_client_response_t response = {0}; + http_client_status_t rc = http_client_request(&request, &response); + int rejected = rc == HTTP_CLIENT_SUCCESS && response.status_code == 403 && + response.body && strstr((const char *)response.body, + "Invalid ATOP signature"); + http_client_free(g_client.pal, &response); + if (!rejected) { + printf(" invalid signature case %zu was not rejected by the peer\n", i); + return -1; + } + } + return 0; +} + int main(void) { printf("========== ATOP Generic Call Test Suite ==========\n"); @@ -740,6 +839,9 @@ int main(void) RUN_TEST(test_binding_status_request_guards); /* Round trips */ + RUN_TEST(test_signing_uses_pal_wall_clock); + RUN_TEST(test_signing_warns_when_utc_unavailable); + RUN_TEST(test_peer_rejects_invalid_signature); RUN_TEST(test_call_returns_result_json); RUN_TEST(test_call_returns_json_array); RUN_TEST(test_null_body_defaults_to_empty_object); diff --git a/modules/iot-client/test/iot_dp_test.c b/modules/iot-client/test/iot_dp_test.c index 4c6c157..3406ff6 100644 --- a/modules/iot-client/test/iot_dp_test.c +++ b/modules/iot-client/test/iot_dp_test.c @@ -6,8 +6,9 @@ * iot_dp_dispatch_downlink / iot_dp_deinit) plus the public iot_dp_* API * directly on a hand-built iot_client_t — no MQTT needed. * - * Two cases use mocks (reusing the existing harness pattern): + * Three cases use mocks (reusing the existing harness pattern): * - report round-trip via message_mock.py (publish -> echo -> dispatch); + * - report with PAL UTC unavailable: still publishes, logs why it carries t=0; * - schema upgrade via atop_mock.py (tuya.device.schema.newest.get). */ @@ -27,6 +28,7 @@ #include "iot_dp.h" #include "iot_dp_internal.h" #include "atop.h" +#include "test_log.h" /* ---- mock endpoints (match atop_test.c / iot_client_message_test.c) ---- */ #define MSG_MOCK_PORT 11885 @@ -523,6 +525,62 @@ static int test_validate_json(void) return rc; } +static uint64_t unavailable_utc_ms(void) +{ + return 0; +} + +/* No report is emitted when restored DPs are clean or no values are known; + * repeated polling must not warn about the timestamp of a discarded report. */ +static int test_empty_reports_do_not_warn_when_utc_unavailable(void) +{ + pal_t utc_less = *get_default_pal(); + utc_less.wal_clock_time = unavailable_utc_ms; + iot_client_t *restored = make_client(&utc_less, TEST_SCHEMA); + iot_client_t *empty[] = { + make_client(&utc_less, TEST_SCHEMA), + make_client(&utc_less, "[]"), + make_client(&utc_less, NULL), + }; + int rc = -1; + char capture[4096]; + + if (!restored) goto out; + for (size_t i = 0; i < sizeof empty / sizeof empty[0]; i++) { + if (!empty[i]) goto out; + } + if (iot_dp_restore_json(restored, "{\"dps\":{\"1\":true}}") != OPRT_OK) { + printf(" restore failed\n"); + goto out; + } + + test_log_capture_begin(capture, sizeof capture); + int reports_ok = 1; + for (int repeat = 0; repeat < 3; repeat++) { + if (iot_dp_report_all_dirty(restored) != OPRT_OK) reports_ok = 0; + for (size_t i = 0; i < sizeof empty / sizeof empty[0]; i++) { + if (iot_dp_report_all_dirty(empty[i]) != OPRT_OK) reports_ok = 0; + if (iot_dp_report_all(empty[i]) != OPRT_OK) reports_ok = 0; + } + } + test_log_capture_end(); + if (!reports_ok) { + printf(" empty report should be a network-free no-op\n"); + goto out; + } + if (strstr(capture, "UTC unavailable")) { + printf(" empty report logged a UTC warning\n"); + goto out; + } + rc = 0; +out: + destroy_client(restored); + for (size_t i = 0; i < sizeof empty / sizeof empty[0]; i++) { + destroy_client(empty[i]); + } + return rc; +} + /* ============================================================================ * Mock-backed tests * ============================================================================ */ @@ -573,6 +631,59 @@ static int test_report_roundtrip(void) return rc; } +/* [10b] report with PAL UTC unavailable: still publishes (echoed back with + * t=0), and the warning explains why. */ +static int test_report_warns_when_utc_unavailable(void) +{ + const pal_t *pal = get_default_pal(); + pal_t utc_less = *pal; + utc_less.wal_clock_time = unavailable_utc_ms; + iot_client_t *client = (iot_client_t *)pal->malloc(sizeof(iot_client_t)); + if (!client) return -1; + memset(client, 0, sizeof(iot_client_t)); + client->pal = &utc_less; + strncpy(client->devid, TEST_DEVID, sizeof(client->devid) - 1); + strncpy(client->secret_key, TEST_SECRET_KEY, sizeof(client->secret_key) - 1); + strncpy(client->local_key, TEST_LOCAL_KEY, sizeof(client->local_key) - 1); + snprintf(client->mqtt_url, sizeof(client->mqtt_url), "%s", MSG_MOCK_URL); + client->cacert = g_cacert; + client->message_callback = msg_callback; + client->schema = pal_strdup(pal, TEST_SCHEMA); + iot_dp_rebuild(client); + + int rc = -1; + char capture[2048]; + if (iot_client_message_connect(client) != OPRT_OK) { printf(" connect failed\n"); goto out; } + + /* drain the mock's initial raw message */ + msg_cb_count = 0; + for (int i = 0; i < 20 && msg_cb_count == 0; i++) iot_client_message_process(client, 50); + msg_cb_count = 0; + msg_cb_data[0] = '\0'; + + iot_dp_value_t v = { .type = IOT_DP_TYPE_BOOL, .value.boolean = true }; + test_log_capture_begin(capture, sizeof capture); + if (iot_dp_report(client, 1, &v) != OPRT_OK) { test_log_capture_end(); printf(" iot_dp_report failed\n"); goto out; } + + for (int i = 0; i < 40 && msg_cb_count == 0; i++) iot_client_message_process(client, 50); + test_log_capture_end(); + if (msg_cb_count == 0) { printf(" no echo received\n"); goto out; } + printf(" echoed report: %s\n", msg_cb_data); + if (!strstr(msg_cb_data, "\"t\":0") || strstr(capture, "UTC unavailable") == NULL) { + printf(" t=0 report missing from payload or warning missing from log\n"); + goto out; + } + rc = 0; +out: + iot_client_message_disconnect(client); + iot_dp_deinit(client); + if (client->schema) pal->free(client->schema); + client->mqtt_url[0] = '\0'; + client->cacert = NULL; + pal->free(client); + return rc; +} + /* [11a] schema upgrade via atop_mock: new schema -> rebuild + callback. */ static int test_schema_check_update(void) { @@ -710,9 +821,11 @@ int main(void) RUN_TEST(test_schema_obj_format); RUN_TEST(test_writability_modes); RUN_TEST(test_validate_json); + RUN_TEST(test_empty_reports_do_not_warn_when_utc_unavailable); /* mock-backed */ RUN_TEST(test_report_roundtrip); + RUN_TEST(test_report_warns_when_utc_unavailable); RUN_TEST(test_schema_check_update); RUN_TEST(test_schema_newest_no_update); diff --git a/modules/iot-client/test/mock/atop_mock.py b/modules/iot-client/test/mock/atop_mock.py index 23fe5b1..fbe0d42 100755 --- a/modules/iot-client/test/mock/atop_mock.py +++ b/modules/iot-client/test/mock/atop_mock.py @@ -712,14 +712,17 @@ def do_POST(self): else: key = self.config.get('authkey', '').encode('utf-8') - # Verify signature (optional for mock, but good to test) - # Note: We need the key to verify, but we'll skip strict verification for mock - # if not verify_signature(url_params.copy(), key.decode('utf-8')): - # print(f"⚠️ Signature verification failed (skipped in mock)") - # Read request body content_length = int(self.headers.get('Content-Length', 0)) body = self.rfile.read(content_length).decode('utf-8') + + # The clock regression endpoint validates the actual wire signature, + # including t, independently of the C SDK. Reject before decrypting + # so malformed signatures cannot pass as decryption errors. + if api == 'tuya.test.echo.timestamp' and not verify_signature( + url_params.copy(), key.decode('utf-8')): + self.send_error(403, "Invalid ATOP signature") + return # Parse request body (format: data=HEX_STRING) if not body.startswith('data='): @@ -757,6 +760,14 @@ def do_POST(self): response_json = handle_version_update(decrypted_data, self.config) elif api == 'tuya.device.upgrade.status.update': response_json = handle_upgrade_status_update(decrypted_data, self.config) + elif api == 'tuya.test.echo.timestamp': + # Reached only after MD5 signature validation above. + response_json = json.dumps({ + "success": True, + "t": int(time.time()), + "result": {"timestamp": url_params.get('t', ''), + "signature_verified": True} + }, separators=(',', ':')) elif api == 'tuya.test.huge.result': # Test-only: a response deliberately larger than the default # 4096-byte AGENTIC_KIT_RESPONSE_BUFFER_SIZE, reproducing the real failure diff --git a/modules/rtc-tcp-client/CONTEXT.md b/modules/rtc-tcp-client/CONTEXT.md index 25c0efd..63e0d05 100644 --- a/modules/rtc-tcp-client/CONTEXT.md +++ b/modules/rtc-tcp-client/CONTEXT.md @@ -7,6 +7,17 @@ conversation, it does not own device DPs (that is the [IoT Client](../iot-client ## Language +**UTC timestamp / Monotonic time**: +Media timestamps and Ping payloads use the Unix UTC millisecond clock +(`pal_t.wal_clock_time`). Connection deadlines, Keepalive and receive-drain budgets use +elapsed milliseconds (`pal_t.time_ms`), which are never adjusted during UTC +synchronization. The application owns UTC synchronization before opening a +Connection. The FreeRTOS default reads libc `gettimeofday()`; platforms may +replace that callback when their UTC source is not exposed through libc. +The shared PAL decision is recorded in the +[IoT Client's ADR 0003](../iot-client/docs/adr/0003-pal-separates-utc-and-monotonic-time.md). +_Avoid_: uptime (for media timestamps), wall-clock time (for a timeout budget). + **Connection**: The single TCP+TLS link to the AI server, opened by `tai_connect` (TLS handshake → ClientHello → SessionNew → start the background receive thread) and torn down by diff --git a/modules/rtc-tcp-client/src/tai_client.c b/modules/rtc-tcp-client/src/tai_client.c index 4d9e216..d0b1327 100644 --- a/modules/rtc-tcp-client/src/tai_client.c +++ b/modules/rtc-tcp-client/src/tai_client.c @@ -416,6 +416,13 @@ int tai_connect(tai_ctx_t *ctx) TAI_LOGI(ctx->pal, TAG, "connecting to %s:%u", ctx->host, ctx->port); + /* An unsynchronized PAL (wal_clock_time returns 0) stamps every media and + * Ping header with 0, with no other device-visible signal. Warn once per + * Connection instead of per packet. */ + if (ctx->pal->wal_clock_time() == 0) { + TAI_LOGW(ctx->pal, TAG, "PAL reports UTC unavailable; media and Ping timestamps will be 0"); + } + /* 1. Generate 32-byte random for security-suit */ rc = tai_random_bytes(ctx->pal, ctx->encrypt_random, 32); if (rc != 0) { diff --git a/modules/rtc-tcp-client/src/tai_protocol.c b/modules/rtc-tcp-client/src/tai_protocol.c index 960929c..68144b0 100644 --- a/modules/rtc-tcp-client/src/tai_protocol.c +++ b/modules/rtc-tcp-client/src/tai_protocol.c @@ -367,7 +367,7 @@ int tai_proto_build_audio_hdr(tai_ctx_t *ctx, /* Append the 8-byte media header — the first bytes of the packet payload. */ int hl = tai_pack_media_hdr(ctx->proto_ver, data_id, stream_flag, - ctx->pal->time_ms(), buf + pos, buf_size - (size_t)pos); + ctx->pal->wal_clock_time(), buf + pos, buf_size - (size_t)pos); if (hl < 0) return hl; return pos + hl; } @@ -419,7 +419,7 @@ int tai_proto_build_image_hdr(tai_ctx_t *ctx, if (pos < 0) return pos; int hl = tai_pack_media_hdr(ctx->proto_ver, data_id, stream_flag, - ctx->pal->time_ms(), buf + pos, buf_size - (size_t)pos); + ctx->pal->wal_clock_time(), buf + pos, buf_size - (size_t)pos); if (hl < 0) return hl; return pos + hl; } @@ -432,7 +432,7 @@ int tai_proto_build_ping(tai_ctx_t *ctx, uint8_t *buf, size_t buf_size) uint8_t s_ts[8]; tai_attr_t attrs[1]; attrs[0] = tai_attr_u64v(TAI_ATTR_CLIENT_TIMESTAMP, s_ts, - ctx->pal->time_ms()); + ctx->pal->wal_clock_time()); return tai_packet_encode(ctx->proto_ver, TAI_PKT_PING, attrs, 1, NULL, 0, buf, buf_size); } diff --git a/modules/rtc-tcp-client/test/tai_pal_loopback.c b/modules/rtc-tcp-client/test/tai_pal_loopback.c index 59cb3cb..e2205e5 100644 --- a/modules/rtc-tcp-client/test/tai_pal_loopback.c +++ b/modules/rtc-tcp-client/test/tai_pal_loopback.c @@ -411,7 +411,7 @@ static void lb_sleep_ms(uint32_t ms) while (nanosleep(&ts, &ts) < 0 && errno == EINTR) { } } -static uint64_t lb_time_ms(void); /* defined below; used for a real-time recv timeout */ +static uint64_t lb_time_ms(void); /* defined below; monotonic recv timeout */ static int lb_tcp_recv(void *tcp, uint8_t *buf, size_t buf_len, uint32_t timeout_ms) @@ -478,6 +478,13 @@ static int lb_tcp_poll(void *tcp, int events, uint32_t timeout_ms) /* ========================================================================= * Time / memory * ========================================================================= */ +static uint64_t lb_wal_clock_time(void) +{ + struct timespec ts; + if (clock_gettime(CLOCK_REALTIME, &ts) != 0 || ts.tv_sec <= 0) return 0; + return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)ts.tv_nsec / 1000000ULL; +} + static uint64_t lb_time_ms(void) { struct timespec ts; @@ -532,6 +539,7 @@ static const pal_t g_loopback_pal = { .tcp_close = lb_tcp_close, .tcp_poll = lb_tcp_poll, .time_ms = lb_time_ms, + .wal_clock_time = lb_wal_clock_time, .malloc = lb_malloc, .free = lb_free, .mutex_create = lb_mutex_create, diff --git a/modules/rtc-tcp-client/test/tai_pal_loopback.h b/modules/rtc-tcp-client/test/tai_pal_loopback.h index f20297c..7e291f8 100644 --- a/modules/rtc-tcp-client/test/tai_pal_loopback.h +++ b/modules/rtc-tcp-client/test/tai_pal_loopback.h @@ -2,9 +2,9 @@ * tests/tai_pal_loopback.h -- In-memory PAL for deterministic integration tests. * * Replaces the TLS layer with a pair of byte FIFOs so tests can drive the - * library without any network. Crypto uses OpenSSL directly (same as the - * openssl PAL), time is a controllable virtual clock, and randomness is - * seeded for reproducibility. + * library without any network. PAL wal_clock_time uses real CLOCK_REALTIME and + * time_ms uses real CLOCK_MONOTONIC. The separate virtual clock helpers + * below do not change either PAL clock. * * test library * ---- ------- @@ -39,7 +39,7 @@ void tai_loopback_seed_random(uint64_t seed); /* Virtual clock. Defaults to 1700000000000 ms after reset. * - * NOT wired to the receive worker: the worker reads real CLOCK_MONOTONIC, so + * NOT wired to the receive worker: pal->time_ms reads CLOCK_MONOTONIC, so * advancing this clock does not move its Ping schedule or liveness deadline. * Keepalive and liveness tests therefore need real timeouts and real sleeps -- * driving them with advance_time() looks like the production code is broken. */ diff --git a/modules/rtc-tcp-client/test/test_core.c b/modules/rtc-tcp-client/test/test_core.c index 475710e..7bb1d6d 100644 --- a/modules/rtc-tcp-client/test/test_core.c +++ b/modules/rtc-tcp-client/test/test_core.c @@ -23,7 +23,8 @@ * Minimal stub PAL. TLS and crypto are no longer PAL responsibilities -- * the SDK uses mbedTLS internally -- so the stub only needs time/memory/log. * ------------------------------------------------------------------------- */ -static uint64_t stub_time(void) { return 1700000000000ULL; } +static uint64_t stub_wal_clock_time(void) { return 1700000000000ULL; } +static uint64_t stub_time_ms(void) { return 0; } static void *stub_malloc(size_t s) { return malloc(s); } static void stub_free(void *p) { free(p); } /* Trivial single-threaded mutex stubs: the unit tests run on one thread, but @@ -40,7 +41,8 @@ static const pal_t g_stub_pal = { .tcp_recv = NULL, .tcp_close = NULL, .tcp_poll = NULL, - .time_ms = stub_time, + .time_ms = stub_time_ms, + .wal_clock_time = stub_wal_clock_time, .malloc = stub_malloc, .free = stub_free, .mutex_create = stub_mutex_create, diff --git a/modules/rtc-tcp-client/test/test_integration.c b/modules/rtc-tcp-client/test/test_integration.c index 3cf0844..0472206 100644 --- a/modules/rtc-tcp-client/test/test_integration.c +++ b/modules/rtc-tcp-client/test/test_integration.c @@ -418,7 +418,7 @@ static size_t server_send_audio(tai_ctx_t *ctx, { uint8_t payload[4096]; int hlen = tai_pack_media_hdr(TAI_VER_21, TAI_DATA_ID_AUDIO_DOWN, - stream_flag, ctx->pal->time_ms(), + stream_flag, ctx->pal->wal_clock_time(), payload, sizeof(payload)); if (hlen <= 0) return 0; if ((size_t)hlen + alen > sizeof(payload)) return 0; @@ -444,7 +444,7 @@ static size_t server_send_image(tai_ctx_t *ctx, { uint8_t payload[4096]; int hlen = tai_pack_media_hdr(TAI_VER_21, TAI_DATA_ID_IMAGE_UP, - stream_flag, ctx->pal->time_ms(), + stream_flag, ctx->pal->wal_clock_time(), payload, sizeof(payload)); if (hlen <= 0) return 0; if ((size_t)hlen + ilen > sizeof(payload)) return 0; @@ -497,7 +497,7 @@ static int build_audio_app(tai_ctx_t *ctx, uint8_t stream_flag, { uint8_t payload[8192]; int hlen = tai_pack_media_hdr(TAI_VER_21, TAI_DATA_ID_AUDIO_DOWN, - stream_flag, ctx->pal->time_ms(), + stream_flag, ctx->pal->wal_clock_time(), payload, sizeof(payload)); if (hlen <= 0 || (size_t)hlen + alen > sizeof(payload)) return -1; if (alen) memcpy(payload + hlen, audio, alen); @@ -2623,6 +2623,89 @@ static void test_long_receive_pause_liveness(void) tai_ctx_deinit(ctx); } +static uint64_t adjusted_wall_ms; + +static uint64_t test_wall_ms(void) +{ + pthread_mutex_lock(&g_st.mtx); + uint64_t value = adjusted_wall_ms; + pthread_mutex_unlock(&g_st.mtx); + return value; +} + +static void test_wall_clock_does_not_change_liveness(void) +{ + SECTION("wall_clock_does_not_change_liveness"); + static uint8_t ctx_mem[sizeof(struct tai_ctx)]; + tai_ctx_t *ctx = setup_ctx(ctx_mem); + CHECK(ctx != NULL); + if (!ctx) return; + pal_t pal = *ctx->pal; + pal.wal_clock_time = test_wall_ms; + ctx->pal = &pal; + ctx->ping_interval_ms = 5000; + ctx->ping_timeout_ms = 800; + adjusted_wall_ms = 1700000000000ULL; + int rc = tai_connect(ctx); + CHECK_EQ_INT(rc, TAI_OK); + if (rc == TAI_OK) { + pthread_mutex_lock(&g_st.mtx); + adjusted_wall_ms += 86400000ULL; + pthread_mutex_unlock(&g_st.mtx); + sleep_ms(100); + pthread_mutex_lock(&g_st.mtx); + CHECK_EQ_INT(g_st.disconnect_calls, 0); + adjusted_wall_ms -= 2 * 86400000ULL; + pthread_mutex_unlock(&g_st.mtx); + sleep_ms(100); + pthread_mutex_lock(&g_st.mtx); + CHECK_EQ_INT(g_st.disconnect_calls, 0); + pthread_mutex_unlock(&g_st.mtx); + /* Frozen UTC must still expire a genuinely idle Connection. */ + CHECK(WAIT_FOR(g_st.disconnect_calls > 0, 2000)); + tai_disconnect(ctx); + CHECK_EQ_INT(g_st.disconnect_detail, TAI_TRANSPORT_PING_TIMEOUT); + } + tai_ctx_deinit(ctx); +} + +static void test_connect_warns_when_utc_unavailable(void) +{ + SECTION("connect_warns_when_utc_unavailable"); + static uint8_t ctx_mem[sizeof(struct tai_ctx)]; + tai_ctx_t *ctx = setup_ctx(ctx_mem); + CHECK(ctx != NULL); + if (!ctx) return; + pal_t pal = *ctx->pal; + pal.wal_clock_time = test_wall_ms; + ctx->pal = &pal; + adjusted_wall_ms = 0; /* PAL reports UTC unavailable */ + char capture[2048]; + test_log_capture_begin(capture, sizeof capture); + int rc = tai_connect(ctx); + test_log_capture_end(); + CHECK_EQ_INT(rc, TAI_OK); + CHECK(strstr(capture, "UTC unavailable") != NULL); + tai_disconnect(ctx); /* joins the worker before the static ctx is reused */ + tai_ctx_deinit(ctx); +} + +static void test_pal_clock_contract(void) +{ + SECTION("pal_clock_contract"); + const pal_t *pal = tai_pal_posix(); + uint64_t before = (uint64_t)time(NULL) * 1000U; + uint64_t wall = pal->wal_clock_time(); + uint64_t after = (uint64_t)time(NULL) * 1000U + 999U; + CHECK(wall >= before && wall <= after); + pal_t incomplete = *pal; + incomplete.wal_clock_time = NULL; + CHECK(!pal_is_valid(&incomplete)); + incomplete = *pal; + incomplete.time_ms = NULL; + CHECK(!pal_is_valid(&incomplete)); +} + static void check_pal_sleep(const pal_t *pal, void *tcp) { uint8_t byte = 0; @@ -2704,6 +2787,9 @@ int main(void) test_partial_frame_blocking_recv(); test_long_receive_pause_liveness(); test_pal_sleep(); + test_pal_clock_contract(); + test_wall_clock_does_not_change_liveness(); + test_connect_warns_when_utc_unavailable(); test_text_query(); test_audio_roundtrip(); test_image_query(); diff --git a/pal/pal.h b/pal/pal.h index 39e7055..e2c142f 100644 --- a/pal/pal.h +++ b/pal/pal.h @@ -10,7 +10,11 @@ * * Two ready-made implementations are provided in the pal/ directory: * pal_posix.c -- POSIX sockets + pthreads (Linux / macOS) - * pal_esp_idf.c -- lwIP sockets + FreeRTOS (ESP32) + * pal_freertos.c -- lwIP sockets + FreeRTOS (ESP32) + * The FreeRTOS UTC callback reads libc gettimeofday(); the POSIX adapter reads + * CLOCK_REALTIME. The application/platform owns clock synchronization. If the + * default UTC source does not suit the target, copy its table into a long-lived + * application PAL and replace wal_clock_time before using the SDK. */ #ifndef PAL_H @@ -52,13 +56,11 @@ extern "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 * it never passes that table through pal_is_valid()). - * The host build compiles three of them -- pal_freertos.c is in no CMake - * target here, only the ESP-IDF component -- and C99 zero-fills whatever a - * designated initializer omits, with no -Wmissing-field-initializers - * anywhere in this project. So a green build and a green ctest run prove - * nothing about the FreeRTOS table: it silently carries a NULL member, and - * on device you get either pal_is_valid() refusing the pal at init or a - * call straight through a NULL pointer. + * 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. */ typedef struct pal { @@ -96,9 +98,18 @@ typedef struct pal { int (*tcp_poll)(void *handle, int events, uint32_t timeout_ms); /* --- Time ------------------------------------------------------------ - * Return milliseconds since some monotonic epoch (need not be wall-clock). + * time_ms: milliseconds since a local monotonic epoch, for elapsed + * time, deadlines and keepalive. Never adjusted by wall-clock sync. + * wal_clock_time: wall-clock milliseconds since 1970-01-01 00:00:00 UTC + * (Unix epoch, no timezone offset). Return 0 while UTC is unavailable. + * Synchronization may move this clock forwards or backwards. The app + * must synchronize it before sending cloud requests or media timestamps. + * The FreeRTOS default reads libc gettimeofday(). Replace it at startup + * if the platform needs another UTC source. + * Both callbacks are mandatory and must be safe for concurrent tasks. */ uint64_t (*time_ms)(void); + uint64_t (*wal_clock_time)(void); /* --- Memory ---------------------------------------------------------- * Standard malloc/free semantics. malloc must return NULL on failure. @@ -153,7 +164,8 @@ typedef struct pal { static inline bool pal_is_valid(const pal_t *p) { return p && p->tcp_connect && p->tcp_send && p->tcp_recv && p->tcp_close - && p->tcp_poll && p->time_ms && p->malloc && p->free + && p->tcp_poll && p->time_ms && p->wal_clock_time + && p->malloc && p->free && p->mutex_create && p->mutex_lock && p->mutex_unlock && p->mutex_destroy && p->thread_create && p->thread_join && p->sleep_ms; diff --git a/pal/pal_freertos.c b/pal/pal_freertos.c index 2b2ce21..9609f9b 100644 --- a/pal/pal_freertos.c +++ b/pal/pal_freertos.c @@ -40,14 +40,26 @@ * Defaults & docs: pal_config_defaults.h (overrides are picked up via * log.h; see that header's banner). * + * UTC time requires libc gettimeofday(), maintained by the application/platform + * through RTC/SNTP or another synchronization source. The SDK does not sync it. + * * Usage: * cfg.pal = tai_pal_freertos(); + * + * If the libc UTC source does not suit the platform, override the callback: + * static pal_t pal; + * pal = *tai_pal_freertos(); + * pal.wal_clock_time = app_utc_ms; + * cfg.pal = &pal; // keep this table alive and immutable */ #include #include +#include #include +#include #include +#include #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -267,18 +279,61 @@ static void pal_tcp_close(void *handle) /* ------------------------------------------------------------------------- * Time * ------------------------------------------------------------------------- */ +/* Task context after scheduler start, never ISR. vTaskSetTimeOutState captures + * ticks and their overflow count together, preserving multiple wraps without + * polling or a tick hook. This relies on the TimeOut_t layout and critical + * section in vTaskSetTimeOutState in FreeRTOS V10.4.3/V11.1.0, not a stable + * extended-clock API. See ADR 0003 and tools/check_freertos_pal.py; recheck + * vendor forks/upgrades. For 16/32-bit ticks, UBaseType_t must be at least 32 bits + * and the kernel overflow count must remain representable. Native 64-bit ticks + * use their own counter. A kernel restart establishes a new monotonic epoch. + * Tickless-idle sleep counts only as the port compensates the kernel ticks. + */ +typedef char pal_supported_tick_width[ + (sizeof(TickType_t) * CHAR_BIT == 16 || + sizeof(TickType_t) * CHAR_BIT == 32 || + sizeof(TickType_t) * CHAR_BIT == 64) ? 1 : -1]; +typedef char pal_supported_overflow_width[ + (sizeof(TickType_t) * CHAR_BIT == 64 || + sizeof(UBaseType_t) * CHAR_BIT >= 32) ? 1 : -1]; +typedef char pal_valid_tick_rate[ + (configTICK_RATE_HZ > 0 && + (uint64_t)configTICK_RATE_HZ <= UINT32_MAX) ? 1 : -1]; + static uint64_t pal_time_ms(void) { - /* NOT monotonic, despite pal.h's contract: the cast stops the MULTIPLY - * overflowing, it does not stop xTaskGetTickCount() itself wrapping -- - * back to 0 after 2^32 ticks (~49.7 days at a 1 ms tick), or every ~65 s - * if the port sets configUSE_16_BIT_TICKS 1 (so: don't). Deltas do not - * survive the wrap either; they are the worst case. now < start makes the - * unsigned subtraction ~1.8e19, so every connect deadline, keepalive and - * liveness check in the SDK trips at once and the client tears down and - * reconnects. It re-stamps its timestamps on the way back up, so this - * self-heals and leaves no trace but one unexplained reconnect. */ - return (uint64_t)xTaskGetTickCount() * (uint64_t)portTICK_PERIOD_MS; + TimeOut_t now; + const unsigned tick_bits = (unsigned)(sizeof(TickType_t) * CHAR_BIT); + vTaskSetTimeOutState(&now); + uint64_t ticks = (uint64_t)now.xTimeOnEntering; + /* Merge the kernel wrap count above the tick bits for 16/32-bit counters; + * native 64-bit ticks keep their full counter. Literal shift counts stay + * well-formed on every width: a variable count folds to 64 on the 64-bit + * target and trips -Werror=shift-count-overflow (GCC) even in dead code. */ + if (tick_bits == 16U) { + ticks |= (uint64_t)(UBaseType_t)now.xOverflowCount << 16; + } else if (tick_bits == 32U) { + ticks |= (uint64_t)(UBaseType_t)now.xOverflowCount << 32; + } + + /* Convert by frequency, including fractional-ms ticks. Quotient/remainder + * avoids overflowing ticks * 1000; saturate if milliseconds exceed uint64. */ + const uint64_t hz = (uint64_t)configTICK_RATE_HZ; + const uint64_t seconds = ticks / hz; + const uint64_t fraction = (ticks % hz) * 1000U / hz; + if (seconds > UINT64_MAX / 1000U) return UINT64_MAX; + const uint64_t whole = seconds * 1000U; + if (fraction > UINT64_MAX - whole) return UINT64_MAX; + return whole + fraction; +} + +static uint64_t pal_wal_clock_time(void) +{ + /* Common FreeRTOS/lwIP ports provide libc UTC via gettimeofday(). A port + * using another clock source can replace this callback in its PAL copy. */ + struct timeval tv; + if (gettimeofday(&tv, NULL) != 0 || tv.tv_sec <= 0) return 0; + return (uint64_t)tv.tv_sec * 1000ULL + (uint64_t)(tv.tv_usec / 1000); } static void pal_sleep_ms(uint32_t ms) @@ -398,6 +453,7 @@ static const pal_t g_freertos_pal = { .tcp_close = pal_tcp_close, .tcp_poll = pal_tcp_poll, .time_ms = pal_time_ms, + .wal_clock_time = pal_wal_clock_time, .malloc = pal_malloc, .free = pal_free, .mutex_create = pal_mutex_create, diff --git a/pal/pal_posix.c b/pal/pal_posix.c index fcc5bfa..babab6e 100644 --- a/pal/pal_posix.c +++ b/pal/pal_posix.c @@ -235,6 +235,13 @@ static void pal_tcp_close(void *handle) /* ------------------------------------------------------------------------- * Time * ------------------------------------------------------------------------- */ +static uint64_t pal_wal_clock_time(void) +{ + struct timespec ts; + if (clock_gettime(CLOCK_REALTIME, &ts) != 0 || ts.tv_sec <= 0) return 0; + return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)(ts.tv_nsec / 1000000); +} + static uint64_t pal_time_ms(void) { struct timespec ts; @@ -307,6 +314,7 @@ static const pal_t g_posix_pal = { .tcp_close = pal_tcp_close, .tcp_poll = pal_tcp_poll, .time_ms = pal_time_ms, + .wal_clock_time = pal_wal_clock_time, .malloc = pal_malloc, .free = pal_free, .mutex_create = pal_mutex_create, diff --git a/pal/test/freertos/FreeRTOS.h b/pal/test/freertos/FreeRTOS.h new file mode 100644 index 0000000..ce2a42c --- /dev/null +++ b/pal/test/freertos/FreeRTOS.h @@ -0,0 +1,28 @@ +/* Host-only FreeRTOS type model for the PAL tick-clock regression matrix. */ +#ifndef INC_FREERTOS_H +#define INC_FREERTOS_H + +#include +#include + +typedef int32_t BaseType_t; +typedef uint32_t UBaseType_t; + +#if TEST_TICK_BITS == 16 +typedef uint16_t TickType_t; +#elif TEST_TICK_BITS == 32 +typedef uint32_t TickType_t; +#elif TEST_TICK_BITS == 64 +typedef uint64_t TickType_t; +#else +#error "Unsupported test tick width" +#endif + +#define configTICK_RATE_HZ ((uint32_t)TEST_TICK_HZ) +#define portMAX_DELAY ((TickType_t)~(TickType_t)0) +#define pdPASS 1 + +static inline void *pvPortMalloc(size_t size) { return malloc(size); } +static inline void vPortFree(void *pointer) { free(pointer); } + +#endif diff --git a/pal/test/freertos/semphr.h b/pal/test/freertos/semphr.h new file mode 100644 index 0000000..ae402c2 --- /dev/null +++ b/pal/test/freertos/semphr.h @@ -0,0 +1,26 @@ +/* Host-only link shims. Clock tests do not exercise semaphore operations. */ +#ifndef PAL_TEST_SEMPHR_H +#define PAL_TEST_SEMPHR_H + +typedef void *SemaphoreHandle_t; +static inline SemaphoreHandle_t xSemaphoreCreateRecursiveMutex(void) { return NULL; } +static inline SemaphoreHandle_t xSemaphoreCreateBinary(void) { return NULL; } +static inline BaseType_t xSemaphoreTake(SemaphoreHandle_t handle, TickType_t ticks) +{ + (void)handle; (void)ticks; return 0; +} +static inline BaseType_t xSemaphoreTakeRecursive(SemaphoreHandle_t handle, TickType_t ticks) +{ + return xSemaphoreTake(handle, ticks); +} +static inline BaseType_t xSemaphoreGive(SemaphoreHandle_t handle) +{ + (void)handle; return 0; +} +static inline BaseType_t xSemaphoreGiveRecursive(SemaphoreHandle_t handle) +{ + return xSemaphoreGive(handle); +} +static inline void vSemaphoreDelete(SemaphoreHandle_t handle) { (void)handle; } + +#endif diff --git a/pal/test/freertos/task.h b/pal/test/freertos/task.h new file mode 100644 index 0000000..9207d2c --- /dev/null +++ b/pal/test/freertos/task.h @@ -0,0 +1,26 @@ +/* Host-only snapshot API; never placed on a production include path. */ +#ifndef INC_TASK_H +#define INC_TASK_H + +typedef struct xTIME_OUT { + BaseType_t xOverflowCount; + TickType_t xTimeOnEntering; +} TimeOut_t; + +void vTaskSetTimeOutState(TimeOut_t *state); + +/* Unused by clock tests; refuse task creation rather than start a host worker. */ +typedef void *TaskHandle_t; +#define tskIDLE_PRIORITY ((UBaseType_t)0) +static inline BaseType_t xTaskCreate(void (*task)(void *), const char *name, + uint32_t stack, void *arg, UBaseType_t priority, + TaskHandle_t *handle) +{ + (void)task; (void)name; (void)stack; (void)arg; (void)priority; (void)handle; + return 0; +} +static inline void vTaskDelay(TickType_t ticks) { (void)ticks; } +static inline void vTaskSuspend(TaskHandle_t task) { (void)task; } +static inline void vTaskDelete(TaskHandle_t task) { (void)task; } + +#endif diff --git a/pal/test/freertos_compat/FreeRTOSConfig.h b/pal/test/freertos_compat/FreeRTOSConfig.h new file mode 100644 index 0000000..e00c05a --- /dev/null +++ b/pal/test/freertos_compat/FreeRTOSConfig.h @@ -0,0 +1,31 @@ +/* Host configuration for syntax checks with official FreeRTOS headers only. */ +#define configUSE_PREEMPTION 1 +#define configUSE_IDLE_HOOK 0 +#define configUSE_TICK_HOOK 0 +#define configCPU_CLOCK_HZ 48000000UL +#define configTICK_RATE_HZ ((TickType_t)1000) +#define configMAX_PRIORITIES 5 +#define configMINIMAL_STACK_SIZE 128 +#define configTOTAL_HEAP_SIZE 4096 +#define configMAX_TASK_NAME_LEN 16 +#define configUSE_MUTEXES 1 +#define configUSE_RECURSIVE_MUTEXES 1 +#define configUSE_TIMERS 0 +#define INCLUDE_vTaskPrioritySet 1 +#define INCLUDE_uxTaskPriorityGet 1 +#define INCLUDE_vTaskDelete 1 +#define INCLUDE_vTaskSuspend 1 +#define INCLUDE_vTaskDelayUntil 1 +#define INCLUDE_vTaskDelay 1 + +#if TEST_KERNEL_MAJOR == 10 +#define configUSE_16_BIT_TICKS (TEST_TICK_BITS == 16) +#else +#if TEST_TICK_BITS == 16 +#define configTICK_TYPE_WIDTH_IN_BITS TICK_TYPE_WIDTH_16_BITS +#elif TEST_TICK_BITS == 32 +#define configTICK_TYPE_WIDTH_IN_BITS TICK_TYPE_WIDTH_32_BITS +#else +#define configTICK_TYPE_WIDTH_IN_BITS TICK_TYPE_WIDTH_64_BITS +#endif +#endif diff --git a/pal/test/freertos_compat/portmacro.h b/pal/test/freertos_compat/portmacro.h new file mode 100644 index 0000000..8d97286 --- /dev/null +++ b/pal/test/freertos_compat/portmacro.h @@ -0,0 +1,26 @@ +/* Type-only host port for header checks, never used to run a kernel. */ +#ifndef PAL_TEST_FREERTOS_COMPAT_PORTMACRO_H +#define PAL_TEST_FREERTOS_COMPAT_PORTMACRO_H + +#include +typedef uint32_t StackType_t; +typedef int32_t BaseType_t; +typedef uint32_t UBaseType_t; +#if TEST_TICK_BITS == 16 +typedef uint16_t TickType_t; +#define portMAX_DELAY UINT16_MAX +#elif TEST_TICK_BITS == 32 +typedef uint32_t TickType_t; +#define portMAX_DELAY UINT32_MAX +#else +typedef uint64_t TickType_t; +#define portMAX_DELAY UINT64_MAX +#endif +#define portSTACK_GROWTH (-1) +#define portBYTE_ALIGNMENT 8 +#define portTICK_PERIOD_MS ((TickType_t)1000 / configTICK_RATE_HZ) +#define portPRIVILEGE_BIT ((UBaseType_t)0) +#define portENTER_CRITICAL() ((void)0) +#define portEXIT_CRITICAL() ((void)0) + +#endif diff --git a/pal/test/lwip/netdb.h b/pal/test/lwip/netdb.h new file mode 100644 index 0000000..e5e647c --- /dev/null +++ b/pal/test/lwip/netdb.h @@ -0,0 +1,7 @@ +/* Host DNS declarations; clock tests never open sockets or resolve names. */ +#ifndef PAL_TEST_LWIP_NETDB_H +#define PAL_TEST_LWIP_NETDB_H + +#include + +#endif diff --git a/pal/test/lwip/sockets.h b/pal/test/lwip/sockets.h new file mode 100644 index 0000000..62ec89a --- /dev/null +++ b/pal/test/lwip/sockets.h @@ -0,0 +1,13 @@ +/* Host socket declarations for compiling the production FreeRTOS PAL. */ +#ifndef PAL_TEST_LWIP_SOCKETS_H +#define PAL_TEST_LWIP_SOCKETS_H + +#include +#include +#include +#include +#include +#include +#define lwip_fcntl fcntl + +#endif diff --git a/pal/test/test_freertos_time.c b/pal/test/test_freertos_time.c new file mode 100644 index 0000000..259587e --- /dev/null +++ b/pal/test/test_freertos_time.c @@ -0,0 +1,296 @@ +#include +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "pal.h" + +extern const pal_t *tai_pal_freertos(void); + +static uint64_t application_utc_ms; +static struct timeval platform_time; +static int platform_read_result; +static unsigned platform_read_calls; +static int platform_timezone_requested; + +/* The test build remaps the platform read in the actual default callback. */ +int pal_test_gettimeofday(struct timeval *tv, void *tz) +{ + platform_read_calls++; + if (tz != NULL) platform_timezone_requested = 1; + if (platform_read_result != 0) return platform_read_result; + *tv = platform_time; + return 0; +} + +static void set_platform_time(time_t seconds, suseconds_t microseconds) +{ + platform_time.tv_sec = seconds; + platform_time.tv_usec = microseconds; + platform_read_result = 0; +} + +static uint64_t application_wall_ms(void) +{ + return application_utc_ms; +} + +static pal_t application_pal(void) +{ + pal_t pal = *tai_pal_freertos(); + pal.wal_clock_time = application_wall_ms; + return pal; +} + +static TimeOut_t kernel_time; +static unsigned passed; +static unsigned failed; + +/* Model the coherent snapshot returned by the kernel's critical section. */ +void vTaskSetTimeOutState(TimeOut_t *state) +{ + *state = kernel_time; +} + +static void set_ticks(uint64_t ticks) +{ + kernel_time.xTimeOnEntering = (TickType_t)ticks; +#if TEST_TICK_BITS < 64 + kernel_time.xOverflowCount = (BaseType_t)(ticks >> TEST_TICK_BITS); +#else + kernel_time.xOverflowCount = 0; +#endif +} + +/* Host compilers support a wider reference product than the adapter uses. */ +static uint64_t expected_time(uint64_t ticks) +{ + __uint128_t value = (__uint128_t)ticks * 1000U / TEST_TICK_HZ; + return value > UINT64_MAX ? UINT64_MAX : (uint64_t)value; +} + +#define CHECK(expr) do { \ + if (!(expr)) { \ + printf("FAIL %s:%d: %s\n", __func__, __LINE__, #expr); \ + return 1; \ + } \ +} while (0) + +#define RUN_TEST(fn) do { \ + if ((fn)() == 0) { passed++; } else { failed++; } \ +} while (0) + +static uint64_t wrap_boundary(void) +{ +#if TEST_TICK_BITS < 64 + return UINT64_C(1) << TEST_TICK_BITS; +#else + /* A native 64-bit clock must also cross the old 32-bit boundary. */ + return UINT64_C(1) << 32; +#endif +} + +static int test_units_and_resolution(void) +{ + CHECK(pal_is_valid(tai_pal_freertos())); + set_ticks(1); + CHECK(tai_pal_freertos()->time_ms() == expected_time(1)); + set_ticks(TEST_TICK_HZ); + CHECK(tai_pal_freertos()->time_ms() == 1000); + return 0; +} + +static int test_repeated_reads(void) +{ + set_ticks(7); + uint64_t ms = tai_pal_freertos()->time_ms(); + CHECK(tai_pal_freertos()->time_ms() == ms); + return 0; +} + +static int test_tick_wrap(void) +{ + uint64_t boundary = wrap_boundary(); + set_ticks(boundary - 16); + uint64_t before = tai_pal_freertos()->time_ms(); + set_ticks(boundary + 32); + uint64_t after = tai_pal_freertos()->time_ms(); + CHECK(after > before); + CHECK(after - before == expected_time(boundary + 32) + - expected_time(boundary - 16)); + return 0; +} + +static int test_multiple_wraps_between_reads(void) +{ + uint64_t ticks = 5 * wrap_boundary() + 123; + set_ticks(123); + uint64_t before = tai_pal_freertos()->time_ms(); + set_ticks(ticks); + CHECK(tai_pal_freertos()->time_ms() == expected_time(ticks)); + CHECK(tai_pal_freertos()->time_ms() > before); + return 0; +} + +static int test_read_after_prior_wraps(void) +{ + /* Correct absolute time without any per-reader init or sampling history. */ + uint64_t ticks = 3 * wrap_boundary() + TEST_TICK_HZ; + set_ticks(ticks); + CHECK(tai_pal_freertos()->time_ms() == expected_time(ticks)); + return 0; +} + +static int test_connection_deadline_across_wrap(void) +{ + uint64_t boundary = wrap_boundary(); + set_ticks(boundary - 16); + uint64_t start = tai_pal_freertos()->time_ms(); + uint64_t deadline = start + 1000; + set_ticks(boundary + 16); + CHECK(tai_pal_freertos()->time_ms() < deadline); + CHECK(tai_pal_freertos()->time_ms() - start < 1000); + set_ticks(boundary + TEST_TICK_HZ + 16); + CHECK(tai_pal_freertos()->time_ms() >= deadline); + return 0; +} + +static int test_large_conversion(void) +{ +#if TEST_TICK_BITS < 64 + uint64_t ticks = ((uint64_t)INT32_MAX << TEST_TICK_BITS) + | (wrap_boundary() - 1); +#else + uint64_t ticks = UINT64_MAX; +#endif + set_ticks(ticks); + CHECK(tai_pal_freertos()->time_ms() == expected_time(ticks)); + return 0; +} + +static int test_default_utc_uses_ms(void) +{ + const pal_t *pal = tai_pal_freertos(); + CHECK(pal_is_valid(pal)); + platform_read_calls = 0; + platform_timezone_requested = 0; + set_platform_time(1700000000, 123456); + CHECK(pal->wal_clock_time() == UINT64_C(1700000000123)); + set_platform_time(1700000000, 999); + CHECK(pal->wal_clock_time() == UINT64_C(1700000000000)); + set_platform_time(1700000000, 999999); + CHECK(pal->wal_clock_time() == UINT64_C(1700000000999)); + set_platform_time(1700000001, 0); + CHECK(pal->wal_clock_time() == UINT64_C(1700000001000)); + CHECK(platform_read_calls == 4); + CHECK(platform_timezone_requested == 0); + return 0; +} + +static int test_default_utc_unavailable(void) +{ + const pal_t *pal = tai_pal_freertos(); + set_platform_time(0, 0); + CHECK(pal->wal_clock_time() == 0); + set_platform_time(0, 999999); + CHECK(pal->wal_clock_time() == 0); + set_platform_time(-1, 999999); + CHECK(pal->wal_clock_time() == 0); + return 0; +} + +static int test_default_utc_read_failure_and_recovery(void) +{ + const pal_t *pal = tai_pal_freertos(); + platform_read_calls = 0; + set_platform_time(1700000000, 123000); + CHECK(pal->wal_clock_time() == UINT64_C(1700000000123)); + platform_read_result = -1; + CHECK(pal->wal_clock_time() == 0); + set_platform_time(1700000001, 456000); + CHECK(pal->wal_clock_time() == UINT64_C(1700000001456)); + CHECK(platform_read_calls == 3); + return 0; +} + +static int test_default_utc_steps_preserve_monotonic(void) +{ + const pal_t *pal = tai_pal_freertos(); + set_ticks(3 * TEST_TICK_HZ); + uint64_t start = pal->time_ms(); + set_platform_time(1700000000, 123000); + CHECK(pal->wal_clock_time() == UINT64_C(1700000000123)); + set_platform_time(1800000000, 456000); + CHECK(pal->wal_clock_time() == UINT64_C(1800000000456)); + CHECK(pal->time_ms() == start); + set_platform_time(1600000000, 789000); + CHECK(pal->wal_clock_time() == UINT64_C(1600000000789)); + CHECK(pal->time_ms() == start); + set_ticks(4 * TEST_TICK_HZ); + CHECK(pal->time_ms() - start == 1000); + CHECK(pal->wal_clock_time() == UINT64_C(1600000000789)); + return 0; +} + +static int test_injected_utc_unavailable(void) +{ + pal_t pal = application_pal(); + application_utc_ms = 0; + platform_read_calls = 0; + platform_read_result = -1; + CHECK(pal_is_valid(&pal)); + CHECK(pal.wal_clock_time() == 0); + CHECK(platform_read_calls == 0); + return 0; +} + +static int test_injected_utc_uses_ms(void) +{ + pal_t pal = application_pal(); + set_ticks(TEST_TICK_HZ); + application_utc_ms = 1700000000123ULL; + platform_read_calls = 0; + platform_read_result = -1; + CHECK(pal.wal_clock_time() == 1700000000123ULL); + CHECK(pal.time_ms() == 1000); + CHECK(pal.wal_clock_time != tai_pal_freertos()->wal_clock_time); + CHECK(platform_read_calls == 0); + return 0; +} + +static int test_injected_utc_steps_preserve_monotonic(void) +{ + pal_t pal = application_pal(); + set_ticks(3 * TEST_TICK_HZ); + uint64_t start = pal.time_ms(); + application_utc_ms = 1800000000000ULL; + CHECK(pal.wal_clock_time() == application_utc_ms); + CHECK(pal.time_ms() == start); + application_utc_ms = 1600000000000ULL; + CHECK(pal.wal_clock_time() == application_utc_ms); + CHECK(pal.time_ms() == start); + set_ticks(4 * TEST_TICK_HZ); + CHECK(pal.time_ms() - start == 1000); + return 0; +} + +int main(void) +{ + RUN_TEST(test_units_and_resolution); + RUN_TEST(test_repeated_reads); + RUN_TEST(test_tick_wrap); + RUN_TEST(test_multiple_wraps_between_reads); + RUN_TEST(test_read_after_prior_wraps); + RUN_TEST(test_connection_deadline_across_wrap); + RUN_TEST(test_large_conversion); + RUN_TEST(test_default_utc_uses_ms); + RUN_TEST(test_default_utc_unavailable); + RUN_TEST(test_default_utc_read_failure_and_recovery); + RUN_TEST(test_default_utc_steps_preserve_monotonic); + RUN_TEST(test_injected_utc_unavailable); + RUN_TEST(test_injected_utc_uses_ms); + RUN_TEST(test_injected_utc_steps_preserve_monotonic); + printf("FreeRTOS clock (%d-bit ticks, %d Hz): %u passed, %u failed\n", + TEST_TICK_BITS, TEST_TICK_HZ, passed, failed); + return failed ? 1 : 0; +} diff --git a/pal/test/test_posix_time.c b/pal/test/test_posix_time.c new file mode 100644 index 0000000..78de481 --- /dev/null +++ b/pal/test/test_posix_time.c @@ -0,0 +1,120 @@ +#include +#include +#include +#include "pal.h" + +extern const pal_t *tai_pal_posix(void); + +static struct timespec utc; +static struct timespec monotonic; +static int utc_error; +static unsigned passed; +static unsigned failed; + +/* Renamed only in this target; the production PAL still calls clock_gettime. */ +int pal_test_clock_gettime(clockid_t clock, struct timespec *value) +{ + if (clock == CLOCK_REALTIME) { + if (utc_error) { + errno = EIO; + return -1; + } + *value = utc; + } else if (clock == CLOCK_MONOTONIC) { + *value = monotonic; + } else { + errno = EINVAL; + return -1; + } + return 0; +} + +#define CHECK(expr) do { \ + if (!(expr)) { \ + printf("FAIL %s:%d: %s\n", __func__, __LINE__, #expr); \ + return 1; \ + } \ +} while (0) + +static void run_test(int (*test)(void)) +{ + utc = (struct timespec){0, 0}; + monotonic = (struct timespec){3, 123456789}; + utc_error = 0; + if (test() == 0) passed++; + else failed++; +} + +static int test_table(void) +{ + const pal_t *pal = tai_pal_posix(); + CHECK(pal_is_valid(pal)); + pal_t incomplete = *pal; + incomplete.wal_clock_time = NULL; + CHECK(!pal_is_valid(&incomplete)); + incomplete = *pal; + incomplete.time_ms = NULL; + CHECK(!pal_is_valid(&incomplete)); + return 0; +} + +static int test_utc_units_and_clock_domain(void) +{ + const pal_t *pal = tai_pal_posix(); + utc = (struct timespec){1700000000, 123456789}; + CHECK(pal->wal_clock_time() == 1700000000123ULL); + CHECK(pal->time_ms() == 3123); + return 0; +} + +static int test_unavailable_utc(void) +{ + const pal_t *pal = tai_pal_posix(); + CHECK(pal->wal_clock_time() == 0); + /* Subsecond values in the unset Unix epoch do not make UTC available. */ + utc.tv_nsec = 999000000; + CHECK(pal->wal_clock_time() == 0); + utc.tv_sec = -1; + CHECK(pal->wal_clock_time() == 0); + CHECK(pal->time_ms() == 3123); + return 0; +} + +static int test_failed_utc_read_and_recovery(void) +{ + const pal_t *pal = tai_pal_posix(); + utc = (struct timespec){1700000000, 0}; + utc_error = 1; + CHECK(pal->wal_clock_time() == 0); + CHECK(pal->time_ms() == 3123); + utc_error = 0; + CHECK(pal->wal_clock_time() == 1700000000000ULL); + return 0; +} + +static int test_utc_corrections_preserve_elapsed_time(void) +{ + const pal_t *pal = tai_pal_posix(); + uint64_t start = pal->time_ms(); + utc = (struct timespec){1800000000, 0}; + CHECK(pal->wal_clock_time() == 1800000000000ULL); + CHECK(pal->time_ms() == start); + utc.tv_sec = 1600000000; + CHECK(pal->wal_clock_time() == 1600000000000ULL); + CHECK(pal->time_ms() == start); + /* Frozen UTC cannot freeze elapsed time or a deadline. */ + monotonic.tv_sec++; + CHECK(pal->time_ms() - start == 1000); + return 0; +} + +int main(void) +{ + run_test(test_table); + run_test(test_utc_units_and_clock_domain); + run_test(test_unavailable_utc); + run_test(test_failed_utc_read_and_recovery); + run_test(test_utc_corrections_preserve_elapsed_time); + printf("POSIX clock: %u passed, %u failed\n", passed, failed); + return failed ? 1 : 0; +} diff --git a/tools/check_freertos_pal.py b/tools/check_freertos_pal.py new file mode 100644 index 0000000..d725970 --- /dev/null +++ b/tools/check_freertos_pal.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Compile the PAL with pinned upstream FreeRTOS headers (no kernel execution).""" + +import argparse +import io +import os +from pathlib import Path +import shlex +import subprocess +import tarfile +import tempfile +import time +import urllib.error +import urllib.request + + +KERNELS = ( + ("V10.4.3", "9c048e0c71ee43630394981a86f5265bc57131e4", 10, (16, 32)), + ("V11.1.0", "dbf70559b27d39c1fdb68dfb9a32140b6a6777a0", 11, (16, 32, 64)), +) + +DEFAULT_CACHE = Path.home() / ".cache" / "agentic-kit" / "freertos-kernels" + + +def download_archive(commit, attempts=3): + """Fetch the pinned kernel tarball, retrying transient network failures.""" + url = f"https://codeload.github.com/FreeRTOS/FreeRTOS-Kernel/tar.gz/{commit}" + last_error = None + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(url, timeout=30) as response: + return response.read() + except (urllib.error.URLError, OSError) as exc: + last_error = exc + if attempt < attempts: + print(f"Download attempt {attempt}/{attempts} failed ({exc}); retrying", + flush=True) + time.sleep(3) + raise RuntimeError(f"Could not download {commit} after {attempts} attempts: {last_error}") + + +def extract_headers(archive_bytes, target): + target.mkdir(parents=True) + with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as archive: + for member in archive.getmembers(): + parts = Path(member.name).parts + # Copy only direct include/*.h files; never extract archive paths. + if (member.isfile() and len(parts) == 3 and parts[1] == "include" + and parts[2].endswith(".h")): + with archive.extractfile(member) as source: + (target / parts[2]).write_bytes(source.read()) + for header in ("FreeRTOS.h", "task.h", "semphr.h"): + if not (target / header).is_file(): + raise RuntimeError(f"Upstream archive is missing {header}") + + +def ensure_headers(version, commit, cache_dir): + """Materialize the pinned headers under cache_dir and return their directory. + + The cache is keyed by version+commit, so a hit can never serve another + revision. Each writer owns its staging directory and publishes it atomically; + a concurrent winner is reused without modifying its published headers.""" + cache_dir = Path(cache_dir).resolve() + headers = cache_dir / f"{version}-{commit}" + stamp = headers / ".pinned-commit" + + def cache_ready(): + return (stamp.is_file() and stamp.read_text().strip() == commit + and all((headers / name).is_file() + for name in ("FreeRTOS.h", "task.h", "semphr.h"))) + + if cache_ready(): + print(f"Using cached {version} headers at {commit}", flush=True) + return headers + if headers.exists(): + # A writer can finish between the readiness check and exists(). + if cache_ready(): + print(f"Using cached {version} headers at {commit}", flush=True) + return headers + raise RuntimeError(f"Incomplete header cache at {headers}; remove it and retry") + print(f"Downloading {version} headers at {commit}", flush=True) + cache_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix=f".staging-{version}-{commit}-", dir=cache_dir) as directory: + staging = Path(directory) / "include" + extract_headers(download_archive(commit), staging) + (staging / ".pinned-commit").write_text(commit + "\n") + try: + staging.rename(headers) + except OSError: + # Another process may have published after our initial cache check. + # Never remove its directory: its compiler may already be using it. + if not cache_ready(): + raise + return headers + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cc", default=os.environ.get("CC", "cc"), + help="C compiler command (default: CC or cc)") + parser.add_argument("--kernels-dir", type=Path, + help="Offline directory with pinned V10.4.3/V11.1.0 git checkouts") + parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE, + help=f"Download cache, reused across runs (default: {DEFAULT_CACHE})") + args = parser.parse_args() + compiler = shlex.split(args.cc) + if not compiler: + parser.error("--cc must name a compiler") + repo = Path(__file__).resolve().parent.parent + probe = repo / "pal/test/freertos_compat" + passed = 0 + with tempfile.TemporaryDirectory(prefix="agentic-kit-freertos-") as directory: + work = Path(directory) + for version, commit, major, widths in KERNELS: + if args.kernels_dir: + kernel = args.kernels_dir.resolve() / version + actual = subprocess.check_output( + ["git", "-C", str(kernel), "rev-parse", "HEAD"], text=True).strip() + if actual != commit: + raise RuntimeError(f"{version}: expected {commit}, got {actual}") + dirty = subprocess.check_output( + ["git", "-C", str(kernel), "status", "--porcelain", "--", "include"], + text=True).strip() + if dirty: + raise RuntimeError(f"{version}: local headers have changes") + headers = kernel / "include" + else: + headers = ensure_headers(version, commit, args.cache_dir) + wrapper = work / version / "wrapper" + wrapper.mkdir(parents=True) + (wrapper / "freertos").symlink_to(headers, target_is_directory=True) + for bits in widths: + command = compiler + [ + "-std=c99", "-Wall", "-Wextra", "-Werror", "-fsyntax-only", + f"-DTEST_KERNEL_MAJOR={major}", f"-DTEST_TICK_BITS={bits}", + "-DAGENTIC_KIT_LOG_LEVEL=0", + # 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. + # Defining _POSIX_C_SOURCE alone would hide BSD socket flags + # on darwin (MSG_DONTWAIT); _DARWIN_C_SOURCE restores them + # and is inert on glibc. + "-D_POSIX_C_SOURCE=200112L", "-D_DARWIN_C_SOURCE", + ] + command += [f"-I{path}" for path in ( + wrapper, headers, probe, repo / "pal/test", repo / "pal", repo / "common" + )] + command.append(str(repo / "pal/pal_freertos.c")) + subprocess.run(command, check=True) + passed += 1 + print(f"PASS: {version} ({commit}), {bits}-bit ticks", flush=True) + print(f"FreeRTOS C99 header compatibility: {passed}/5; no kernel runtime tested.") + + +if __name__ == "__main__": + main() diff --git a/tools/test_check_freertos_pal.py b/tools/test_check_freertos_pal.py new file mode 100644 index 0000000..faaf477 --- /dev/null +++ b/tools/test_check_freertos_pal.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Offline regression tests for the FreeRTOS header cache.""" + +from contextlib import contextmanager +import importlib.util +import io +import multiprocessing +import os +from pathlib import Path +import sys +import tarfile +import tempfile +import traceback +import unittest +from unittest import mock + + +sys.dont_write_bytecode = True + +SCRIPT = Path(__file__).with_name("check_freertos_pal.py") +VERSION = "V11.1.0" +COMMIT = "dbf70559b27d39c1fdb68dfb9a32140b6a6777a0" +HEADERS = {"FreeRTOS.h": b"/* kernel */\n", "task.h": b"/* tasks */\n", + "semphr.h": b"/* semaphores */\n"} + + +def load_checker(): + spec = importlib.util.spec_from_file_location("check_freertos_pal", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def header_archive(headers=HEADERS): + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w:gz") as archive: + for name, data in headers.items(): + entry = tarfile.TarInfo(f"FreeRTOS-Kernel-{COMMIT}/include/{name}") + entry.size = len(data) + archive.addfile(entry, io.BytesIO(data)) + return output.getvalue() + + +@contextmanager +def working_directory(path): + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def concurrent_writer(cache, archive, barrier, results): + """Force two real processes to finish extraction before either publishes.""" + checker = load_checker() + extract = checker.extract_headers + + def synchronized_extract(data, target): + extract(data, target) + barrier.wait(timeout=15) + + try: + with mock.patch.object(checker, "download_archive", return_value=archive), \ + mock.patch.object(checker, "extract_headers", + side_effect=synchronized_extract): + headers = checker.ensure_headers(VERSION, COMMIT, Path(cache)) + results.put(("ok", str(headers), (headers / "FreeRTOS.h").read_bytes())) + except Exception: + results.put(("error", traceback.format_exc())) + + +class HeaderCacheTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="freertos-cache-test-") + self.addCleanup(self.directory.cleanup) + self.work = Path(self.directory.name).resolve() + self.cache = self.work / "cache" + self.checker = load_checker() + + def populate(self): + with mock.patch.object(self.checker, "download_archive", + return_value=header_archive()): + return self.checker.ensure_headers(VERSION, COMMIT, self.cache) + + def assert_complete(self, headers): + self.assertEqual((headers / ".pinned-commit").read_text().strip(), COMMIT) + for name, contents in HEADERS.items(): + self.assertEqual((headers / name).read_bytes(), contents) + + def test_relative_cache_produces_valid_wrapper_symlink(self): + for cold in (True, False): + with self.subTest(cold_cache=cold), working_directory(self.work), \ + mock.patch.object(self.checker, "download_archive", + return_value=header_archive()) as download: + headers = self.checker.ensure_headers(VERSION, COMMIT, Path("cache")) + wrapper = self.work / f"wrapper-{cold}" / "freertos" + wrapper.parent.mkdir() + wrapper.symlink_to(headers, target_is_directory=True) + self.assert_complete(wrapper) + self.assertTrue(headers.is_absolute()) + self.assertEqual(download.call_count, int(cold)) + + def test_cache_hit_preserves_headers_without_downloading(self): + headers = self.populate() + before = {name: (headers / name).stat().st_ino for name in HEADERS} + with mock.patch.object(self.checker, "download_archive", + side_effect=AssertionError("cache hit used the network")): + self.assertEqual(self.checker.ensure_headers(VERSION, COMMIT, self.cache), + headers) + self.assert_complete(headers) + self.assertEqual(before, {name: (headers / name).stat().st_ino for name in HEADERS}) + + def test_concurrent_cold_writers_publish_one_complete_cache(self): + self.cache.mkdir() + context = multiprocessing.get_context("spawn") + barrier = context.Barrier(2) + results = context.Queue() + processes = [context.Process(target=concurrent_writer, + args=(str(self.cache), header_archive(), barrier, results)) + for _ in range(2)] + try: + for process in processes: + process.start() + outcomes = [results.get(timeout=25) for _ in processes] + for process in processes: + process.join(timeout=5) + self.assertEqual(process.exitcode, 0) + self.assertEqual([outcome[0] for outcome in outcomes], ["ok", "ok"], outcomes) + self.assertEqual(outcomes[0][1:], outcomes[1][1:]) + self.assert_complete(Path(outcomes[0][1])) + self.assertEqual(list(self.cache.iterdir()), [Path(outcomes[0][1])]) + finally: + for process in processes: + if process.is_alive(): + process.terminate() + if process.pid is not None: + process.join(timeout=5) + results.close() + results.join_thread() + + def test_competing_publish_between_cache_checks_is_reused(self): + self.cache.mkdir() + headers = self.cache / f"{VERSION}-{COMMIT}" + exists = Path.exists + + def publish_before_exists(path): + if path == headers: + self.checker.extract_headers(header_archive(), headers) + (headers / ".pinned-commit").write_text(COMMIT + "\n") + return exists(path) + + with mock.patch.object(Path, "exists", new=publish_before_exists), \ + mock.patch.object(self.checker, "download_archive", + side_effect=AssertionError("winner was not reused")): + self.assertEqual(self.checker.ensure_headers(VERSION, COMMIT, self.cache), + headers) + self.assert_complete(headers) + + def test_failed_extraction_is_cleaned_and_can_be_retried(self): + partial = header_archive({"FreeRTOS.h": HEADERS["FreeRTOS.h"]}) + with mock.patch.object(self.checker, "download_archive", return_value=partial): + with self.assertRaisesRegex(RuntimeError, "missing task.h"): + self.checker.ensure_headers(VERSION, COMMIT, self.cache) + self.assertEqual(list(self.cache.iterdir()), []) + self.assert_complete(self.populate()) + + def test_incomplete_published_cache_is_not_deleted(self): + headers = self.populate() + (headers / "task.h").unlink() + preserved = headers / "FreeRTOS.h" + inode = preserved.stat().st_ino + with mock.patch.object(self.checker, "download_archive", + side_effect=AssertionError("invalid cache used the network")): + with self.assertRaisesRegex(RuntimeError, "Incomplete header cache"): + self.checker.ensure_headers(VERSION, COMMIT, self.cache) + self.assertEqual(preserved.stat().st_ino, inode) + self.assertEqual(preserved.read_bytes(), HEADERS["FreeRTOS.h"]) + + +if __name__ == "__main__": + unittest.main()