diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index e0ef4c60..36bc0b8f 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -23,6 +23,13 @@ jobs: example: http_client board: native_sim no_sysbuild: true + # Builds the resource-table rpmsg adapter, which the native_sim + # variant of this sample does not: its board conf selects + # CONFIG_POUCH_RPMSG_RSC_DEVICE in place of the UART adapter. + - manifest: west-zephyr.yml + example: rpmsg_device + board: imx95_evk/mimx9596/m7 + no_sysbuild: true fail-fast: false runs-on: ubuntu-24.04 steps: diff --git a/examples/zephyr/rpmsg_device/CMakeLists.txt b/examples/zephyr/rpmsg_device/CMakeLists.txt new file mode 100644 index 00000000..d8e28e69 --- /dev/null +++ b/examples/zephyr/rpmsg_device/CMakeLists.txt @@ -0,0 +1,13 @@ +# Copyright (c) 2026 Golioth, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.20.0) + +find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) +project(rpmsg_device) + +target_sources(app PRIVATE + src/main.c + src/credentials.c +) diff --git a/examples/zephyr/rpmsg_device/Kconfig b/examples/zephyr/rpmsg_device/Kconfig new file mode 100644 index 00000000..4dd2b9d7 --- /dev/null +++ b/examples/zephyr/rpmsg_device/Kconfig @@ -0,0 +1,7 @@ +# Copyright (c) 2026 Golioth, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +mainmenu "Pouch rpmsg device example" + +source "Kconfig.zephyr" diff --git a/examples/zephyr/rpmsg_device/README.md b/examples/zephyr/rpmsg_device/README.md new file mode 100644 index 00000000..0eae81aa --- /dev/null +++ b/examples/zephyr/rpmsg_device/README.md @@ -0,0 +1,104 @@ +# Pouch rpmsg Device Example + +Demonstrates a Pouch device on the MCU half of a heterogeneous MPU+MCU SoC. The +MCU runs Zephyr and the Pouch device stack; a broker on the Linux side forwards +its pouches to the cloud over OpenAMP/rpmsg — the standard on-die IPC on these +parts. The MCU keeps its own cloud identity and end-to-end encryption, so the +Linux side only ever handles ciphertext. + +The transport is an adapter on the Pouch Serial core rather than a transport of +its own: one rpmsg message carries one Pouch Serial frame, with no segmentation. +The same device code runs over a plain UART, which is what the `native_sim` +build uses so the sample can be exercised without hardware. + +## Building + +### Native Simulator (UART link) + +`native_sim` builds the UART adapter. `uart0` stays the console and `uart1` +carries the Pouch link on its own pty, which a broker process attaches to. + +```sh +west build -b native_sim examples/zephyr/rpmsg_device +west build -t run +``` + +The pty for `uart1` is printed on startup, e.g. +`UART_1 connected to pseudotty: /dev/pts/6`. + +### i.MX95 EVK, Cortex-M7 (rpmsg link) + +```sh +west build -b imx95_evk/mimx9596/m7 examples/zephyr/rpmsg_device +``` + +`boards/imx95_evk_mimx9596_m7.conf` selects the resource-table adapter in place +of the UART one (`CONFIG_POUCH_RPMSG_RSC_DEVICE=y`), and the overlay points +`zephyr,ipc_shm` / `zephyr,ipc_rsc_table` at the reserved-memory carve-outs in +NXP's Linux device tree. + +> [!NOTE] +> `CONFIG_POUCH_RPMSG_RSC_DEVICE` builds its vdev from the **resource table**, which is +> what the Linux kernel's `virtio_rpmsg_bus` expects. Zephyr's `ipc_service` backends +> use a static vring layout Linux does not understand, so they are not an option here. + +## Running against Linux + +The Linux side loads and starts this firmware through `remoteproc`. Find the +node by name rather than assuming an index — a board may expose several, and on +the i.MX95 FRDM `remoteproc0` is the Neutron NPU while the M7 is `remoteproc1`: + +```sh +rproc=$(grep -l '^imx-rproc$' /sys/class/remoteproc/*/name | xargs dirname) + +cp zephyr.elf /lib/firmware/ +echo zephyr.elf > $rproc/firmware +echo start > $rproc/state +``` + +The device announces its endpoint through the rpmsg name service under the name +in `CONFIG_POUCH_RPMSG_RSC_DEVICE_EPT_NAME`. The default, `rpmsg-raw`, is what +binds the kernel's `rpmsg_char` driver, so the endpoint appears to userspace as +`/dev/rpmsgN` with no `RPMSG_CREATE_EPT_IOCTL` needed. The broker reads and +writes Pouch Serial frames on that character device. + +> [!CAUTION] +> Write to `/dev/rpmsgN` with **blocking** writes. `virtio_rpmsg_poll()` reports +> the endpoint writable only when a TX buffer is already free and never enables +> the tx-complete callback; only the blocking `write(2)` path arms it. A +> non-blocking writer that drains the ring — routine when streaming — waits for +> a wakeup that cannot arrive. This bites runtimes that make descriptors +> non-blocking by default (Go, Node, async Rust). + +## Provisioning + +> [!CAUTION] +> This sample embeds a **self-signed placeholder** key and certificate +> (`src/device_key.der.inc`, `src/device_crt.der.inc`) so it builds and runs +> without a provisioned filesystem. They are not secret and are not trusted by +> the cloud. Do not ship them. + +A real deployment needs a device certificate signed by a CA the cloud trusts. +See the [Golioth PKI documentation][pki] for issuing one, and the `coap_client` +or `ble_gatt` examples for the filesystem-based provisioning pattern +(`/lfs1/credentials/crt.der` + `key.der`) to use in place of +`src/credentials.c`. + +[pki]: https://docs.golioth.io/connectivity/credentials/ + +## Tuning notes + +The board config carries settings that were not obvious and are worth keeping if +you adapt this to another SoC: + +- `CONFIG_POUCH_RPMSG_RSC_DEVICE_THREAD_PRIORITY` must be **below** the Pouch + work queue (`CONFIG_POUCH_THREAD_PRIORITY`, default 5). At equal priority the + management thread drains the entire receive vring without yielding while the + decrypt queue never runs, and the downlink heap-allocates until `malloc` + fails — which puts the downlink channel into a permanent error state. +- `CONFIG_POUCH_RPMSG_RSC_DEVICE_STACK_SIZE` has to cover the whole serial-core + receive chain, including the server-cert endpoint's 4 KB allocation. 2 KB + faults partway through the certificate transfer. +- `CONFIG_HEAP_MEM_POOL_SIZE` must cover libmetal/OpenAMP virtqueue state, the + server-certificate allocation, *and* the encrypted downlink blocks in flight + at once. diff --git a/examples/zephyr/rpmsg_device/VERSION b/examples/zephyr/rpmsg_device/VERSION new file mode 100644 index 00000000..8e864e8f --- /dev/null +++ b/examples/zephyr/rpmsg_device/VERSION @@ -0,0 +1,5 @@ +VERSION_MAJOR = 1 +VERSION_MINOR = 0 +PATCHLEVEL = 2 +VERSION_TWEAK = 0 +EXTRAVERSION = diff --git a/examples/zephyr/rpmsg_device/boards/imx95_evk_mimx9596_m7.conf b/examples/zephyr/rpmsg_device/boards/imx95_evk_mimx9596_m7.conf new file mode 100644 index 00000000..a9d358e6 --- /dev/null +++ b/examples/zephyr/rpmsg_device/boards/imx95_evk_mimx9596_m7.conf @@ -0,0 +1,63 @@ +# Copyright (c) 2026 Golioth, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +# i.MX95 M7: Pouch over rpmsg against a Linux virtio-rpmsg host +# (remoteproc + resource table). Platform config mirrors the +# openamp_rsc_table sample's imx95_evk fragment. + +CONFIG_CLOCK_CONTROL=y +CONFIG_ARM_SCMI=y +CONFIG_IPM=y +CONFIG_IPM_MBOX=y +CONFIG_MBOX_NXP_IMX_MU=y +CONFIG_MBOX_INIT_PRIORITY=0 + +# System heap must cover libmetal/open-amp virtqueue state, the +# malloc(CONFIG_POUCH_SERVER_CERT_MAX_LEN) the server-cert endpoint does when +# the broker pushes the Golioth server certificate (4096 by default), AND the +# encrypted downlink blocks in flight - pouch_downlink_push() heap-allocates +# one per block and hands it to the decrypt work queue, so a fast link keeps +# many alive at once. Sizing the heap at exactly the cert length makes the +# cert allocation fail, which aborts the session right after the info +# exchange; sizing it for a slow link makes the block allocation fail mid +# firmware download, which puts the downlink channel into a permanent error +# state (the broker then sees "peer rejected transfer" on every retry). +CONFIG_HEAP_MEM_POOL_SIZE=16384 + +# The rpmsg management thread must run BELOW the Pouch work queue +# (CONFIG_POUCH_THREAD_PRIORITY, default 5). At equal priority the management +# thread drains the whole receive vring without ever yielding - a fast host +# pushed 137 KB in 6 ms - while the decrypt work queue never runs, so +# pouch_downlink_push() keeps heap-allocating encrypted blocks until malloc +# fails and the downlink channel errors out permanently. +CONFIG_POUCH_RPMSG_RSC_DEVICE_THREAD_PRIORITY=6 + +# The rpmsg rx path runs the whole serial-core receive chain (including the +# server-cert endpoint's 4 KB malloc and certificate handling) in the adapter's +# management thread. 2 KB overflows it and faults with "Illegal load of +# EXC_RETURN into PC" partway through the server-cert transfer. +CONFIG_POUCH_RPMSG_RSC_DEVICE_STACK_SIZE=8192 + +# The default of 2 blocks lets the uplink handlers hold every block while the +# transport is still waiting for the first encrypted one, which stalls the +# uplink for the whole session. +CONFIG_POUCH_BLOCK_COUNT=8 + +CONFIG_OPENAMP=y +CONFIG_OPENAMP_MASTER=n +CONFIG_OPENAMP_RSC_TABLE=y +CONFIG_OPENAMP_RSC_TABLE_NUM_RPMSG_BUFF=8 +CONFIG_OPENAMP_COPY_RSC_TABLE=y +CONFIG_OPENAMP_WITH_DCACHE=y + +# Use the resource-table rpmsg adapter instead of the UART one. +CONFIG_POUCH_SERIAL_UART_DEVICE=n +CONFIG_POUCH_RPMSG_RSC_DEVICE=y + +# Keep the M7 console (LPUART3) for logs. +CONFIG_CONSOLE=y +CONFIG_UART_CONSOLE=y +CONFIG_LOG=y +CONFIG_EARLY_CONSOLE=y +CONFIG_PRINTK=y diff --git a/examples/zephyr/rpmsg_device/boards/imx95_evk_mimx9596_m7.overlay b/examples/zephyr/rpmsg_device/boards/imx95_evk_mimx9596_m7.overlay new file mode 100644 index 00000000..a62c75d5 --- /dev/null +++ b/examples/zephyr/rpmsg_device/boards/imx95_evk_mimx9596_m7.overlay @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Golioth, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + * + * OpenAMP resource-table plumbing for the i.MX95 M7 talking to a Linux + * virtio-rpmsg host. Addresses match the vdev/vring/rsc-table reserved-memory + * nodes in NXP's Linux device tree (imx95-19x19-evk / imx95-15x15-frdm). + */ + +/ { + chosen { + zephyr,ipc_shm = &shmem; + zephyr,ipc_rsc_table = &rsc_table; + zephyr,ipc = &mailbox0; + }; + + shmem: memory@88000000 { + compatible = "mmio-sram"; + reg = <0x88000000 0x500000>; + }; + + rsc_table: memory@88220000 { + compatible = "mmio-sram"; + reg = <0x88220000 0x100>; + }; + + mailbox0: mailbox { + compatible = "zephyr,mbox-ipm"; + mboxes = <&mu7 1>, <&mu7 1>; + mbox-names = "tx", "rx"; + status = "okay"; + }; +}; + +&mu7 { + status = "okay"; +}; diff --git a/examples/zephyr/rpmsg_device/boards/native_sim.overlay b/examples/zephyr/rpmsg_device/boards/native_sim.overlay new file mode 100644 index 00000000..c1351313 --- /dev/null +++ b/examples/zephyr/rpmsg_device/boards/native_sim.overlay @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Golioth, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + * + * uart0 stays the console; uart1 carries the Pouch serial link on its own pty, + * which the broker process attaches to. + */ + +/ { + chosen { + golioth,pouch-serial-uart = &uart1; + }; +}; + +&uart1 { + status = "okay"; +}; diff --git a/examples/zephyr/rpmsg_device/boards/native_sim_native_64.overlay b/examples/zephyr/rpmsg_device/boards/native_sim_native_64.overlay new file mode 100644 index 00000000..c1351313 --- /dev/null +++ b/examples/zephyr/rpmsg_device/boards/native_sim_native_64.overlay @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Golioth, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + * + * uart0 stays the console; uart1 carries the Pouch serial link on its own pty, + * which the broker process attaches to. + */ + +/ { + chosen { + golioth,pouch-serial-uart = &uart1; + }; +}; + +&uart1 { + status = "okay"; +}; diff --git a/examples/zephyr/rpmsg_device/prj.conf b/examples/zephyr/rpmsg_device/prj.conf new file mode 100644 index 00000000..5a9bf7f2 --- /dev/null +++ b/examples/zephyr/rpmsg_device/prj.conf @@ -0,0 +1,28 @@ +# Copyright (c) 2026 Golioth, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +CONFIG_MAIN_STACK_SIZE=4096 +CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048 + +CONFIG_LOG=y + +# uart0 stays the console; uart1 carries the Pouch serial link on its own pty. +CONFIG_SERIAL=y +CONFIG_UART_INTERRUPT_DRIVEN=y + +CONFIG_POUCH=y +CONFIG_POUCH_TRANSPORT_SERIAL=y +CONFIG_POUCH_SERIAL_UART_DEVICE=y + +# Pouch uses SAEAD end-to-end encryption by default, which pulls in the PSA/ +# mbedTLS crypto it needs. Provide the mbedTLS heap and disable the p256m +# driver (as the other examples do) so the SAEAD curves build. +CONFIG_MBEDTLS=y +CONFIG_MBEDTLS_BUILTIN=y +CONFIG_MBEDTLS_ENABLE_HEAP=y +CONFIG_MBEDTLS_HEAP_SIZE=32768 +CONFIG_MBEDTLS_PSA_P256M_DRIVER_ENABLED=n +CONFIG_MBEDTLS_PKCS5_C=n +CONFIG_TEST_RANDOM_GENERATOR=y + diff --git a/examples/zephyr/rpmsg_device/sample.yaml b/examples/zephyr/rpmsg_device/sample.yaml new file mode 100644 index 00000000..fb760896 --- /dev/null +++ b/examples/zephyr/rpmsg_device/sample.yaml @@ -0,0 +1,13 @@ +sample: + description: Pouch device over the serial transport's rpmsg and UART adapters (SAEAD) + name: rpmsg_device +tests: + sample.pouch.rpmsg_device: + build_only: true + platform_allow: + - native_sim + - native_sim/native/64 + integration_platforms: + - native_sim + tags: + - pouch diff --git a/examples/zephyr/rpmsg_device/src/credentials.c b/examples/zephyr/rpmsg_device/src/credentials.c new file mode 100644 index 00000000..e4fb4c75 --- /dev/null +++ b/examples/zephyr/rpmsg_device/src/credentials.c @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Golioth, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Embedded device credentials for the rpmsg example. + * + * The key and certificate below are auto-generated placeholders (a self-signed + * P-256 key pair) so the example is self-contained and builds without a + * provisioned filesystem. A real deployment must provision a device + * certificate signed by a CA the cloud trusts (see the coap_client / ble_gatt + * examples for the filesystem-based provisioning pattern). + */ + +#include "credentials.h" + +#include +#include + +#include +LOG_MODULE_REGISTER(credentials, LOG_LEVEL_INF); + +static const uint8_t device_key[] = { +#include "device_key.der.inc" +}; + +static const uint8_t device_crt[] = { +#include "device_crt.der.inc" +}; + +static psa_key_id_t import_raw_pk(const uint8_t *private_key, size_t size) +{ + mbedtls_pk_context pk; + mbedtls_pk_init(&pk); + + int err = mbedtls_pk_parse_key(&pk, private_key, size, NULL, 0); + if (err) + { + LOG_ERR("Failed to parse key: -0x%x", -err); + mbedtls_pk_free(&pk); + return PSA_KEY_ID_NULL; + } + + psa_key_attributes_t attrs = PSA_KEY_ATTRIBUTES_INIT; + + psa_set_key_algorithm(&attrs, PSA_ALG_ECDH); + psa_set_key_type(&attrs, PSA_KEY_TYPE_ECC_KEY_PAIR(PSA_ECC_FAMILY_SECP_R1)); + psa_set_key_usage_flags(&attrs, PSA_KEY_USAGE_DERIVE); + + psa_key_id_t key_id; + err = mbedtls_pk_import_into_psa(&pk, &attrs, &key_id); + mbedtls_pk_free(&pk); + if (err) + { + LOG_ERR("Failed to import private key: -0x%x", -err); + return PSA_KEY_ID_NULL; + } + + return key_id; +} + +psa_key_id_t load_private_key(void) +{ + return import_raw_pk(device_key, sizeof(device_key)); +} + +int load_certificate(struct pouch_cert *cert) +{ + cert->buffer = device_crt; + cert->size = sizeof(device_crt); + return 0; +} diff --git a/examples/zephyr/rpmsg_device/src/credentials.h b/examples/zephyr/rpmsg_device/src/credentials.h new file mode 100644 index 00000000..a49c58ed --- /dev/null +++ b/examples/zephyr/rpmsg_device/src/credentials.h @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Golioth, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include + +#include + +/** + * Import the embedded device private key into PSA. + * + * @return The assigned PSA key ID, or PSA_KEY_ID_NULL on failure. + */ +psa_key_id_t load_private_key(void); + +/** + * Point @p cert at the embedded device certificate. + * + * @return 0 on success. + */ +int load_certificate(struct pouch_cert *cert); diff --git a/examples/zephyr/rpmsg_device/src/device_crt.der.inc b/examples/zephyr/rpmsg_device/src/device_crt.der.inc new file mode 100644 index 00000000..df80a5ec --- /dev/null +++ b/examples/zephyr/rpmsg_device/src/device_crt.der.inc @@ -0,0 +1,35 @@ +/* Auto-generated example device certificate (X.509 DER, self-signed). Placeholder credential for build/demo only. */ + 0x30, 0x82, 0x01, 0x90, 0x30, 0x82, 0x01, 0x37, 0xa0, 0x03, 0x02, 0x01, + 0x02, 0x02, 0x14, 0x73, 0x92, 0x0d, 0xc9, 0x56, 0xc6, 0xc5, 0x0c, 0x13, + 0x54, 0x4e, 0x4d, 0x98, 0x68, 0xb6, 0xd4, 0x3b, 0xf4, 0x99, 0x7a, 0x30, + 0x0a, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02, 0x30, + 0x1e, 0x31, 0x1c, 0x30, 0x1a, 0x06, 0x03, 0x55, 0x04, 0x03, 0x0c, 0x13, + 0x70, 0x6f, 0x75, 0x63, 0x68, 0x2d, 0x72, 0x70, 0x6d, 0x73, 0x67, 0x2d, + 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x30, 0x1e, 0x17, 0x0d, 0x32, + 0x36, 0x30, 0x37, 0x32, 0x33, 0x32, 0x31, 0x33, 0x38, 0x31, 0x31, 0x5a, + 0x17, 0x0d, 0x33, 0x36, 0x30, 0x37, 0x32, 0x30, 0x32, 0x31, 0x33, 0x38, + 0x31, 0x31, 0x5a, 0x30, 0x1e, 0x31, 0x1c, 0x30, 0x1a, 0x06, 0x03, 0x55, + 0x04, 0x03, 0x0c, 0x13, 0x70, 0x6f, 0x75, 0x63, 0x68, 0x2d, 0x72, 0x70, + 0x6d, 0x73, 0x67, 0x2d, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x30, + 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, + 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, + 0x00, 0x04, 0x0f, 0xf6, 0x8f, 0xe5, 0x8f, 0xc0, 0x2f, 0x41, 0x6f, 0xd1, + 0x7f, 0xa5, 0x33, 0x67, 0x3c, 0xf3, 0xcc, 0x3b, 0xc5, 0xc9, 0xa7, 0xdc, + 0xf8, 0x55, 0xf7, 0xc6, 0x0f, 0x44, 0xcc, 0x1c, 0x50, 0x1d, 0x14, 0x35, + 0xda, 0xb0, 0x2d, 0x08, 0x89, 0xca, 0x59, 0x7f, 0xdb, 0x42, 0xa3, 0x06, + 0xdf, 0x4b, 0x63, 0x13, 0xe6, 0x50, 0x5d, 0x7b, 0x1e, 0x24, 0xb7, 0xae, + 0x8f, 0x02, 0xe0, 0xbb, 0x50, 0xed, 0xa3, 0x53, 0x30, 0x51, 0x30, 0x1d, + 0x06, 0x03, 0x55, 0x1d, 0x0e, 0x04, 0x16, 0x04, 0x14, 0x99, 0x2f, 0x04, + 0xe9, 0x3b, 0x86, 0xbe, 0xeb, 0x56, 0xee, 0xef, 0x46, 0x58, 0xa8, 0x06, + 0xf9, 0x69, 0x60, 0x73, 0xb0, 0x30, 0x1f, 0x06, 0x03, 0x55, 0x1d, 0x23, + 0x04, 0x18, 0x30, 0x16, 0x80, 0x14, 0x99, 0x2f, 0x04, 0xe9, 0x3b, 0x86, + 0xbe, 0xeb, 0x56, 0xee, 0xef, 0x46, 0x58, 0xa8, 0x06, 0xf9, 0x69, 0x60, + 0x73, 0xb0, 0x30, 0x0f, 0x06, 0x03, 0x55, 0x1d, 0x13, 0x01, 0x01, 0xff, + 0x04, 0x05, 0x30, 0x03, 0x01, 0x01, 0xff, 0x30, 0x0a, 0x06, 0x08, 0x2a, + 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02, 0x03, 0x47, 0x00, 0x30, 0x44, + 0x02, 0x20, 0x14, 0x8a, 0xa7, 0x18, 0x32, 0x01, 0xc7, 0x63, 0x50, 0xc0, + 0x0a, 0x01, 0xb2, 0x8f, 0x9e, 0x95, 0xa5, 0x83, 0xf8, 0x75, 0xf7, 0x15, + 0xce, 0x91, 0x67, 0xe4, 0xf0, 0x42, 0x26, 0xee, 0x7c, 0x20, 0x02, 0x20, + 0x07, 0x29, 0xfc, 0x5d, 0x5a, 0x03, 0xde, 0x5b, 0x72, 0xa8, 0xd2, 0xc7, + 0xf7, 0x32, 0x9f, 0xe4, 0x7e, 0xea, 0xd7, 0xa9, 0x92, 0x26, 0xed, 0xfd, + 0x61, 0x97, 0x02, 0x0b, 0x5f, 0xaa, 0xc7, 0x93 diff --git a/examples/zephyr/rpmsg_device/src/device_key.der.inc b/examples/zephyr/rpmsg_device/src/device_key.der.inc new file mode 100644 index 00000000..028832a6 --- /dev/null +++ b/examples/zephyr/rpmsg_device/src/device_key.der.inc @@ -0,0 +1,13 @@ +/* Auto-generated example device private key (PKCS#8 DER, P-256). Placeholder credential for build/demo only. */ + 0x30, 0x81, 0x87, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, + 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, + 0x03, 0x01, 0x07, 0x04, 0x6d, 0x30, 0x6b, 0x02, 0x01, 0x01, 0x04, 0x20, + 0xd1, 0xef, 0x2c, 0x28, 0x89, 0x61, 0x6e, 0x24, 0x66, 0x00, 0xbd, 0xcb, + 0xbf, 0x7d, 0xa4, 0x00, 0x1d, 0xe2, 0xb2, 0x46, 0x08, 0xdd, 0xe9, 0x56, + 0xf8, 0x48, 0x93, 0xc6, 0x3b, 0xcc, 0x32, 0x24, 0xa1, 0x44, 0x03, 0x42, + 0x00, 0x04, 0x0f, 0xf6, 0x8f, 0xe5, 0x8f, 0xc0, 0x2f, 0x41, 0x6f, 0xd1, + 0x7f, 0xa5, 0x33, 0x67, 0x3c, 0xf3, 0xcc, 0x3b, 0xc5, 0xc9, 0xa7, 0xdc, + 0xf8, 0x55, 0xf7, 0xc6, 0x0f, 0x44, 0xcc, 0x1c, 0x50, 0x1d, 0x14, 0x35, + 0xda, 0xb0, 0x2d, 0x08, 0x89, 0xca, 0x59, 0x7f, 0xdb, 0x42, 0xa3, 0x06, + 0xdf, 0x4b, 0x63, 0x13, 0xe6, 0x50, 0x5d, 0x7b, 0x1e, 0x24, 0xb7, 0xae, + 0x8f, 0x02, 0xe0, 0xbb, 0x50, 0xed diff --git a/examples/zephyr/rpmsg_device/src/main.c b/examples/zephyr/rpmsg_device/src/main.c new file mode 100644 index 00000000..403d91b9 --- /dev/null +++ b/examples/zephyr/rpmsg_device/src/main.c @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Golioth, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Pouch device example that syncs over the serial transport's UART device + * adapter. On native_sim the link is a host pty (native-tty UART) that a broker + * process attaches to; on a real MPU+MCU part the same device code runs over + * the rpmsg adapter instead. The transport adapter initializes itself at boot, + * so the application only initializes Pouch (with its device credentials for + * end-to-end encryption) and registers its handlers. + */ + +#include +LOG_MODULE_REGISTER(rpmsg_device, LOG_LEVEL_INF); + +#include + +#include + +#include +#include + +#include "credentials.h" + +/* Called by Pouch each time a sync collects uplink data. */ +static void report_telemetry(void) +{ + static uint32_t counter; + char payload[64]; + + int len = snprintk(payload, + sizeof(payload), + "{\"uptime\":%lld,\"counter\":%u}", + k_uptime_get() / MSEC_PER_SEC, + counter++); + + pouch_uplink_entry_write(".s/telemetry", POUCH_CONTENT_TYPE_JSON, payload, len, POUCH_FOREVER); +} +POUCH_UPLINK_HANDLER(report_telemetry); + +int main(void) +{ + struct pouch_config config = {0}; + + int err = load_certificate(&config.certificate); + if (err) + { + LOG_ERR("Failed to load certificate (err %d)", err); + return err; + } + + config.private_key = load_private_key(); + if (config.private_key == PSA_KEY_ID_NULL) + { + LOG_ERR("Failed to load private key"); + return -EIO; + } + + err = pouch_init(&config); + if (err) + { + LOG_ERR("Pouch init failed (err %d)", err); + return err; + } + + LOG_INF("Pouch serial/rpmsg device ready"); + + return 0; +} diff --git a/port/zephyr/CMakeLists.txt b/port/zephyr/CMakeLists.txt index b48893d9..e01226a3 100644 --- a/port/zephyr/CMakeLists.txt +++ b/port/zephyr/CMakeLists.txt @@ -74,6 +74,9 @@ if(CONFIG_POUCH) if (CONFIG_POUCH_TRANSPORT_COAP_CLIENT) add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/transport/coap transport_coap) endif() + if (CONFIG_POUCH_TRANSPORT_SERIAL) + add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/transport/serial transport_serial) + endif() endif() if(CONFIG_POUCH_GATEWAY) diff --git a/port/zephyr/transport/serial/CMakeLists.txt b/port/zephyr/transport/serial/CMakeLists.txt new file mode 100644 index 00000000..9fcacf55 --- /dev/null +++ b/port/zephyr/transport/serial/CMakeLists.txt @@ -0,0 +1,17 @@ +# Copyright (c) 2026 Golioth, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +# Physical adapters for the Pouch Serial transport. The serial protocol core +# lives in src/transport/serial and is compiled by src/transport/CMakeLists.txt; +# the files here bind that core to a concrete link (rpmsg/OpenAMP over a +# resource table, a plain UART, and in future other buses). + +if(CONFIG_POUCH_RPMSG_RSC_DEVICE) + zephyr_library_sources(${CMAKE_CURRENT_LIST_DIR}/rpmsg_rsc_device.c) +endif() + +if(CONFIG_POUCH_SERIAL_UART_DEVICE) + zephyr_library_sources(${CMAKE_CURRENT_LIST_DIR}/uart_device.c) + zephyr_library_sources(${CMAKE_CURRENT_LIST_DIR}/uart_framing.c) +endif() diff --git a/port/zephyr/transport/serial/Kconfig b/port/zephyr/transport/serial/Kconfig index fe16384a..4567d2a2 100644 --- a/port/zephyr/transport/serial/Kconfig +++ b/port/zephyr/transport/serial/Kconfig @@ -21,6 +21,127 @@ config POUCH_TRANSPORT_SERIAL_DEVICE runs on the node device and communicates with a gateway running the broker implementation. +menuconfig POUCH_RPMSG_RSC_DEVICE + bool "Pouch rpmsg device transport adapter (resource table)" + depends on POUCH_TRANSPORT_SERIAL_DEVICE + depends on OPENAMP_RSC_TABLE + depends on IPM + help + Enable the rpmsg (OpenAMP) device transport adapter for the Pouch + Serial device protocol on parts where a Linux host drives + virtio-rpmsg through remoteproc and a resource table (i.MX 8M/9x, + ZynqMP, RZ/G, ...). Zephyr's ipc_service backends use a static vring + layout the Linux kernel's virtio_rpmsg_bus does not speak, so this + adapter publishes the vdev via the resource table exactly like the + openamp_rsc_table sample, and + announces a single endpoint carrying one Pouch Serial frame per rpmsg + message. Requires the zephyr,ipc_shm / zephyr,ipc_rsc_table / + zephyr,ipc chosen nodes. + +if POUCH_RPMSG_RSC_DEVICE + +config POUCH_RPMSG_RSC_DEVICE_EPT_NAME + string "rpmsg endpoint name" + default "rpmsg-raw" + help + Name of the rpmsg endpoint announced to the host via the rpmsg name + service. The default "rpmsg-raw" binds the Linux rpmsg_char driver, + exposing the endpoint to a userspace broker as /dev/rpmsgN with no + extra kernel or ioctl plumbing. + +config POUCH_RPMSG_RSC_DEVICE_FRAME_SIZE + int "Maximum rpmsg frame size" + default 496 + range 2 496 + help + Maximum size in bytes of a single Pouch Serial frame carried in one + rpmsg message (1-byte header plus payload). The default matches the + payload available in the standard 512-byte rpmsg buffers. + +config POUCH_RPMSG_RSC_DEVICE_STACK_SIZE + int "Adapter thread stack sizes" + default 2048 + +config POUCH_RPMSG_RSC_DEVICE_TX_THREAD_PRIORITY + int "Transmit thread priority" + default 4 + help + Must be above the management thread so the Pouch work queue keeps + draining what the receive callback delivers. + +config POUCH_RPMSG_RSC_DEVICE_TX_WAIT_MS + int "How long to wait for a free rpmsg TX buffer (ms)" + default 2000 + help + The transmit thread retries rpmsg_trysend() for this long while the + host has no free receive buffers, then drops the frame. Retrying + outside the rpmsg device lock keeps the management thread free to + return receive buffers to the host; the blocking rpmsg_send() holds + that lock and deadlocks against a host that is mid-write. + +config POUCH_RPMSG_RSC_DEVICE_TX_POLL_MS + int "TX poll interval (ms)" + default 10 + help + How often the transmit thread re-checks the serial core for a frame + while waiting on an endpoint that reported POUCH_MORE_DATA with no + bytes available yet. The uplink endpoint produces its data + asynchronously and never calls bearer_ready(), so a purely + event-driven transmit path stalls the session. + +config POUCH_RPMSG_RSC_DEVICE_THREAD_PRIORITY + int "Adapter thread priority" + default 5 + +module = POUCH_RPMSG_RSC_DEVICE +module-str = Pouch rpmsg rsc-table Device +source "subsys/logging/Kconfig.template.log_config" + +endif # POUCH_RPMSG_RSC_DEVICE + +menuconfig POUCH_SERIAL_UART_DEVICE + bool "Pouch UART device transport adapter" + depends on POUCH_TRANSPORT_SERIAL_DEVICE + depends on SERIAL && UART_INTERRUPT_DRIVEN + help + Enable the interrupt-driven UART device adapter for the Pouch Serial + device protocol. It carries Pouch Serial frames over the UART referenced + by the "golioth,pouch-serial-uart" chosen node, using a minimal + length-delimited framing. Intended for development and native_sim + end-to-end testing over a host pty (via the native-tty UART driver), + with the broker running as a host process on the other end of the link. + +if POUCH_SERIAL_UART_DEVICE + +config POUCH_SERIAL_UART_DEVICE_FRAME_SIZE + int "Maximum serial frame size" + default 496 + range 2 4096 + help + Maximum size in bytes of a single Pouch Serial frame (1-byte header plus + payload) carried over the UART link. + +config POUCH_SERIAL_UART_DEVICE_RX_RING_SIZE + int "RX ring buffer size" + default 1024 + help + Size in bytes of the ring buffer that decouples the UART RX ISR from the + frame-parsing thread. + +config POUCH_SERIAL_UART_DEVICE_RX_THREAD_STACK_SIZE + int "RX thread stack size" + default 2048 + +config POUCH_SERIAL_UART_DEVICE_RX_THREAD_PRIORITY + int "RX thread priority" + default 5 + +module = POUCH_SERIAL_UART_DEVICE +module-str = Pouch UART Device +source "subsys/logging/Kconfig.template.log_config" + +endif # POUCH_SERIAL_UART_DEVICE + module = POUCH_SERIAL module-str = Pouch Serial Transport source "subsys/logging/Kconfig.template.log_config" diff --git a/port/zephyr/transport/serial/rpmsg_rsc_device.c b/port/zephyr/transport/serial/rpmsg_rsc_device.c new file mode 100644 index 00000000..65d62668 --- /dev/null +++ b/port/zephyr/transport/serial/rpmsg_rsc_device.c @@ -0,0 +1,423 @@ +/* + * Copyright (c) 2026 Golioth, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +LOG_MODULE_REGISTER(pouch_rpmsg_rsc, CONFIG_POUCH_RPMSG_RSC_DEVICE_LOG_LEVEL); + +/* + * Pouch Serial device adapter for MPU+MCU parts where the Linux host drives + * virtio-rpmsg through remoteproc and a resource table. Zephyr's + * DT-instantiable ipc_service backends speak their own static-vring layout, + * which the Linux kernel's virtio_rpmsg_bus does not understand, so on these + * systems the vdev must come from the resource table, exactly like the + * openamp_rsc_table sample this platform bring-up is lifted from. + * + * rpmsg is reliable, ordered and message-oriented: each rpmsg message carries + * exactly one Pouch Serial frame (1-byte header + payload). + */ + +#if !DT_HAS_CHOSEN(zephyr_ipc_shm) +#error "The rpmsg (rsc table) adapter requires a zephyr,ipc_shm chosen node" +#endif + +#define SHM_NODE DT_CHOSEN(zephyr_ipc_shm) +#define SHM_START_ADDR DT_REG_ADDR(SHM_NODE) +#define SHM_SIZE DT_REG_SIZE(SHM_NODE) + +static const struct device *const ipm_handle = DEVICE_DT_GET(DT_CHOSEN(zephyr_ipc)); + +static metal_phys_addr_t shm_physmap = SHM_START_ADDR; +static metal_phys_addr_t rsc_tab_physmap; + +static struct metal_io_region shm_io_data; +static struct metal_io_region rsc_io_data; + +static struct rpmsg_virtio_device rvdev; +static void *rsc_table; +static struct rpmsg_device *rpdev; +static struct rpmsg_endpoint pouch_ept; + +static K_SEM_DEFINE(ipm_sem, 0, 1); +static K_SEM_DEFINE(tx_sem, 0, 1); +static K_SEM_DEFINE(ept_ready_sem, 0, 1); + +static K_THREAD_STACK_DEFINE(mng_stack, CONFIG_POUCH_RPMSG_RSC_DEVICE_STACK_SIZE); +static K_THREAD_STACK_DEFINE(tx_stack, CONFIG_POUCH_RPMSG_RSC_DEVICE_STACK_SIZE); +static struct k_thread mng_thread; +static struct k_thread tx_thread; + +static uint8_t tx_frame[CONFIG_POUCH_RPMSG_RSC_DEVICE_FRAME_SIZE]; + +static uint32_t rx_frames; +static uint32_t tx_frames; + +static void platform_ipm_callback(const struct device *dev, + void *context, + uint32_t id, + volatile void *data) +{ + ARG_UNUSED(dev); + ARG_UNUSED(context); + ARG_UNUSED(id); + ARG_UNUSED(data); + k_sem_give(&ipm_sem); +} + +static int mailbox_notify(void *priv, uint32_t id) +{ + ARG_UNUSED(priv); +#if CONFIG_IPM_MAX_DATA_SIZE > 0 + ipm_send(ipm_handle, 0, id, &id, 4); +#else + ipm_send(ipm_handle, 0, id, NULL, 0); +#endif + return 0; +} + +static int pouch_ept_cb(struct rpmsg_endpoint *ept, + void *data, + size_t len, + uint32_t src, + void *priv) +{ + ARG_UNUSED(ept); + ARG_UNUSED(src); + ARG_UNUSED(priv); + + if (len == 0) + { + return RPMSG_SUCCESS; + } + + rx_frames++; + LOG_DBG("rx %u: %zu bytes, hdr 0x%02x", rx_frames, len, ((const uint8_t *) data)[0]); + + int err = pouch_serial_device_recv(data, len); + if (err) + { + LOG_ERR("RX process failed: %d", err); + } + + /* Receiving may have produced a response frame (e.g. an ACK). */ + k_sem_give(&tx_sem); + + return RPMSG_SUCCESS; +} + +static void pouch_ept_unbound(struct rpmsg_endpoint *ept) +{ + ARG_UNUSED(ept); + LOG_WRN("Endpoint unbound"); +} + +/* Serial core -> adapter: a frame is available to send. */ +static void serial_ready_cb(void) +{ + k_sem_give(&tx_sem); +} + +static void ns_bind_cb(struct rpmsg_device *rdev, const char *name, uint32_t src) +{ + ARG_UNUSED(rdev); + LOG_WRN("Unexpected NS announcement for %s (src %u)", name, src); +} + +/* The Linux host may kick the MU the moment the core starts, and the MU's + * interrupt-enable bits survive a remoteproc restart from a previous firmware, + * so the mailbox ISR can run long before this transport's threads. Register + * the IPM callback in the earliest init slot after the ipm device exists so a + * stray kick finds a valid callback rather than a NULL pointer. + */ +static int ipm_early_init(void) +{ + if (!device_is_ready(ipm_handle)) + { + LOG_ERR("IPM device not ready at early init"); + return -ENODEV; + } + + ipm_register_callback(ipm_handle, platform_ipm_callback, NULL); + return 0; +} + +SYS_INIT(ipm_early_init, POST_KERNEL, 99); + +static int platform_init(void) +{ + struct metal_init_params metal_params = METAL_INIT_DEFAULTS; + int rsc_size; + int status; + + status = metal_init(&metal_params); + if (status) + { + LOG_ERR("metal_init failed: %d", status); + return -EIO; + } + + metal_io_init(&shm_io_data, + (void *) SHM_START_ADDR, + &shm_physmap, + SHM_SIZE, + -1, + 0, + addr_translation_get_ops(shm_physmap)); + + rsc_table_get(&rsc_table, &rsc_size); + rsc_tab_physmap = (uintptr_t) rsc_table; + metal_io_init(&rsc_io_data, rsc_table, &rsc_tab_physmap, rsc_size, -1, 0, NULL); + + if (!device_is_ready(ipm_handle)) + { + LOG_ERR("IPM device not ready"); + return -ENODEV; + } + + status = ipm_set_enabled(ipm_handle, 1); + if (status) + { + LOG_ERR("ipm_set_enabled failed: %d", status); + return -EIO; + } + + return 0; +} + +static struct rpmsg_device *create_rpmsg_vdev(void) +{ + struct fw_rsc_vdev_vring *vring_rsc; + struct virtio_device *vdev; + int ret; + + vdev = rproc_virtio_create_vdev(VIRTIO_DEV_DEVICE, + VDEV_ID, + rsc_table_to_vdev(rsc_table), + &rsc_io_data, + NULL, + mailbox_notify, + NULL); + if (!vdev) + { + LOG_ERR("failed to create vdev"); + return NULL; + } + + /* Wait for the Linux virtio host to finish its rpmsg init. */ + rproc_virtio_wait_remote_ready(vdev); + + vring_rsc = rsc_table_get_vring0(rsc_table); + ret = rproc_virtio_init_vring(vdev, + 0, + vring_rsc->notifyid, + (void *) vring_rsc->da, + &rsc_io_data, + vring_rsc->num, + vring_rsc->align); + if (ret) + { + goto failed; + } + + vring_rsc = rsc_table_get_vring1(rsc_table); + ret = rproc_virtio_init_vring(vdev, + 1, + vring_rsc->notifyid, + (void *) vring_rsc->da, + &rsc_io_data, + vring_rsc->num, + vring_rsc->align); + if (ret) + { + goto failed; + } + + ret = rpmsg_init_vdev(&rvdev, vdev, ns_bind_cb, &shm_io_data, NULL); + if (ret) + { + LOG_ERR("rpmsg_init_vdev failed: %d", ret); + goto failed; + } + + return rpmsg_virtio_get_rpmsg_device(&rvdev); + +failed: + rproc_virtio_remove_vdev(vdev); + return NULL; +} + +/* Push every frame the serial core has ready. This runs on its own thread so + * the firmware channel keeps draining while the receive callback is stalled on + * relay pressure; libmetal's mutex makes concurrent rpmsg access safe. + */ +static void drain_tx(void) +{ + while (true) + { + size_t len = pouch_serial_device_frame_get(tx_frame, sizeof(tx_frame)); + if (len == 0) + { + break; + } + + /* Never use the blocking rpmsg_send(): it spins until a TX buffer + * frees while this thread is the only one that can service the vrings, + * so a host with no free buffers would never get them back. Give up + * after a bounded wait instead. + */ + int ret; + uint32_t waited = 0; + + while ((ret = rpmsg_trysend(&pouch_ept, tx_frame, len)) == RPMSG_ERR_NO_BUFF + && waited < CONFIG_POUCH_RPMSG_RSC_DEVICE_TX_WAIT_MS) + { + k_sleep(K_MSEC(1)); + waited++; + } + + if (ret < 0) + { + LOG_ERR("rpmsg_trysend failed (%d) after %u ms, dropping frame", ret, waited); + } + else + { + tx_frames++; + LOG_DBG("tx %u: %zu bytes, hdr 0x%02x", tx_frames, len, tx_frame[0]); + if (waited > 0) + { + LOG_WRN("tx buffer starved %u ms (frame %u)", waited, tx_frames); + } + } + } +} + +static void mng_task(void *a, void *b, void *c) +{ + ARG_UNUSED(a); + ARG_UNUSED(b); + ARG_UNUSED(c); + + if (platform_init()) + { + return; + } + + rpdev = create_rpmsg_vdev(); + if (!rpdev) + { + LOG_ERR("Failed to create rpmsg virtio device"); + return; + } + + int err = rpmsg_create_ept(&pouch_ept, + rpdev, + CONFIG_POUCH_RPMSG_RSC_DEVICE_EPT_NAME, + RPMSG_ADDR_ANY, + RPMSG_ADDR_ANY, + pouch_ept_cb, + pouch_ept_unbound); + if (err) + { + LOG_ERR("rpmsg_create_ept failed: %d", err); + return; + } + + LOG_INF("rpmsg (rsc table) device transport ready"); + k_sem_give(&ept_ready_sem); + k_sem_give(&tx_sem); + + while (true) + { + /* Poll rather than block indefinitely. The serial core's frame_get() + * returns 0 when an endpoint reports POUCH_MORE_DATA with no bytes yet + * ("wait for the next call", channel.c), but nothing schedules that + * call: the uplink endpoint produces its data asynchronously on the + * pouch work queue and never invokes bearer_ready(). Waiting only on + * the mailbox would therefore stall a session forever in the window + * between the broker's prompt and the first encrypted block. + */ + k_sem_take(&ipm_sem, K_MSEC(CONFIG_POUCH_RPMSG_RSC_DEVICE_TX_POLL_MS)); + rproc_virtio_notified(rvdev.vdev, VRING1_ID); + } +} + +static void tx_task(void *a, void *b, void *c) +{ + ARG_UNUSED(a); + ARG_UNUSED(b); + ARG_UNUSED(c); + + k_sem_take(&ept_ready_sem, K_FOREVER); + + while (true) + { + /* Poll rather than block indefinitely. The serial core's frame_get() + * returns 0 when an endpoint reports POUCH_MORE_DATA with no bytes yet + * ("wait for the next call", channel.c), but nothing schedules that + * call: the uplink endpoint produces its data asynchronously on the + * pouch work queue and never invokes bearer_ready(). Waiting only on + * the semaphore would therefore stall a session forever in the window + * between the broker's prompt and the first encrypted block. + */ + k_sem_take(&tx_sem, K_MSEC(CONFIG_POUCH_RPMSG_RSC_DEVICE_TX_POLL_MS)); + drain_tx(); + } +} + +static int pouch_rpmsg_rsc_init(void) +{ + /* Bring up the serial core before the endpoint so frames can be delivered + * as soon as the host binds. + */ + pouch_serial_device_init(serial_ready_cb); + + k_thread_create(&mng_thread, + mng_stack, + K_THREAD_STACK_SIZEOF(mng_stack), + mng_task, + NULL, + NULL, + NULL, + CONFIG_POUCH_RPMSG_RSC_DEVICE_THREAD_PRIORITY, + 0, + K_NO_WAIT); + k_thread_name_set(&mng_thread, "pouch_rpmsg_mng"); + + /* Above the management thread: the firmware channel has to keep draining + * while the receive callback is stalled waiting for it. + */ + k_thread_create(&tx_thread, + tx_stack, + K_THREAD_STACK_SIZEOF(tx_stack), + tx_task, + NULL, + NULL, + NULL, + CONFIG_POUCH_RPMSG_RSC_DEVICE_TX_THREAD_PRIORITY, + 0, + K_NO_WAIT); + k_thread_name_set(&tx_thread, "pouch_rpmsg_tx"); + + + return 0; +} + +SYS_INIT(pouch_rpmsg_rsc_init, APPLICATION, CONFIG_APPLICATION_INIT_PRIORITY); diff --git a/port/zephyr/transport/serial/uart_device.c b/port/zephyr/transport/serial/uart_device.c new file mode 100644 index 00000000..65a3d8c3 --- /dev/null +++ b/port/zephyr/transport/serial/uart_device.c @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026 Golioth, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "uart_framing.h" + +#include +#include + +LOG_MODULE_REGISTER(pouch_uart_device, CONFIG_POUCH_SERIAL_UART_DEVICE_LOG_LEVEL); + +/* + * Interrupt-driven UART device adapter for the Pouch Serial transport. + * + * The Pouch Serial core produces and consumes whole frames (a 1-byte serial + * header plus payload). A UART is a raw byte stream, so frames are wrapped in + * the length-delimited framing implemented by uart_framing.[ch]. This is the + * device-side adapter used for development and native_sim end-to-end testing + * (over a host pty via the native-tty UART driver), where the broker runs on + * the other end of the link. The reliable, ordered link means no + * retransmission is required. + */ + +#define POUCH_UART_NODE DT_CHOSEN(golioth_pouch_serial_uart) + +BUILD_ASSERT(DT_NODE_EXISTS(POUCH_UART_NODE), + "The chosen node golioth,pouch-serial-uart must reference a UART device"); + +static const struct device *const uart_dev = DEVICE_DT_GET(POUCH_UART_NODE); + +RING_BUF_DECLARE(rx_ring, CONFIG_POUCH_SERIAL_UART_DEVICE_RX_RING_SIZE); +static K_SEM_DEFINE(rx_sem, 0, 1); + +static K_MUTEX_DEFINE(tx_mutex); +static struct k_work_delayable tx_work; + +static K_THREAD_STACK_DEFINE(rx_stack, CONFIG_POUCH_SERIAL_UART_DEVICE_RX_THREAD_STACK_SIZE); +static struct k_thread rx_thread; + +static void uart_isr(const struct device *dev, void *user_data) +{ + ARG_UNUSED(user_data); + + while (uart_irq_update(dev) && uart_irq_rx_ready(dev)) + { + uint8_t buf[64]; + int n = uart_fifo_read(dev, buf, sizeof(buf)); + if (n <= 0) + { + break; + } + + uint32_t written = ring_buf_put(&rx_ring, buf, n); + if (written < (uint32_t) n) + { + LOG_WRN("RX ring overflow, dropped %d bytes", n - (int) written); + } + k_sem_give(&rx_sem); + } +} + +/* Pull one byte from the RX ring, blocking until available. */ +static uint8_t rx_byte(void) +{ + uint8_t b; + + while (ring_buf_get(&rx_ring, &b, 1) != 1) + { + k_sem_take(&rx_sem, K_FOREVER); + } + + return b; +} + +static void rx_thread_fn(void *a, void *b, void *c) +{ + ARG_UNUSED(a); + ARG_UNUSED(b); + ARG_UNUSED(c); + + static uint8_t frame[CONFIG_POUCH_SERIAL_UART_DEVICE_FRAME_SIZE]; + struct pouch_uart_framer framer; + + pouch_uart_framer_init(&framer, frame, sizeof(frame)); + + while (true) + { + size_t len = pouch_uart_framer_feed(&framer, rx_byte()); + if (len == 0) + { + continue; + } + + int err = pouch_serial_device_recv(frame, len); + if (err) + { + LOG_ERR("RX process failed: %d", err); + } + + /* Receiving may have produced a response frame; kick the TX path. */ + k_work_reschedule(&tx_work, K_NO_WAIT); + } +} + +static void tx_process(struct k_work *work) +{ + ARG_UNUSED(work); + + uint8_t frame[CONFIG_POUCH_SERIAL_UART_DEVICE_FRAME_SIZE]; + uint8_t framed[CONFIG_POUCH_SERIAL_UART_DEVICE_FRAME_SIZE + POUCH_UART_FRAME_OVERHEAD]; + + k_mutex_lock(&tx_mutex, K_FOREVER); + + while (true) + { + size_t len = pouch_serial_device_frame_get(frame, sizeof(frame)); + if (len == 0) + { + break; + } + + size_t framed_len = pouch_uart_frame_encode(framed, sizeof(framed), frame, len); + for (size_t i = 0; i < framed_len; i++) + { + uart_poll_out(uart_dev, framed[i]); + } + } + + k_mutex_unlock(&tx_mutex); +} + +/* Serial core -> adapter: a frame is available to send. */ +static void serial_ready_cb(void) +{ + k_work_reschedule(&tx_work, K_NO_WAIT); +} + +static int pouch_uart_device_init(void) +{ + if (!device_is_ready(uart_dev)) + { + LOG_ERR("UART device not ready"); + return -ENODEV; + } + + k_work_init_delayable(&tx_work, tx_process); + + pouch_serial_device_init(serial_ready_cb); + + uart_irq_callback_user_data_set(uart_dev, uart_isr, NULL); + uart_irq_rx_enable(uart_dev); + + k_thread_create(&rx_thread, + rx_stack, + K_THREAD_STACK_SIZEOF(rx_stack), + rx_thread_fn, + NULL, + NULL, + NULL, + CONFIG_POUCH_SERIAL_UART_DEVICE_RX_THREAD_PRIORITY, + 0, + K_NO_WAIT); + k_thread_name_set(&rx_thread, "pouch_uart_rx"); + + LOG_DBG("UART device transport ready"); + return 0; +} + +SYS_INIT(pouch_uart_device_init, APPLICATION, CONFIG_APPLICATION_INIT_PRIORITY); diff --git a/port/zephyr/transport/serial/uart_framing.c b/port/zephyr/transport/serial/uart_framing.c new file mode 100644 index 00000000..cdcf349b --- /dev/null +++ b/port/zephyr/transport/serial/uart_framing.c @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026 Golioth, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "uart_framing.h" + +#include + +size_t pouch_uart_frame_encode(uint8_t *out, size_t out_size, const uint8_t *frame, size_t len) +{ + if (out == NULL || frame == NULL || len == 0 || len > UINT16_MAX) + { + return 0; + } + + if (out_size < len + POUCH_UART_FRAME_OVERHEAD) + { + return 0; + } + + out[0] = POUCH_UART_FRAME_SOF; + out[1] = (uint8_t) (len >> 8); + out[2] = (uint8_t) (len & 0xff); + memcpy(&out[POUCH_UART_FRAME_OVERHEAD], frame, len); + + return len + POUCH_UART_FRAME_OVERHEAD; +} + +void pouch_uart_framer_init(struct pouch_uart_framer *framer, uint8_t *buf, size_t buf_size) +{ + framer->state = POUCH_UART_FRAMER_SOF; + framer->buf = buf; + framer->buf_size = buf_size; + framer->expected = 0; + framer->got = 0; +} + +size_t pouch_uart_framer_feed(struct pouch_uart_framer *framer, uint8_t byte) +{ + switch (framer->state) + { + case POUCH_UART_FRAMER_SOF: + if (byte == POUCH_UART_FRAME_SOF) + { + framer->state = POUCH_UART_FRAMER_LEN_HI; + } + break; + + case POUCH_UART_FRAMER_LEN_HI: + framer->expected = (size_t) byte << 8; + framer->state = POUCH_UART_FRAMER_LEN_LO; + break; + + case POUCH_UART_FRAMER_LEN_LO: + framer->expected |= byte; + framer->got = 0; + + /* Reject zero-length and oversized frames, resynchronizing on the + * next SOF. */ + if (framer->expected == 0 || framer->expected > framer->buf_size) + { + framer->state = POUCH_UART_FRAMER_SOF; + } + else + { + framer->state = POUCH_UART_FRAMER_PAYLOAD; + } + break; + + case POUCH_UART_FRAMER_PAYLOAD: + framer->buf[framer->got++] = byte; + if (framer->got == framer->expected) + { + framer->state = POUCH_UART_FRAMER_SOF; + return framer->expected; + } + break; + } + + return 0; +} diff --git a/port/zephyr/transport/serial/uart_framing.h b/port/zephyr/transport/serial/uart_framing.h new file mode 100644 index 00000000..8428794b --- /dev/null +++ b/port/zephyr/transport/serial/uart_framing.h @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Golioth, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include + +/** + * @file uart_framing.h + * @brief Length-delimited framing for Pouch Serial frames over a byte stream. + * + * The Pouch Serial core produces and consumes whole frames (a 1-byte serial + * header plus payload), but a UART is a raw byte stream with no frame + * boundaries. This module wraps each serial frame as: + * + * [ SOF=0xA5 ][ len_hi ][ len_lo ][ frame bytes ... ] + * + * The encoder is a pure function; the decoder is a small byte-at-a-time state + * machine so it can be driven directly from a UART RX path. Both are free of + * Zephyr dependencies so they can be unit tested (and mirrored by a host-side + * broker) in isolation. + */ + +#define POUCH_UART_FRAME_SOF 0xA5u + +/** Bytes of framing overhead added around each serial frame (SOF + 2-byte length). */ +#define POUCH_UART_FRAME_OVERHEAD 3u + +/** + * Encode a serial frame into its on-wire framed representation. + * + * @param out Destination buffer. + * @param out_size Size of @p out. + * @param frame Serial frame bytes to encode. + * @param len Number of frame bytes (must be > 0 and fit in 16 bits). + * @return Number of bytes written to @p out (len + POUCH_UART_FRAME_OVERHEAD), + * or 0 if the inputs are invalid or @p out is too small. + */ +size_t pouch_uart_frame_encode(uint8_t *out, size_t out_size, const uint8_t *frame, size_t len); + +/** Decoder state. */ +enum pouch_uart_framer_state +{ + POUCH_UART_FRAMER_SOF, + POUCH_UART_FRAMER_LEN_HI, + POUCH_UART_FRAMER_LEN_LO, + POUCH_UART_FRAMER_PAYLOAD, +}; + +/** + * Streaming frame decoder. + * + * Assembles frames into a caller-provided buffer. A frame whose declared length + * exceeds the buffer is dropped and the decoder resynchronizes on the next SOF. + */ +struct pouch_uart_framer +{ + enum pouch_uart_framer_state state; + uint8_t *buf; + size_t buf_size; + size_t expected; + size_t got; +}; + +/** + * Initialize a decoder to assemble frames into @p buf. + * + * @param framer Decoder to initialize. + * @param buf Buffer that completed frames are assembled into. + * @param buf_size Size of @p buf; caps the largest decodable frame. + */ +void pouch_uart_framer_init(struct pouch_uart_framer *framer, uint8_t *buf, size_t buf_size); + +/** + * Feed one received byte into the decoder. + * + * @param framer Decoder. + * @param byte Received byte. + * @return The length of a completed frame now available in the decoder's + * buffer, or 0 if no frame completed on this byte. + */ +size_t pouch_uart_framer_feed(struct pouch_uart_framer *framer, uint8_t byte); diff --git a/tests/pouch/rpmsg/exchange/CMakeLists.txt b/tests/pouch/rpmsg/exchange/CMakeLists.txt new file mode 100644 index 00000000..07903b4d --- /dev/null +++ b/tests/pouch/rpmsg/exchange/CMakeLists.txt @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.20.0) + +find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) +project(rpmsg_exchange_test) + +set(POUCH_DIR ${CMAKE_CURRENT_LIST_DIR}/../../../..) + +# Golioth SDK path (needed by gateway/types.h included from broker.c) +set(GOLIOTH_SDK ${ZEPHYR_BASE}/../modules/lib/golioth-firmware-sdk) + +target_include_directories(app PRIVATE + ${POUCH_DIR}/include + ${POUCH_DIR}/port/include + ${POUCH_DIR}/port/zephyr/include + ${POUCH_DIR}/port/zephyr/transport/serial # uart_framing.h + ${POUCH_DIR}/src + ${GOLIOTH_SDK}/include + ${GOLIOTH_SDK}/port/zephyr/include + ${POUCH_DIR}/tests/pouch/serial/exchange/src # stub_endpoints.h +) + +# The serial transport log macro needs CONFIG_POUCH_SERIAL_LOG_LEVEL, normally +# provided by Kconfig; define it directly since we build serial sources in +# isolation. +target_compile_definitions(app PRIVATE CONFIG_POUCH_SERIAL_LOG_LEVEL=3) + +# Serial transport core + the UART framing under test, plus the shared stub +# endpoints from the serial exchange test. +target_sources(app PRIVATE + ${POUCH_DIR}/src/transport/serial/broker.c + ${POUCH_DIR}/src/transport/serial/device.c + ${POUCH_DIR}/src/transport/serial/serial.c + ${POUCH_DIR}/src/transport/serial/channel.c + ${POUCH_DIR}/src/transport/serial/packet.c + ${POUCH_DIR}/port/zephyr/transport/serial/uart_framing.c + ${POUCH_DIR}/tests/pouch/serial/exchange/src/stub_endpoints.c +) + +target_sources(app PRIVATE src/test_rpmsg_exchange.c) diff --git a/tests/pouch/rpmsg/exchange/prj.conf b/tests/pouch/rpmsg/exchange/prj.conf new file mode 100644 index 00000000..9467c292 --- /dev/null +++ b/tests/pouch/rpmsg/exchange/prj.conf @@ -0,0 +1 @@ +CONFIG_ZTEST=y diff --git a/tests/pouch/rpmsg/exchange/src/test_rpmsg_exchange.c b/tests/pouch/rpmsg/exchange/src/test_rpmsg_exchange.c new file mode 100644 index 00000000..85a871bd --- /dev/null +++ b/tests/pouch/rpmsg/exchange/src/test_rpmsg_exchange.c @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2026 Golioth, Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * rpmsg/UART framing tests. + * + * The rpmsg and UART device adapters carry Pouch Serial frames over a link + * using the length-delimited framing in uart_framing.[ch]. This suite tests + * that framing two ways: + * + * 1. Directly: encode/decode round-trips, resynchronization, and rejection + * of malformed frames. + * 2. End to end: a full broker <-> device serial exchange (the same one + * exercised by tests/pouch/serial/exchange, using stub endpoints so no + * gateway or cloud is required) where every frame on the wire is passed + * through encode + a byte-at-a-time decode. If the framing corrupted or + * dropped any frame, the exchange would not converge and the received + * payloads would not match. + */ + +#include "stub_endpoints.h" +#include "uart_framing.h" + +#include +#include +#include + +#include + +#include +#include + +#define MAX_FRAME_SIZE 64 +#define MAX_PUMP_ITER 200 + +static struct pouch_serial_broker *test_broker; +static bool broker_done; +static bool broker_success; + +static void broker_ready(const struct pouch_serial_broker *broker) +{ + (void) broker; +} + +static void broker_end(const struct pouch_serial_broker *broker, bool success) +{ + (void) broker; + broker_done = true; + broker_success = success; +} + +static const struct pouch_serial_broker_adapter adapter = { + .ready = broker_ready, + .end = broker_end, +}; + +static void device_ready(void) +{ + pouch_serial_broker_notify(test_broker); +} + +/* + * Pass a serial frame through the UART framing: encode it, then feed the + * encoded bytes one at a time into a fresh decoder. Asserts the decoder + * reproduces exactly the original frame, and returns it in @p out. + */ +static size_t roundtrip_frame(const uint8_t *frame, size_t len, uint8_t *out, size_t out_size) +{ + uint8_t wire[MAX_FRAME_SIZE + POUCH_UART_FRAME_OVERHEAD]; + + size_t wire_len = pouch_uart_frame_encode(wire, sizeof(wire), frame, len); + zassert_equal(wire_len, len + POUCH_UART_FRAME_OVERHEAD, "unexpected encoded length"); + + struct pouch_uart_framer framer; + pouch_uart_framer_init(&framer, out, out_size); + + size_t decoded = 0; + for (size_t i = 0; i < wire_len; i++) + { + decoded = pouch_uart_framer_feed(&framer, wire[i]); + } + + zassert_equal(decoded, len, "decoded length %zu != %zu", decoded, len); + zassert_mem_equal(out, frame, len, "framing corrupted the frame"); + return decoded; +} + +/* Pump frames between broker and device, routing each through the framing. */ +static int pump_framed(int max_iter) +{ + uint8_t buf[MAX_FRAME_SIZE]; + uint8_t decoded[MAX_FRAME_SIZE]; + int iter; + + for (iter = 0; iter < max_iter; iter++) + { + size_t blen = pouch_serial_broker_frame_get(test_broker, buf, sizeof(buf)); + if (blen > 0) + { + size_t dlen = roundtrip_frame(buf, blen, decoded, sizeof(decoded)); + zassert_ok(pouch_serial_device_recv(decoded, dlen), "device_recv failed"); + continue; + } + + size_t dlen0 = pouch_serial_device_frame_get(buf, sizeof(buf)); + if (dlen0 > 0) + { + size_t dlen = roundtrip_frame(buf, dlen0, decoded, sizeof(decoded)); + zassert_ok(pouch_serial_broker_recv(test_broker, decoded, dlen), "broker_recv failed"); + continue; + } + + break; + } + + zassert_true(iter < max_iter, "exchange did not converge within %d iterations", max_iter); + return iter; +} + +static void reset_parties(void) +{ + free(test_broker); + test_broker = pouch_serial_broker_create(&adapter); + zassert_not_null(test_broker, "broker_create returned NULL"); + + stubs_reset(); + pouch_serial_device_init(device_ready); + + broker_done = false; + broker_success = false; +} + +/* --- direct framing tests --- */ + +ZTEST(rpmsg_framing, test_roundtrip) +{ + const uint8_t frame[] = {0x81, 0x02, 0x03, 0x04, 0x05}; + uint8_t out[16]; + + size_t n = roundtrip_frame(frame, sizeof(frame), out, sizeof(out)); + zassert_equal(n, sizeof(frame), "length mismatch"); +} + +ZTEST(rpmsg_framing, test_resync_after_garbage) +{ + uint8_t out[16]; + struct pouch_uart_framer framer; + pouch_uart_framer_init(&framer, out, sizeof(out)); + + /* Garbage bytes before a frame must be ignored. */ + zassert_equal(pouch_uart_framer_feed(&framer, 0x11), 0, "garbage produced a frame"); + zassert_equal(pouch_uart_framer_feed(&framer, 0x22), 0, "garbage produced a frame"); + + const uint8_t wire[] = {POUCH_UART_FRAME_SOF, 0x00, 0x02, 0xAA, 0xBB}; + size_t got = 0; + for (size_t i = 0; i < sizeof(wire); i++) + { + got = pouch_uart_framer_feed(&framer, wire[i]); + } + + zassert_equal(got, 2, "frame not recovered after garbage"); + zassert_equal(out[0], 0xAA, "payload mismatch"); + zassert_equal(out[1], 0xBB, "payload mismatch"); +} + +ZTEST(rpmsg_framing, test_rejects_oversize) +{ + uint8_t out[4]; + struct pouch_uart_framer framer; + pouch_uart_framer_init(&framer, out, sizeof(out)); + + /* Declared length 5 exceeds the 4-byte buffer: the frame must be dropped + * and no spurious frame reported. */ + const uint8_t wire[] = {POUCH_UART_FRAME_SOF, 0x00, 0x05, 1, 2, 3, 4, 5}; + for (size_t i = 0; i < sizeof(wire); i++) + { + zassert_equal(pouch_uart_framer_feed(&framer, wire[i]), 0, "oversize frame not rejected"); + } +} + +ZTEST(rpmsg_framing, test_encode_rejects_bad_args) +{ + uint8_t out[8]; + const uint8_t frame[] = {1, 2, 3}; + + zassert_equal(pouch_uart_frame_encode(out, sizeof(out), frame, 0), 0, "zero length accepted"); + zassert_equal(pouch_uart_frame_encode(out, 4, frame, sizeof(frame)), 0, "overflow not caught"); +} + +ZTEST_SUITE(rpmsg_framing, NULL, NULL, NULL, NULL, NULL); + +/* --- end-to-end exchange through the framing --- */ + +static const uint8_t info_payload[] = "device-info-stub"; +static const uint8_t server_cert_payload[] = "server-certificate-data"; +static const uint8_t device_cert_payload[] = "device-certificate-data"; +static const uint8_t downlink_payload[] = "downlink-payload"; +static const uint8_t uplink_payload[] = "uplink-payload"; + +static void load_default_payloads(void) +{ + stub_sender_set_data(&device_stubs.info, info_payload, sizeof(info_payload)); + stub_sender_set_data(&broker_stubs.server_cert, + server_cert_payload, + sizeof(server_cert_payload)); + stub_sender_set_data(&device_stubs.device_cert, + device_cert_payload, + sizeof(device_cert_payload)); + stub_sender_set_data(&broker_stubs.downlink, downlink_payload, sizeof(downlink_payload)); + stub_sender_set_data(&device_stubs.uplink, uplink_payload, sizeof(uplink_payload)); +} + +ZTEST(rpmsg_exchange, test_exchange_survives_framing) +{ + reset_parties(); + load_default_payloads(); + + zassert_ok(pouch_serial_broker_start(test_broker), "broker_start failed"); + pump_framed(MAX_PUMP_ITER); + + zassert_true(broker_done, "exchange did not complete"); + zassert_true(broker_success, "exchange ended with failure"); + + /* Device -> broker payloads survived the framing round-trip. */ + zassert_equal(broker_stubs.uplink.rx_len, sizeof(uplink_payload), "uplink length"); + zassert_mem_equal(broker_stubs.uplink.rx_buf, + uplink_payload, + sizeof(uplink_payload), + "uplink data"); + zassert_equal(broker_stubs.info.rx_len, sizeof(info_payload), "info length"); + zassert_mem_equal(broker_stubs.info.rx_buf, info_payload, sizeof(info_payload), "info data"); + + /* Broker -> device payloads survived too. */ + zassert_equal(device_stubs.downlink.rx_len, sizeof(downlink_payload), "downlink length"); + zassert_mem_equal(device_stubs.downlink.rx_buf, + downlink_payload, + sizeof(downlink_payload), + "downlink data"); +} + +static void exchange_teardown(void *f) +{ + (void) f; + free(test_broker); + test_broker = NULL; +} + +ZTEST_SUITE(rpmsg_exchange, NULL, NULL, NULL, exchange_teardown, NULL); diff --git a/tests/pouch/rpmsg/exchange/testcase.yaml b/tests/pouch/rpmsg/exchange/testcase.yaml new file mode 100644 index 00000000..63f71ae0 --- /dev/null +++ b/tests/pouch/rpmsg/exchange/testcase.yaml @@ -0,0 +1,9 @@ +tests: + pouch.rpmsg.exchange: + platform_allow: + - native_sim + - native_sim/native/64 + integration_platforms: + - native_sim + - native_sim/native/64 + tags: test_framework diff --git a/west-zephyr.yml b/west-zephyr.yml index 37199dad..8fbf3209 100644 --- a/west-zephyr.yml +++ b/west-zephyr.yml @@ -16,6 +16,8 @@ manifest: - cmsis_6 - hal_nordic - hal_nxp + - libmetal + - open-amp - mbedtls - mcuboot - nrf_hw_models