From bd2944fc775cafa26fc97f387fc5a9487296399d Mon Sep 17 00:00:00 2001 From: Rodrigo Peixoto Date: Thu, 21 May 2026 11:22:54 -0300 Subject: [PATCH] =?UTF-8?q?feat(coap):=20envelope=20=E2=86=94=20CoAP=20pay?= =?UTF-8?q?load=20translation=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure transport-free decode/encode helpers + return_code → CoAP class mapping table for the CoAP frontend. No dependency on coap_service, sockets, or zbus — the helpers operate on raw buffers and pre- initialised struct coap_packet instances, so they are unit-testable in isolation. Codegen now emits per-method req_max_size / resp_max_size from nanopb's compile-time _SIZE constants, so the decoder enforces the exact per-method envelope (no global Kconfig cap) and the Phase 3 dispatcher can size per-type response scratch from a single codegen-derived ceiling. Test target tests/coap_translate/ (harness: ztest, mps2/an385 + native_sim) covers: the full mapping table (success ± payload, each mapped errno, default 5.00 fallback), decode happy/malformed/ oversize/empty paths, and encode for resp-present / resp-empty / zero-encoded / errno-verbatim cases. Refs #30 --- .../templates/zephlet_coap_interface.c.jinja | 2 + frontends/coap/CMakeLists.txt | 5 +- .../coap/include/zephlet_coap_translate.h | 126 ++++++ frontends/coap/include/zephlet_coap_types.h | 8 +- frontends/coap/zephlet_coap_translate.c | 162 +++++++ tests/coap_translate/CMakeLists.txt | 31 ++ tests/coap_translate/prj.conf | 28 ++ tests/coap_translate/src/main.c | 396 ++++++++++++++++++ tests/coap_translate/src/test_msg.proto | 18 + tests/coap_translate/testcase.yaml | 8 + 10 files changed, 782 insertions(+), 2 deletions(-) create mode 100644 frontends/coap/include/zephlet_coap_translate.h create mode 100644 frontends/coap/zephlet_coap_translate.c create mode 100644 tests/coap_translate/CMakeLists.txt create mode 100644 tests/coap_translate/prj.conf create mode 100644 tests/coap_translate/src/main.c create mode 100644 tests/coap_translate/src/test_msg.proto create mode 100644 tests/coap_translate/testcase.yaml diff --git a/codegen/templates/zephlet_coap_interface.c.jinja b/codegen/templates/zephlet_coap_interface.c.jinja index e0fbfa8..8d39090 100644 --- a/codegen/templates/zephlet_coap_interface.c.jinja +++ b/codegen/templates/zephlet_coap_interface.c.jinja @@ -29,6 +29,8 @@ static const struct zephlet_coap_method {{ type_snake }}_coap_methods[] = { .method_id = {{ cmd.method_id }}, .req_desc = {% if cmd.req_desc %}&{{ cmd.req_desc }}{% else %}NULL{% endif %}, .resp_desc = {% if cmd.resp_desc %}&{{ cmd.resp_desc }}{% else %}NULL{% endif %}, + .req_max_size = {% if cmd.req_is_empty %}0{% else %}{{ cmd.req_c_name|upper }}_SIZE{% endif %}, + .resp_max_size = {% if cmd.resp_is_empty %}0{% else %}{{ cmd.resp_c_name|upper }}_SIZE{% endif %}, }, {% endfor %} }; diff --git a/frontends/coap/CMakeLists.txt b/frontends/coap/CMakeLists.txt index 118ea52..09d3daa 100644 --- a/frontends/coap/CMakeLists.txt +++ b/frontends/coap/CMakeLists.txt @@ -1,5 +1,8 @@ if(CONFIG_ZEPHLETS_COAP) zephyr_library_named(zephlet_coap_frontend) - zephyr_library_sources(zephlet_coap_frontend.c) + zephyr_library_sources( + zephlet_coap_frontend.c + zephlet_coap_translate.c + ) zephyr_include_directories(include) endif() diff --git a/frontends/coap/include/zephlet_coap_translate.h b/frontends/coap/include/zephlet_coap_translate.h new file mode 100644 index 0000000..4c10197 --- /dev/null +++ b/frontends/coap/include/zephlet_coap_translate.h @@ -0,0 +1,126 @@ +#ifndef ZEPHLET_FRONTENDS_COAP_TRANSLATE_H +#define ZEPHLET_FRONTENDS_COAP_TRANSLATE_H + +#include +#include +#include + +#include + +#include "zephlet.h" +#include "zephlet_coap_types.h" + +/** + * @file + * @brief Pure envelope ↔ CoAP payload translation for the CoAP frontend. + * + * Transport-free helpers used by the Phase 3 catch-all dispatcher and the + * Phase 5 events bridge. None of these functions touch `coap_service`, + * sockets, or zbus — they operate on raw buffers and pre-initialised + * `struct coap_packet` instances, so they are unit-testable in isolation. + * + * Wire format: request and response bodies are nanopb-encoded protobuf + * messages described by the same `pb_msgdesc_t` descriptors that + * `_interface.c` references for local dispatch. Content-Format + * `ZEPHLET_COAP_CT_NANOPB` (65001) tags outbound payloads. + */ + +/** + * @brief Map a zephlet handler return code to a CoAP response code byte. + * + * The mapping table is the authoritative one pinned in the CoAP frontend + * adoption plan: + * + * rc == 0, has_payload → 2.05 Content + * rc == 0, no payload → 2.04 Changed + * rc == -EINVAL → 4.00 Bad Request + * rc == -ENODEV → 4.04 Not Found + * rc == -ENOSYS → 4.05 Method Not Allowed + * rc == -EALREADY → 4.09 Conflict + * rc == -EMSGSIZE → 4.13 Request Entity Too Large + * rc == -EBUSY/-EAGAIN → 5.03 Service Unavailable + * rc == -ETIMEDOUT → 5.04 Gateway Timeout + * rc == -ENOMEM → 5.00 Internal Server Error + * any other negative rc → 5.00 Internal Server Error (default) + * + * Positive rc values are not produced by zephlet handlers; they are + * treated the same as "any other". + * + * @param rc Handler return code (POSIX errno; 0 == success). + * @param has_payload Only consulted when @p rc == 0. True selects 2.05; + * false selects 2.04. + * + * @return CoAP response code byte (`COAP_MAKE_RESPONSE_CODE(class, detail)`). + */ +uint8_t zephlet_coap_map_return_code(int32_t rc, bool has_payload); + +/** + * @brief Decode a CoAP request body into a `struct zephlet_call`. + * + * On success the function fills @p call with `method_id`, `req_desc`, + * `resp_desc`, and `req = req_storage`. The caller is responsible for + * `resp` / `resp_desc` storage and for any prior CoAP-side validation + * (URI segments, Content-Format option). + * + * - `len > m->req_max_size` → `-EMSGSIZE`; dispatcher surfaces 4.13. + * The bound is the codegen-emitted `_SIZE` from nanopb, so the + * decoder enforces the exact per-method envelope (Empty requests + * have `req_max_size == 0`, so any inbound body is rejected here). + * - nanopb decode failure (truncated, malformed varints, type + * mismatch) → `-EINVAL`; dispatcher surfaces 4.00. + * + * @param buf CoAP payload bytes (may be NULL if @p len == 0). + * @param len Number of payload bytes. + * @param m Method descriptor (matched + * `zephlet_coap_method`). Must be non-NULL. + * @param call Envelope to populate. Must be non-NULL. + * @param req_storage Caller-owned request struct storage (e.g. stack- + * allocated `struct Req`). Must be non-NULL + * when `m->req_desc != NULL`. + * + * @return 0 on success; + * `-EINVAL` on malformed body or NULL-argument violation; + * `-EMSGSIZE` on oversized body. + */ +int zephlet_coap_decode_request(const uint8_t *buf, size_t len, const struct zephlet_coap_method *m, + struct zephlet_call *call, void *req_storage); + +/** + * @brief Encode a post-dispatch envelope into a CoAP response packet. + * + * @p cpkt must already have been initialised by the caller via + * `coap_packet_init` with the appropriate version, type, token, and + * message ID (matching the request). This helper: + * + * 1. Sets the response code from `call->return_code` via + * `zephlet_coap_map_return_code`. + * 2. Always appends the verbatim-errno option + * (`ZEPHLET_COAP_OPT_ERRNO = 65052`) carrying `call->return_code` + * reinterpreted as `uint32_t`. The receiver re-casts to `int32_t`. + * 3. When `call->return_code == 0` and `call->resp_desc != NULL`, + * encodes the response payload through @p scratch using nanopb. If + * the encoded size is greater than zero, the response code is + * upgraded to 2.05 Content, a Content-Format option (65001) is + * appended, the payload marker is written, and the encoded bytes + * are appended. + * + * @p scratch is required only when the caller can produce a non-empty + * payload (success + non-NULL `resp_desc`). Size it from nanopb's + * compile-time `_size` macros for the relevant zephlet type's + * largest opted-in response message. + * + * @param call Envelope to serialise (must be non-NULL). + * @param cpkt Pre-initialised CoAP packet (must be non-NULL). + * @param scratch Encoder scratch buffer (may be NULL when no + * payload is expected). + * @param scratch_size Size of @p scratch in bytes. + * + * @return 0 on success; + * `-ENOMEM` if either @p scratch is insufficient for the encoded + * response or the CoAP packet's underlying buffer cannot fit the + * options/payload. + */ +int zephlet_coap_encode_response(const struct zephlet_call *call, struct coap_packet *cpkt, + uint8_t *scratch, size_t scratch_size); + +#endif /* ZEPHLET_FRONTENDS_COAP_TRANSLATE_H */ diff --git a/frontends/coap/include/zephlet_coap_types.h b/frontends/coap/include/zephlet_coap_types.h index 88ff490..35e204d 100644 --- a/frontends/coap/include/zephlet_coap_types.h +++ b/frontends/coap/include/zephlet_coap_types.h @@ -28,13 +28,19 @@ * in the .proto service block). `method_id` indexes into * `zephlet_api.methods` for the dispatch trampoline. `req_desc` / * `resp_desc` are the nanopb descriptors used by the Phase 2 envelope - * decode/encode. + * decode/encode. `req_max_size` / `resp_max_size` are the nanopb- + * computed maximum encoded sizes (from `_SIZE` in `.pb.h`) + * — zero for `Empty` messages. Phase 2 decoder rejects request bodies + * larger than `req_max_size` with `-EMSGSIZE`; Phase 3 dispatcher uses + * `resp_max_size` to size per-type response scratch. */ struct zephlet_coap_method { const char *path_segment; uint16_t method_id; const pb_msgdesc_t *req_desc; const pb_msgdesc_t *resp_desc; + size_t req_max_size; + size_t resp_max_size; }; /** diff --git a/frontends/coap/zephlet_coap_translate.c b/frontends/coap/zephlet_coap_translate.c new file mode 100644 index 0000000..37fee19 --- /dev/null +++ b/frontends/coap/zephlet_coap_translate.c @@ -0,0 +1,162 @@ +#include "zephlet_coap_translate.h" + +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include "zephlet.h" +#include "zephlet_coap_consts.h" +#include "zephlet_coap_types.h" + +/** + * @file + * @brief Pure CoAP ↔ zephlet envelope translation. + * + * Implementation notes: + * - Compiled only when `CONFIG_ZEPHLETS_COAP=y` (CMake gate). + * - No dependency on `coap_service`, sockets, or zbus. All inputs are + * buffers or pre-initialised `struct coap_packet` instances; all + * outputs are byte-level CoAP options and payloads. + */ + +uint8_t zephlet_coap_map_return_code(int32_t rc, bool has_payload) +{ + if (rc == 0) { + if (has_payload) { + return COAP_RESPONSE_CODE_CONTENT; + } + return COAP_RESPONSE_CODE_CHANGED; + } + + switch (rc) { + case -EINVAL: + return COAP_RESPONSE_CODE_BAD_REQUEST; + case -ENODEV: + return COAP_RESPONSE_CODE_NOT_FOUND; + case -ENOSYS: + return COAP_RESPONSE_CODE_NOT_ALLOWED; + case -EALREADY: + return COAP_RESPONSE_CODE_CONFLICT; + case -EMSGSIZE: + return COAP_RESPONSE_CODE_REQUEST_TOO_LARGE; + case -EBUSY: + case -EAGAIN: + return COAP_RESPONSE_CODE_SERVICE_UNAVAILABLE; + case -ETIMEDOUT: + return COAP_RESPONSE_CODE_GATEWAY_TIMEOUT; + case -ENOMEM: + return COAP_RESPONSE_CODE_INTERNAL_ERROR; + default: + return COAP_RESPONSE_CODE_INTERNAL_ERROR; + } +} + +int zephlet_coap_decode_request(const uint8_t *buf, size_t len, const struct zephlet_coap_method *m, + struct zephlet_call *call, void *req_storage) +{ + if (m == NULL || call == NULL) { + return -EINVAL; + } + + /* Per-method bound emitted by codegen from the proto's nanopb + * `_SIZE` constant. A request body larger than the method's + * own message envelope is a malformed wire frame; surface 4.13. */ + if (len > m->req_max_size) { + return -EMSGSIZE; + } + + call->method_id = m->method_id; + call->req_desc = m->req_desc; + call->resp_desc = m->resp_desc; + call->req = NULL; + call->return_code = 0; + /* resp / resp_desc storage is the caller's responsibility (Phase 3 + * dispatcher allocates the response struct on its stack). */ + + if (m->req_desc == NULL) { + /* Empty request: req_max_size is also 0 so the bound check + * above already rejected any inbound bytes. */ + return 0; + } + + if (req_storage == NULL) { + return -EINVAL; + } + + pb_istream_t stream = pb_istream_from_buffer(buf, len); + if (!pb_decode(&stream, m->req_desc, req_storage)) { + return -EINVAL; + } + + call->req = req_storage; + return 0; +} + +int zephlet_coap_encode_response(const struct zephlet_call *call, struct coap_packet *cpkt, + uint8_t *scratch, size_t scratch_size) +{ + if (call == NULL || cpkt == NULL) { + return -EINVAL; + } + + size_t payload_len = 0; + bool has_payload = false; + + /* On success with a response descriptor, attempt to encode the + * payload into @p scratch up front so we know whether to mark the + * code as 2.04 or 2.05. */ + if (call->return_code == 0 && call->resp_desc != NULL && call->resp != NULL) { + if (scratch == NULL || scratch_size == 0) { + return -ENOMEM; + } + pb_ostream_t os = pb_ostream_from_buffer(scratch, scratch_size); + if (!pb_encode(&os, call->resp_desc, call->resp)) { + return -ENOMEM; + } + payload_len = os.bytes_written; + has_payload = (payload_len > 0); + } + + int err = coap_header_set_code( + cpkt, zephlet_coap_map_return_code(call->return_code, has_payload)); + if (err < 0) { + return -ENOMEM; + } + + /* Verbatim raw errno is carried on every response so the (lossy) + * class mapping does not erase the precise handler return code. The + * signed value is reinterpreted as unsigned for the wire; receivers + * cast back to int32_t. */ + err = coap_append_option_int(cpkt, ZEPHLET_COAP_OPT_ERRNO, (uint32_t)call->return_code); + if (err < 0) { + return -ENOMEM; + } + + if (!has_payload) { + return 0; + } + + err = coap_append_option_int(cpkt, COAP_OPTION_CONTENT_FORMAT, ZEPHLET_COAP_CT_NANOPB); + if (err < 0) { + return -ENOMEM; + } + + err = coap_packet_append_payload_marker(cpkt); + if (err < 0) { + return -ENOMEM; + } + + err = coap_packet_append_payload(cpkt, scratch, (uint16_t)payload_len); + if (err < 0) { + return -ENOMEM; + } + + return 0; +} diff --git a/tests/coap_translate/CMakeLists.txt b/tests/coap_translate/CMakeLists.txt new file mode 100644 index 0000000..2806af1 --- /dev/null +++ b/tests/coap_translate/CMakeLists.txt @@ -0,0 +1,31 @@ +cmake_minimum_required(VERSION 3.20.0) + +# Walk up to the infra root (modules/lib/zephlet/) from this test dir. +get_filename_component(ZEPHLET_INFRA_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) + +# Register the infra module so zephlet.h's include dir is exported and +# zephlet.c gets pulled in by CONFIG_ZEPHLETS=y. +list(APPEND EXTRA_ZEPHYR_MODULES "${ZEPHLET_INFRA_ROOT}") + +find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) + +project(zephlet_coap_translate_test) + +# Compile the translate translation unit directly so the unit test does +# not have to enable CONFIG_ZEPHLETS_COAP (which selects NETWORKING / +# NET_UDP / NET_SOCKETS / COAP_SERVER). Packet-level CoAP helpers come +# from CONFIG_COAP=y alone. +target_sources(app PRIVATE + src/main.c + ${ZEPHLET_INFRA_ROOT}/frontends/coap/zephlet_coap_translate.c +) +target_include_directories(app PRIVATE + ${ZEPHLET_INFRA_ROOT}/frontends/coap/include +) + +# Wire nanopb for the local test proto. +list(APPEND CMAKE_MODULE_PATH ${ZEPHYR_BASE}/modules/nanopb) +include(nanopb) +set(NANOPB_OPTIONS "--c-style") + +zephyr_nanopb_sources(app src/test_msg.proto) diff --git a/tests/coap_translate/prj.conf b/tests/coap_translate/prj.conf new file mode 100644 index 0000000..fcf00f3 --- /dev/null +++ b/tests/coap_translate/prj.conf @@ -0,0 +1,28 @@ +CONFIG_ZTEST=y + +# Zephlet shared headers (zephlet.h) + ZBUS/nanopb include paths. +CONFIG_ZEPHLETS=y + +CONFIG_NANOPB=y + +# `CONFIG_COAP` is gated by `NETWORKING` in Zephyr's Kconfig tree, so the +# networking subsystem entry must be enabled even though this unit test +# never touches a socket. Packet-level helpers (`coap_packet_init`, +# `coap_header_set_code`, `coap_append_option_int`, …) are all the test +# uses — no COAP_SERVER, no UDP, no L2 stack. +CONFIG_NETWORKING=y +CONFIG_COAP=y + +# QEMU board defaults enable a SLIP serial back-end against +# /tmp/slip.sock when NETWORKING+NET_QEMU_SLIP+NET_SLIP_TAP all hold. +# This unit test has no networking peer, so disable those — otherwise +# the runner aborts before main() with "Failed to connect" on macOS. +CONFIG_NET_QEMU_SLIP=n +CONFIG_NET_SLIP_TAP=n + +# Pulled-in net_if / coap code references sys_rand_get; the test PRNG +# satisfies the symbol without requiring an HW entropy backend. +CONFIG_TEST_RANDOM_GENERATOR=y + +CONFIG_LOG=y +CONFIG_ASSERT=y diff --git a/tests/coap_translate/src/main.c b/tests/coap_translate/src/main.c new file mode 100644 index 0000000..0695040 --- /dev/null +++ b/tests/coap_translate/src/main.c @@ -0,0 +1,396 @@ +#include +#include + +#include +#include +#include + +#include +#include + +#include "zephlet.h" +#include "zephlet_coap_consts.h" +#include "zephlet_coap_translate.h" +#include "zephlet_coap_types.h" + +/* zephyr_nanopb_sources emits the generated header relative to the + * proto's path under CMAKE_CURRENT_BINARY_DIR — for `src/test_msg.proto` + * that means `/src/test_msg.pb.h`. The include path is the + * binary-dir root, hence the `src/` prefix here. */ +#include "src/test_msg.pb.h" + +/** + * @file + * @brief Phase 2 unit tests for zephlet_coap_translate. + * + * Exercises the pure decode/encode helpers and the return_code → CoAP + * class mapping table with hand-built buffers. No coap_service, no + * zbus, no sockets — the translator API is the entire surface under + * test. + */ + +/* ----- Method-descriptor fixtures -------------------------------------- */ + +/* Methods with TestReq → TestResp; matches what the Phase 3 dispatcher + * would see for a service method that takes and returns a payload. */ +static const struct zephlet_coap_method method_req_resp = { + .path_segment = "echo", + .method_id = 1, + .req_desc = &test_req_t_msg, + .resp_desc = &test_resp_t_msg, + .req_max_size = TEST_REQ_SIZE, + .resp_max_size = TEST_RESP_SIZE, +}; + +/* Method with Empty request (req_desc == NULL, req_max_size == 0). */ +static const struct zephlet_coap_method method_empty_req = { + .path_segment = "ping", + .method_id = 2, + .req_desc = NULL, + .resp_desc = &test_resp_t_msg, + .req_max_size = 0, + .resp_max_size = TEST_RESP_SIZE, +}; + +/* ----- Helpers --------------------------------------------------------- */ + +/** + * @brief Initialise a CoAP packet over a stack buffer and prepare it for + * response encoding. Mirrors what coap_service would hand the dispatcher + * after parsing the inbound request. + */ +static void init_response_packet(struct coap_packet *cpkt, uint8_t *buf, size_t buf_size) +{ + int err = coap_packet_init(cpkt, buf, (uint16_t)buf_size, + /* ver = */ 1, + /* type = */ COAP_TYPE_ACK, + /* tkl = */ 0, + /* tok = */ NULL, + /* code = */ 0, /* overwritten by translate */ + /* mid = */ 0x1234); + zassert_equal(err, 0, "coap_packet_init err=%d", err); +} + +/** + * @brief Locate the first CoAP option matching @p code in @p cpkt and + * decode its value as an unsigned integer. Returns true on hit. + */ +static bool find_option_int(const struct coap_packet *cpkt, uint16_t code, uint32_t *value_out) +{ + struct coap_option opt = {0}; + int n = coap_find_options(cpkt, code, &opt, 1); + + if (n <= 0) { + return false; + } + *value_out = (uint32_t)coap_option_value_to_int(&opt); + return true; +} + +/* ====================================================================== */ +/* Mapping table */ +/* ====================================================================== */ + +ZTEST(zephlet_coap_translate, test_map_success_with_payload) +{ + zassert_equal(zephlet_coap_map_return_code(0, true), COAP_RESPONSE_CODE_CONTENT, + "expected 2.05"); +} + +ZTEST(zephlet_coap_translate, test_map_success_no_payload) +{ + zassert_equal(zephlet_coap_map_return_code(0, false), COAP_RESPONSE_CODE_CHANGED, + "expected 2.04"); +} + +ZTEST(zephlet_coap_translate, test_map_einval) +{ + zassert_equal(zephlet_coap_map_return_code(-EINVAL, false), COAP_RESPONSE_CODE_BAD_REQUEST, + "expected 4.00"); +} + +ZTEST(zephlet_coap_translate, test_map_enodev) +{ + zassert_equal(zephlet_coap_map_return_code(-ENODEV, false), COAP_RESPONSE_CODE_NOT_FOUND, + "expected 4.04"); +} + +ZTEST(zephlet_coap_translate, test_map_enosys) +{ + zassert_equal(zephlet_coap_map_return_code(-ENOSYS, false), COAP_RESPONSE_CODE_NOT_ALLOWED, + "expected 4.05"); +} + +ZTEST(zephlet_coap_translate, test_map_ealready) +{ + zassert_equal(zephlet_coap_map_return_code(-EALREADY, false), COAP_RESPONSE_CODE_CONFLICT, + "expected 4.09"); +} + +ZTEST(zephlet_coap_translate, test_map_emsgsize) +{ + zassert_equal(zephlet_coap_map_return_code(-EMSGSIZE, false), + COAP_RESPONSE_CODE_REQUEST_TOO_LARGE, "expected 4.13"); +} + +ZTEST(zephlet_coap_translate, test_map_ebusy) +{ + zassert_equal(zephlet_coap_map_return_code(-EBUSY, false), + COAP_RESPONSE_CODE_SERVICE_UNAVAILABLE, "expected 5.03"); +} + +ZTEST(zephlet_coap_translate, test_map_eagain) +{ + zassert_equal(zephlet_coap_map_return_code(-EAGAIN, false), + COAP_RESPONSE_CODE_SERVICE_UNAVAILABLE, "expected 5.03"); +} + +ZTEST(zephlet_coap_translate, test_map_etimedout) +{ + zassert_equal(zephlet_coap_map_return_code(-ETIMEDOUT, false), + COAP_RESPONSE_CODE_GATEWAY_TIMEOUT, "expected 5.04"); +} + +ZTEST(zephlet_coap_translate, test_map_enomem) +{ + zassert_equal(zephlet_coap_map_return_code(-ENOMEM, false), + COAP_RESPONSE_CODE_INTERNAL_ERROR, "expected 5.00"); +} + +ZTEST(zephlet_coap_translate, test_map_unknown_neg) +{ + /* Any unmapped negative errno collapses to 5.00 — preserves the + * "lossy class mapping" property; the precise errno survives via + * the verbatim-errno option attached by encode_response. */ + zassert_equal(zephlet_coap_map_return_code(-EIO, false), COAP_RESPONSE_CODE_INTERNAL_ERROR, + "expected 5.00 default"); +} + +/* ====================================================================== */ +/* decode_request */ +/* ====================================================================== */ + +/** + * @brief Hand-encode a TestReq via nanopb so the decoder receives a + * realistic wire image (rather than a hard-coded byte sequence the test + * would have to keep in sync with the proto). + */ +static size_t encode_test_req(uint8_t *buf, size_t buf_size, uint32_t v) +{ + test_req_t msg = {.v = v}; + pb_ostream_t s = pb_ostream_from_buffer(buf, buf_size); + + zassert_true(pb_encode(&s, &test_req_t_msg, &msg), "pb_encode for test req failed"); + return s.bytes_written; +} + +ZTEST(zephlet_coap_translate, test_decode_ok) +{ + uint8_t buf[TEST_REQ_SIZE]; + size_t len = encode_test_req(buf, sizeof(buf), 42); + + struct zephlet_call call = {0}; + test_req_t req = {0}; + + int err = zephlet_coap_decode_request(buf, len, &method_req_resp, &call, &req); + zassert_equal(err, 0, "decode err=%d", err); + zassert_equal(call.method_id, method_req_resp.method_id, "method_id mismatch"); + zassert_equal_ptr(call.req_desc, &test_req_t_msg, "req_desc"); + zassert_equal_ptr(call.resp_desc, &test_resp_t_msg, "resp_desc"); + zassert_equal_ptr(call.req, &req, "req storage"); + zassert_equal(req.v, 42, "decoded v=%u", req.v); +} + +ZTEST(zephlet_coap_translate, test_decode_malformed) +{ + /* 0xFF is reserved-for-future-use in proto wire format and is + * rejected at the field-tag varint pass. */ + const uint8_t bogus[] = {0xFF, 0xFF, 0xFF}; + struct zephlet_call call = {0}; + test_req_t req = {0}; + + int err = zephlet_coap_decode_request(bogus, sizeof(bogus), &method_req_resp, &call, &req); + zassert_equal(err, -EINVAL, "expected -EINVAL got %d", err); +} + +ZTEST(zephlet_coap_translate, test_decode_oversize) +{ + uint8_t buf[TEST_REQ_SIZE + 4] = {0}; + struct zephlet_call call = {0}; + test_req_t req = {0}; + + int err = zephlet_coap_decode_request(buf, sizeof(buf), &method_req_resp, &call, &req); + zassert_equal(err, -EMSGSIZE, "expected -EMSGSIZE got %d", err); +} + +ZTEST(zephlet_coap_translate, test_decode_empty_req_no_body) +{ + struct zephlet_call call = {0}; + + int err = zephlet_coap_decode_request(NULL, 0, &method_empty_req, &call, NULL); + zassert_equal(err, 0, "decode empty err=%d", err); + zassert_equal(call.method_id, method_empty_req.method_id, "method_id"); + zassert_is_null(call.req_desc, "req_desc must be NULL for Empty"); + zassert_is_null(call.req, "req must be NULL for Empty"); +} + +ZTEST(zephlet_coap_translate, test_decode_empty_req_with_body) +{ + const uint8_t one_byte[1] = {0x08}; + struct zephlet_call call = {0}; + + int err = zephlet_coap_decode_request(one_byte, sizeof(one_byte), &method_empty_req, &call, + NULL); + zassert_equal(err, -EMSGSIZE, "expected -EMSGSIZE for body on Empty req, got %d", err); +} + +/* ====================================================================== */ +/* encode_response */ +/* ====================================================================== */ + +ZTEST(zephlet_coap_translate, test_encode_success_with_resp) +{ + uint8_t pkt_buf[128]; + uint8_t scratch[TEST_RESP_SIZE]; + test_resp_t resp = {.v = 99}; + + struct zephlet_call call = { + .method_id = method_req_resp.method_id, + .return_code = 0, + .resp_desc = &test_resp_t_msg, + .resp = &resp, + }; + struct coap_packet cpkt; + init_response_packet(&cpkt, pkt_buf, sizeof(pkt_buf)); + + int err = zephlet_coap_encode_response(&call, &cpkt, scratch, sizeof(scratch)); + zassert_equal(err, 0, "encode err=%d", err); + + zassert_equal(coap_header_get_code(&cpkt), COAP_RESPONSE_CODE_CONTENT, "expected 2.05"); + + uint32_t errno_opt = 0xDEADBEEFu; + zassert_true(find_option_int(&cpkt, ZEPHLET_COAP_OPT_ERRNO, &errno_opt), + "errno option missing"); + zassert_equal((int32_t)errno_opt, 0, "errno option expected 0, got %d", (int32_t)errno_opt); + + uint32_t ct = 0; + zassert_true(find_option_int(&cpkt, COAP_OPTION_CONTENT_FORMAT, &ct), "CT option missing"); + zassert_equal(ct, ZEPHLET_COAP_CT_NANOPB, "CT=%u", ct); + + uint16_t payload_len = 0; + const uint8_t *payload = coap_packet_get_payload(&cpkt, &payload_len); + zassert_not_null(payload, "payload pointer null"); + zassert_true(payload_len > 0, "payload empty"); + + test_resp_t decoded = {0}; + pb_istream_t istream = pb_istream_from_buffer(payload, payload_len); + zassert_true(pb_decode(&istream, &test_resp_t_msg, &decoded), "round-trip decode failed"); + zassert_equal(decoded.v, 99, "round-trip v=%u", decoded.v); +} + +ZTEST(zephlet_coap_translate, test_encode_success_empty_resp) +{ + uint8_t pkt_buf[64]; + struct zephlet_call call = { + .method_id = method_empty_req.method_id, + .return_code = 0, + .resp_desc = NULL, + .resp = NULL, + }; + struct coap_packet cpkt; + init_response_packet(&cpkt, pkt_buf, sizeof(pkt_buf)); + + int err = zephlet_coap_encode_response(&call, &cpkt, NULL, 0); + zassert_equal(err, 0, "encode err=%d", err); + + zassert_equal(coap_header_get_code(&cpkt), COAP_RESPONSE_CODE_CHANGED, "expected 2.04"); + + uint32_t errno_opt = 0xDEADBEEFu; + zassert_true(find_option_int(&cpkt, ZEPHLET_COAP_OPT_ERRNO, &errno_opt), + "errno option missing"); + zassert_equal((int32_t)errno_opt, 0, "errno option expected 0"); + + uint32_t ct = 0; + zassert_false(find_option_int(&cpkt, COAP_OPTION_CONTENT_FORMAT, &ct), + "CT option present on empty response"); + + uint16_t payload_len = 0xFFFF; + (void)coap_packet_get_payload(&cpkt, &payload_len); + zassert_equal(payload_len, 0, "expected no payload, got %u bytes", payload_len); +} + +ZTEST(zephlet_coap_translate, test_encode_resp_default_value_no_payload) +{ + /* TestResp with v == 0: proto3 default-value omission produces a + * zero-byte nanopb encoding. The encoder must downgrade the code + * from 2.05 to 2.04 even though resp_desc is set. */ + uint8_t pkt_buf[64]; + uint8_t scratch[TEST_RESP_SIZE]; + test_resp_t resp = {.v = 0}; + + struct zephlet_call call = { + .method_id = method_req_resp.method_id, + .return_code = 0, + .resp_desc = &test_resp_t_msg, + .resp = &resp, + }; + struct coap_packet cpkt; + init_response_packet(&cpkt, pkt_buf, sizeof(pkt_buf)); + + int err = zephlet_coap_encode_response(&call, &cpkt, scratch, sizeof(scratch)); + zassert_equal(err, 0, "encode err=%d", err); + zassert_equal(coap_header_get_code(&cpkt), COAP_RESPONSE_CODE_CHANGED, + "expected 2.04 for empty-encoded resp"); + + uint32_t ct = 0; + zassert_false(find_option_int(&cpkt, COAP_OPTION_CONTENT_FORMAT, &ct), + "CT option present when payload empty"); +} + +ZTEST(zephlet_coap_translate, test_encode_errno_verbatim) +{ + uint8_t pkt_buf[64]; + struct zephlet_call call = { + .method_id = method_req_resp.method_id, + .return_code = -EINVAL, + .resp_desc = NULL, + .resp = NULL, + }; + struct coap_packet cpkt; + init_response_packet(&cpkt, pkt_buf, sizeof(pkt_buf)); + + int err = zephlet_coap_encode_response(&call, &cpkt, NULL, 0); + zassert_equal(err, 0, "encode err=%d", err); + zassert_equal(coap_header_get_code(&cpkt), COAP_RESPONSE_CODE_BAD_REQUEST, + "expected 4.00 for -EINVAL"); + + uint32_t raw = 0; + zassert_true(find_option_int(&cpkt, ZEPHLET_COAP_OPT_ERRNO, &raw), "errno option missing"); + zassert_equal((int32_t)raw, -EINVAL, "errno verbatim mismatch: got %d expected %d", + (int32_t)raw, -EINVAL); +} + +ZTEST(zephlet_coap_translate, test_encode_errno_verbatim_emsgsize) +{ + /* The 4.13 path: oversize body would have produced -EMSGSIZE on + * the dispatcher side; the response must carry both the mapped + * class byte and the verbatim raw errno. */ + uint8_t pkt_buf[64]; + struct zephlet_call call = { + .method_id = method_req_resp.method_id, + .return_code = -EMSGSIZE, + }; + struct coap_packet cpkt; + init_response_packet(&cpkt, pkt_buf, sizeof(pkt_buf)); + + int err = zephlet_coap_encode_response(&call, &cpkt, NULL, 0); + zassert_equal(err, 0, "encode err=%d", err); + zassert_equal(coap_header_get_code(&cpkt), COAP_RESPONSE_CODE_REQUEST_TOO_LARGE, + "expected 4.13 for -EMSGSIZE"); + + uint32_t raw = 0; + zassert_true(find_option_int(&cpkt, ZEPHLET_COAP_OPT_ERRNO, &raw), "errno option missing"); + zassert_equal((int32_t)raw, -EMSGSIZE, "errno verbatim mismatch"); +} + +ZTEST_SUITE(zephlet_coap_translate, NULL, NULL, NULL, NULL, NULL); diff --git a/tests/coap_translate/src/test_msg.proto b/tests/coap_translate/src/test_msg.proto new file mode 100644 index 0000000..b8b468f --- /dev/null +++ b/tests/coap_translate/src/test_msg.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +import "nanopb.proto"; + +option (nanopb_fileopt).long_names = false; + +/* Tiny fixed-size request/response pair used by the coap_translate unit + * tests. uint32 fields keep the encoded sizes statically known, so + * nanopb emits TEST_REQ_SIZE / TEST_RESP_SIZE macros that the translator + * uses to bound the decode wire length. */ + +message TestReq { + uint32 v = 1; +} + +message TestResp { + uint32 v = 1; +} diff --git a/tests/coap_translate/testcase.yaml b/tests/coap_translate/testcase.yaml new file mode 100644 index 0000000..15fd21c --- /dev/null +++ b/tests/coap_translate/testcase.yaml @@ -0,0 +1,8 @@ +tests: + zephlet.coap.translate: + tags: zephlet coap translate + harness: ztest + platform_allow: + - native_sim + - mps2/an385 + timeout: 30