From 2f5135d6553df6ed89c3e34c8b136fe24839fc31 Mon Sep 17 00:00:00 2001 From: Max Grzanna Date: Tue, 25 Jun 2024 15:00:07 +0200 Subject: [PATCH] Add example for interacting with the Kuksa Databroker with ESP32-based microcontrollers --- README.md | 2 + gRPC-on-ESP32/.devcontainer/Dockerfile | 51 ++ gRPC-on-ESP32/.devcontainer/devcontainer.json | 47 + gRPC-on-ESP32/.gitignore | 7 + gRPC-on-ESP32/CMakeLists.txt | 13 + gRPC-on-ESP32/NOTICE.md | 33 + gRPC-on-ESP32/README.md | 74 ++ gRPC-on-ESP32/assets/serial-output.png | Bin 0 -> 77669 bytes gRPC-on-ESP32/compile_proto.sh | 19 + gRPC-on-ESP32/install_nanopb.sh | 58 ++ gRPC-on-ESP32/main/CMakeLists.txt | 22 + gRPC-on-ESP32/main/decoder.c | 135 +++ gRPC-on-ESP32/main/decoder.h | 31 + gRPC-on-ESP32/main/encoder.c | 198 +++++ gRPC-on-ESP32/main/encoder.h | 45 + gRPC-on-ESP32/main/generated/.gitkeep | 0 gRPC-on-ESP32/main/grpc.c | 801 ++++++++++++++++++ gRPC-on-ESP32/main/grpc.h | 70 ++ gRPC-on-ESP32/main/idf_component.yml | 6 + gRPC-on-ESP32/main/main.c | 196 +++++ gRPC-on-ESP32/proto/types.proto | 288 +++++++ gRPC-on-ESP32/proto/val.proto | 115 +++ gRPC-on-ESP32/sdkconfig.ci | 11 + gRPC-on-ESP32/sdkconfig.defaults | 1 + 24 files changed, 2223 insertions(+) create mode 100644 gRPC-on-ESP32/.devcontainer/Dockerfile create mode 100644 gRPC-on-ESP32/.devcontainer/devcontainer.json create mode 100644 gRPC-on-ESP32/.gitignore create mode 100644 gRPC-on-ESP32/CMakeLists.txt create mode 100644 gRPC-on-ESP32/NOTICE.md create mode 100644 gRPC-on-ESP32/README.md create mode 100644 gRPC-on-ESP32/assets/serial-output.png create mode 100755 gRPC-on-ESP32/compile_proto.sh create mode 100755 gRPC-on-ESP32/install_nanopb.sh create mode 100644 gRPC-on-ESP32/main/CMakeLists.txt create mode 100644 gRPC-on-ESP32/main/decoder.c create mode 100644 gRPC-on-ESP32/main/decoder.h create mode 100644 gRPC-on-ESP32/main/encoder.c create mode 100644 gRPC-on-ESP32/main/encoder.h create mode 100644 gRPC-on-ESP32/main/generated/.gitkeep create mode 100644 gRPC-on-ESP32/main/grpc.c create mode 100644 gRPC-on-ESP32/main/grpc.h create mode 100644 gRPC-on-ESP32/main/idf_component.yml create mode 100644 gRPC-on-ESP32/main/main.c create mode 100644 gRPC-on-ESP32/proto/types.proto create mode 100644 gRPC-on-ESP32/proto/val.proto create mode 100644 gRPC-on-ESP32/sdkconfig.ci create mode 100644 gRPC-on-ESP32/sdkconfig.defaults diff --git a/README.md b/README.md index dd44329..cd64dd1 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,14 @@ Component | Content | Comment/Status [eCAL Provider](ecal2val) | Python provider for [eCAL](https://projects.eclipse.org/projects/automotive.ecal) [PS4/PS5 - 2021 Formula Provider](./fone2val) | F1 Telemetrydata source for [KUKSA Databroker](https://github.com/eclipse/kuksa.val/tree/master/kuksa_databroker) [KUKSA GO Client](kuksa_go_client) | Example client written in the [GO](https://go.dev/) programming language for easy interaction with KUKSA Databroker and Server +[ESP32 gRPC provider](gRPC-on-ESP32) | Example for interacting with the [KUKSA Databroker](https://github.com/eclipse/kuksa.val/tree/master/kuksa_databroker) with ESP32-based microcontrollers ## Contribution For contribution guidelines see [CONTRIBUTING.md](CONTRIBUTING.md) ## Pre-commit set up + This repository is set up to use [pre-commit](https://pre-commit.com/) hooks. Use `pip install pre-commit` to install pre-commit. After you clone the project, run `pre-commit install` to install pre-commit into your git hooks. diff --git a/gRPC-on-ESP32/.devcontainer/Dockerfile b/gRPC-on-ESP32/.devcontainer/Dockerfile new file mode 100644 index 0000000..dcf1bac --- /dev/null +++ b/gRPC-on-ESP32/.devcontainer/Dockerfile @@ -0,0 +1,51 @@ +FROM espressif/idf + +ARG DEBIAN_FRONTEND=nointeractive +ARG CONTAINER_USER=esp +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +RUN apt-get update \ + && apt install -y -q \ + cmake \ + git \ + hwdata \ + libglib2.0-0 \ + libnuma1 \ + libpixman-1-0 \ + linux-tools-virtual \ + && rm -rf /var/lib/apt/lists/* + +RUN update-alternatives --install /usr/local/bin/usbip usbip `ls /usr/lib/linux-tools/*/usbip | tail -n1` 20 + +# QEMU +ENV QEMU_REL=esp-develop-20220919 +ENV QEMU_SHA256=f6565d3f0d1e463a63a7f81aec94cce62df662bd42fc7606de4b4418ed55f870 +ENV QEMU_DIST=qemu-${QEMU_REL}.tar.bz2 +ENV QEMU_URL=https://github.com/espressif/qemu/releases/download/${QEMU_REL}/${QEMU_DIST} + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 + +RUN wget --no-verbose ${QEMU_URL} \ + && echo "${QEMU_SHA256} *${QEMU_DIST}" | sha256sum --check --strict - \ + && tar -xf $QEMU_DIST -C /opt \ + && rm ${QEMU_DIST} + +ENV PATH=/opt/qemu/bin:${PATH} + +RUN groupadd --gid $USER_GID $CONTAINER_USER \ + && adduser --uid $USER_UID --gid $USER_GID --disabled-password --gecos "" ${CONTAINER_USER} \ + && usermod -a -G root $CONTAINER_USER && usermod -a -G dialout $CONTAINER_USER + +RUN chmod -R 775 /opt/esp/python_env/ + +USER ${CONTAINER_USER} +ENV USER=${CONTAINER_USER} +WORKDIR /home/${CONTAINER_USER} + +RUN echo "source /opt/esp/idf/export.sh > /dev/null 2>&1" >> ~/.bashrc + +ENTRYPOINT [ "/opt/esp/entrypoint.sh" ] + +CMD ["/bin/bash", "-c"] diff --git a/gRPC-on-ESP32/.devcontainer/devcontainer.json b/gRPC-on-ESP32/.devcontainer/devcontainer.json new file mode 100644 index 0000000..41e381a --- /dev/null +++ b/gRPC-on-ESP32/.devcontainer/devcontainer.json @@ -0,0 +1,47 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.183.0/containers/ubuntu +{ + "name": "ESP-IDF QEMU", + "build": { + "dockerfile": "Dockerfile" + }, + // Add the IDs of extensions you want installed when the container is created + "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind", + /* the path of workspace folder to be opened after container is running + */ + "workspaceFolder": "${localWorkspaceFolder}", + "mounts": [ + "source=extensionCache,target=/root/.vscode-server/extensions,type=volume" + ], + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.defaultProfile.linux": "bash", + "idf.espIdfPath": "/opt/esp/idf", + "idf.customExtraPaths": "", + "idf.pythonBinPath": "/opt/esp/python_env/idf5.3_py3.10_env/bin/python", + "idf.toolsPath": "/opt/esp", + "idf.gitPath": "/usr/bin/git" + }, + "extensions": [ + "espressif.esp-idf-extension" + ], + }, + "codespaces": { + "settings": { + "terminal.integrated.defaultProfile.linux": "bash", + "idf.espIdfPath": "/opt/esp/idf", + "idf.customExtraPaths": "", + "idf.pythonBinPath": "/opt/esp/python_env/idf5.3_py3.10_env/bin/python", + "idf.toolsPath": "/opt/esp", + "idf.gitPath": "/usr/bin/git" + }, + "extensions": [ + "espressif.esp-idf-extension" + ], + } + }, + "runArgs": [ + "--privileged" + ] +} diff --git a/gRPC-on-ESP32/.gitignore b/gRPC-on-ESP32/.gitignore new file mode 100644 index 0000000..274ada5 --- /dev/null +++ b/gRPC-on-ESP32/.gitignore @@ -0,0 +1,7 @@ +.idea +.vscode +build/ +managed_components/ +cmake-build-debug/ +sdkconfig +components diff --git a/gRPC-on-ESP32/CMakeLists.txt b/gRPC-on-ESP32/CMakeLists.txt new file mode 100644 index 0000000..0a56c2e --- /dev/null +++ b/gRPC-on-ESP32/CMakeLists.txt @@ -0,0 +1,13 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.16) + +# Add the directory containing Nanopb to the list of extra component directories +# This assumes that `nanopb` is a directory within `components` +set(EXTRA_COMPONENT_DIRS + $ENV{IDF_PATH}/examples/common_components/protocol_examples_common + ${CMAKE_CURRENT_LIST_DIR}/components/nanopb +) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(gRPC-on-esp32) diff --git a/gRPC-on-ESP32/NOTICE.md b/gRPC-on-ESP32/NOTICE.md new file mode 100644 index 0000000..0b1a4eb --- /dev/null +++ b/gRPC-on-ESP32/NOTICE.md @@ -0,0 +1,33 @@ +# esp-grpc + +The file src/grpc.c and src/grpc.h are derived from [grpc.c](https://github.com/chrisomatic/esp-grpc/blob/main/main/grpc.c) +and [grpc.h](https://github.com/chrisomatic/esp-grpc/blob/main/main/grpc.h). + +Reference: + +The [esp-grpc](https://github.com/chrisomatic/esp-grpc/tree/5395918b5aa38314289b07a386601b7709c6c8e8) project is licensed under the following terms: + +``` +MIT License + +Copyright (c) 2022 Christopher Rose + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` diff --git a/gRPC-on-ESP32/README.md b/gRPC-on-ESP32/README.md new file mode 100644 index 0000000..1bda07f --- /dev/null +++ b/gRPC-on-ESP32/README.md @@ -0,0 +1,74 @@ +# gRPC on ESP32 + +This project offers a example on developing a gRPC client application designed to dispatch unary gRPC calls to a gRPC server, utilizing HTTP/2 as the underlying protocol. + +The project incorporates the following building blocks: + ++ [ESP-IDF](https://github.com/espressif/esp-idf) - ESP-32 Development Framework + ++ [nghttp2](https://nghttp2.org/) - integrated with [esp-idf extra components](https://components.espressif.com/components/espressif/nghttp) + ++ [nanopb](https://github.com/nanopb/nanopb) - compilation of protobufs, encoding/decoding of messages and responses into binary wire format + ++ [kuksa.val Databroker](https://github.com/eclipse/kuksa.val) - protobuf definitions for communicating via gRPC + +Note that this is a simplistic illustration of interacting with the Kuksa Databroker. For more detailed message exchanges, consider starting with `encoder.c` or `decoder.c`. These files contain the protobuf-specific implementations necessary for the exchange. See `grpc.c` for the HTTP/2 gRPC implementation. + +An application scenario would be conceivable in which the ESP32 represents a microcontroller that sends sensor values such as the current speed of the vehicle to the Kuksa Databroker via GRPC. + +## Hardware Required + ++ A development board with ESP32/ESP32-S2/ESP32-C3 SoC (e.g., ESP32-DevKitC, ESP-WROVER-KIT, etc.) ++ A USB cable for power supply and programming + +## Configure the project + +1. **Install the esp-idf toolchain** as shown in the esp-idf [docs](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/get-started/index.html#installation). + +```bash +idf.py menuconfig +``` + +2. **Incorporate nanopb specific header files** + +```bash +chmod +x install_nanopb.sh +./install_nanopb.sh +``` + +3. **Compile Protobufs** + ++ Activate the esp-idf development toolchain and environment with `. ./export.sh` inside your toolchain installation +directory (typically under `~/esp/...`) ++ execute shell script `compile_proto.sh` + +> 📝 Sometimes nanopb will give you a warning, that some dependencies are not installed. Install them with `pip install protobuf grpcio-tools` + +4. **Install expra components** +In the project root, execute the command below to add the nghttp2 lib to the project. + +```bash +idf.py add-dependency "espressif/nghttp^1.58.0" +``` + +5. **Open the project configuration menu** (`idf.py menuconfig`) to configure Wi-Fi or Ethernet. + +## Create Get/Set requests + +Refer to the `main.c` file to modify the message type and set the target URL with a kuksa Databroker running. To send a request for retrieving information, such as the vehicle's current speed, utilize the get section of the code, which is already uncommented. If you wish to set a value in the broker, uncomment the set section and employ the defined constants for _set_. + +## Build and Flash + +Build the project and flash it to the board, then run monitor tool to view serial output: + +```bash +idf.py flash monitor +``` + +(To exit the serial monitor, type ``Ctrl-]`` or ``Ctrl-TX`` on Mac.) + +See the Getting Started Guide for full steps to configure and use ESP-IDF to build projects. + +## Example Output + +![Serial Output](assets/serial-output.png) diff --git a/gRPC-on-ESP32/assets/serial-output.png b/gRPC-on-ESP32/assets/serial-output.png new file mode 100644 index 0000000000000000000000000000000000000000..30413986bad82d45a81ce1735db2de9e9257981d GIT binary patch literal 77669 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 "$CMAKELISTS" + +echo "CMakeLists.txt has been updated." + +echo "Installation of nanopb is complete." diff --git a/gRPC-on-ESP32/main/CMakeLists.txt b/gRPC-on-ESP32/main/CMakeLists.txt new file mode 100644 index 0000000..6a9b6f9 --- /dev/null +++ b/gRPC-on-ESP32/main/CMakeLists.txt @@ -0,0 +1,22 @@ +set ( + srcs + "main.c" + "grpc.c" + "encoder.c" + "decoder.c" + "generated/val.pb.c" + "generated/types.pb.c" +) + +set( + include_dirs + "." + "generated/" +) + +idf_component_register( + SRCS "${srcs}" + INCLUDE_DIRS "${include_dirs}"#EMBED_TXTFILES ../certs/server_cert.pem +) + +target_compile_options(${COMPONENT_LIB} PRIVATE "-Wno-format") diff --git a/gRPC-on-ESP32/main/decoder.c b/gRPC-on-ESP32/main/decoder.c new file mode 100644 index 0000000..5ff4d55 --- /dev/null +++ b/gRPC-on-ESP32/main/decoder.c @@ -0,0 +1,135 @@ +// ################################################################################# +// # Copyright (c) 2024 Contributors to the Eclipse Foundation +// # +// # See the NOTICE file(s) distributed with this work for additional +// # information regarding copyright ownership. +// # +// # This program and the accompanying materials are made available under the +// # terms of the Apache License 2.0 which is available at +// # http://www.apache.org/licenses/LICENSE-2.0 +// # +// # SPDX-License-Identifier: Apache-2.0 +// ################################################################################# + +#include +#include +#include +#include +#include +#include "generated/val.pb.h" +#include "generated/types.pb.h" +#include "esp_log.h" + +static const char *TAG = "DECODER"; + +void print_datapoint(const kuksa_val_v1_Datapoint *datapoint) +{ + + switch (datapoint->which_value) + { + case 11: + printf("String value: %s\n", (char *)(datapoint->value.string.arg)); + break; + case 12: + printf("Boolean value: %s\n", datapoint->value._bool ? "true" : "false"); + break; + case 13: + printf("Int32 value: %d\n", datapoint->value.int32); + break; + case 14: + printf("Int64 value: %lld\n", datapoint->value.int64); + break; + case 15: + printf("Uint32 value: %u\n", datapoint->value.uint32); + break; + case 16: + printf("Uint64 value: %llu\n", datapoint->value.uint64); + break; + case 17: + printf("Float value: %f\n", datapoint->value._float); + break; + case 18: // double + printf("Double value: %f\n", datapoint->value._double); + break; + // TODO: implement functions to print the array data format + default: + printf("Unknown or uninitialized value type.\n"); + break; + } +} + +// Callback for encoding/decoding a dynamically allocated string +bool print_string(pb_istream_t *stream, const pb_field_t *field, void **arg) +{ + uint8_t buffer[1024] = {0}; + + /* We could read block-by-block to avoid the large buffer... */ + if (stream->bytes_left > sizeof(buffer) - 1) + return false; + + if (!pb_read(stream, buffer, stream->bytes_left)) + return false; + + printf((char *)*arg, buffer); + return true; +} + +bool decode_DataEntry(pb_istream_t *stream, const pb_field_t *fields, void **arg) +{ + if (arg == NULL) + { + ESP_LOGE(TAG, "Error: 'arg' pointer is NULL."); + return false; + } + + kuksa_val_v1_DataEntry *entry = *arg; + + if (entry == NULL) + { + ESP_LOGE(TAG, "Error: 'entry' pointer is NULL."); + return false; + } + + ESP_LOGI(TAG, "decoding data entry"); + if (!pb_decode(stream, kuksa_val_v1_DataEntry_fields, entry)) + { + ESP_LOGE(TAG, "Failed to decode entry."); + return false; + } + + if (entry->has_value) + { + print_datapoint(&entry->value); + } + + return true; +} + +bool decode_GetResponse(const uint8_t *buffer, size_t buffer_len) +{ + pb_istream_t stream = pb_istream_from_buffer(buffer, buffer_len); + kuksa_val_v1_GetResponse response = kuksa_val_v1_GetResponse_init_zero; + kuksa_val_v1_DataEntry entry = {}; + + entry.path.funcs.decode = &print_string; + entry.path.arg = "Path: \"%s\" \n"; + + // Set up a decode callback for the repeated DataEntry field + response.entries.funcs.decode = &decode_DataEntry; + response.entries.arg = &entry; + + // Decode the message + if (!pb_decode(&stream, kuksa_val_v1_GetResponse_fields, &response)) + { + ESP_LOGE(TAG, "Failed to decode GetResponse"); + + return false; + } + + if (response.has_error) + { + ESP_LOGE(TAG, "Global Error Code: %u, Reason: %s", response.error.code, response.error.reason); + } + + return true; +} diff --git a/gRPC-on-ESP32/main/decoder.h b/gRPC-on-ESP32/main/decoder.h new file mode 100644 index 0000000..585d3b8 --- /dev/null +++ b/gRPC-on-ESP32/main/decoder.h @@ -0,0 +1,31 @@ +// ################################################################################# +// # Copyright (c) 2024 Contributors to the Eclipse Foundation +// # +// # See the NOTICE file(s) distributed with this work for additional +// # information regarding copyright ownership. +// # +// # This program and the accompanying materials are made available under the +// # terms of the Apache License 2.0 which is available at +// # http://www.apache.org/licenses/LICENSE-2.0 +// # +// # SPDX-License-Identifier: Apache-2.0 +// ################################################################################# + +#pragma once + +#include +#include +#include +#include +#include "generated/val.pb.h" +#include "generated/types.pb.h" + +bool decode_GetResponse(const uint8_t *buffer, size_t buffer_len); + +void decode_DataEntry(pb_istream_t *stream, const pb_field_t fields[], void *const *arg); + +void decode_Datapoint(pb_istream_t *stream, const pb_field_t fields[], void *const *arg); + +void decode_Timestamp(pb_istream_t *stream, const pb_field_t fields[], void *const *arg); + +void handle_GetResponse_Error(pb_istream_t *stream, const pb_field_t fields[], void *const *arg); diff --git a/gRPC-on-ESP32/main/encoder.c b/gRPC-on-ESP32/main/encoder.c new file mode 100644 index 0000000..94bfa7c --- /dev/null +++ b/gRPC-on-ESP32/main/encoder.c @@ -0,0 +1,198 @@ +// ################################################################################# +// # Copyright (c) 2024 Contributors to the Eclipse Foundation +// # +// # See the NOTICE file(s) distributed with this work for additional +// # information regarding copyright ownership. +// # +// # This program and the accompanying materials are made available under the +// # terms of the Apache License 2.0 which is available at +// # http://www.apache.org/licenses/LICENSE-2.0 +// # +// # SPDX-License-Identifier: Apache-2.0 +// ################################################################################# + +#include +#include "esp_log.h" +#include +#include +#include "generated/val.pb.h" +#include "generated/types.pb.h" +#include "encoder.h" + +static const char *TAG = "ENCODER"; + +void log_buffer_content(const uint8_t *buffer, size_t length) +{ + printf("Encoded Buffer: "); + for (size_t i = 0; i < length; i++) + { + printf("%02X ", buffer[i]); + } + printf("\n"); +} + +bool get_server_info(uint8_t *buffer, size_t buffer_size, size_t *message_length) +{ + bool status; + + kuksa_val_v1_GetServerInfoRequest get_server_info_request = kuksa_val_v1_GetServerInfoRequest_init_zero; + + pb_ostream_t stream = pb_ostream_from_buffer(buffer, buffer_size); + + status = pb_encode(&stream, kuksa_val_v1_GetServerInfoRequest_fields, &get_server_info_request); + *message_length = stream.bytes_written; + + if (!status) + { + printf("Encoding failed: %s\n", PB_GET_ERROR(&stream)); + return false; + } + + return status; +} + +bool encode_fields_array(pb_ostream_t *stream, const pb_field_t *field, void *const *arg) +{ + uint32_t *fields = (uint32_t *)*arg; + size_t count = 1; // Change this as necessary to match the number of fields you are encoding + + for (size_t i = 0; i < count; i++) + { + if (!pb_encode_tag_for_field(stream, field)) + { + return false; + } + if (!pb_encode_varint(stream, fields[i])) + { + return false; + } + } + return true; +} + +bool encode_entries_callback(pb_ostream_t *stream, const pb_field_t *field, void *const *arg) +{ + kuksa_val_v1_EntryRequest *request = (kuksa_val_v1_EntryRequest *)*arg; + if (!pb_encode_tag_for_field(stream, field)) + return false; + if (!pb_encode_submessage(stream, kuksa_val_v1_EntryRequest_fields, request)) + return false; + return true; +} + +bool encode_entry_request_callback(pb_ostream_t *stream, const pb_field_t *field, void *const *arg) +{ + kuksa_val_v1_EntryRequest *entry_requests = *arg; + int num_entries = 1; // Example number of entries + + for (int i = 0; i < num_entries; i++) + { + if (!pb_encode_tag_for_field(stream, field)) + return false; + if (!pb_encode_submessage(stream, kuksa_val_v1_EntryRequest_fields, &entry_requests[i])) + return false; + } + return true; +} + +bool callback_string_encoder(pb_ostream_t *stream, const pb_field_t *field, void *const *arg) +{ + const char *str = (char *)*arg; + if (!pb_encode_tag_for_field(stream, field)) + { + return false; + } + return pb_encode_string(stream, (const uint8_t *)str, strlen(str)); +} + +kuksa_val_v1_EntryRequest init_entry_request(EntryRequest *req) +{ + kuksa_val_v1_EntryRequest request = kuksa_val_v1_EntryRequest_init_zero; + + // Properly initialize 'path' + request.path.funcs.encode = callback_string_encoder; + request.path.arg = strdup(req->path); // Allocate and set path + if (!request.path.arg) + { + ESP_LOGE(TAG, "Failed to allocate memory for path"); + abort(); + } + + request.view = req->view; + + // TODO: Set up fields values + + return request; +} + +// Set requests +// ------------------------------------------------------------------------------------------ + +bool encode_updates_array(pb_ostream_t *stream, const pb_field_t *field, void *const *arg) +{ + kuksa_val_v1_EntryUpdate *updates = (kuksa_val_v1_EntryUpdate *)*arg; + for (size_t i = 0; i < 1; i++) + { // Change 1 to the number of updates if dynamic + if (!pb_encode_tag_for_field(stream, field)) + { + return false; + } + if (!pb_encode_submessage(stream, kuksa_val_v1_EntryUpdate_fields, &updates[i])) + { + return false; + } + } + return true; +} + +kuksa_val_v1_Datapoint create_datapoint(float value) +{ + kuksa_val_v1_Datapoint datapoint = kuksa_val_v1_Datapoint_init_zero; + datapoint.which_value = kuksa_val_v1_Datapoint__float_tag; + datapoint.value._float = value; + return datapoint; +} + +kuksa_val_v1_DataEntry create_data_entry(const char *path, kuksa_val_v1_Datapoint datapoint) +{ + kuksa_val_v1_DataEntry data_entry = kuksa_val_v1_DataEntry_init_zero; + data_entry.path.funcs.encode = callback_string_encoder; + data_entry.path.arg = (void *)path; + data_entry.has_value = true; + data_entry.value = datapoint; + return data_entry; +} + +kuksa_val_v1_EntryUpdate create_entry_update(kuksa_val_v1_DataEntry data_entry) +{ + kuksa_val_v1_EntryUpdate entry_update = kuksa_val_v1_EntryUpdate_init_zero; + entry_update.has_entry = true; + entry_update.entry = data_entry; + + static uint32_t fields_array[] = {2}; // Assuming FIELD_VALUE is '2' -> This translates to current_value + entry_update.fields.funcs.encode = encode_fields_array; + entry_update.fields.arg = fields_array; + + return entry_update; +} + +kuksa_val_v1_SetRequest create_set_request(kuksa_val_v1_EntryUpdate *updates) +{ + kuksa_val_v1_SetRequest set_request = kuksa_val_v1_SetRequest_init_zero; + set_request.updates.funcs.encode = encode_updates_array; + set_request.updates.arg = updates; + return set_request; +} + +bool encode_set_request(const kuksa_val_v1_SetRequest *set_request, uint8_t *buffer, size_t buffer_size, size_t *message_length) +{ + pb_ostream_t stream = pb_ostream_from_buffer(buffer, buffer_size); + bool status = pb_encode(&stream, kuksa_val_v1_SetRequest_fields, set_request); + if (!status) + { + printf("Encoding failed: %s\n", PB_GET_ERROR(&stream)); + return false; + } + *message_length = stream.bytes_written; + return true; +} diff --git a/gRPC-on-ESP32/main/encoder.h b/gRPC-on-ESP32/main/encoder.h new file mode 100644 index 0000000..92f1c6f --- /dev/null +++ b/gRPC-on-ESP32/main/encoder.h @@ -0,0 +1,45 @@ +// ################################################################################# +// # Copyright (c) 2024 Contributors to the Eclipse Foundation +// # +// # See the NOTICE file(s) distributed with this work for additional +// # information regarding copyright ownership. +// # +// # This program and the accompanying materials are made available under the +// # terms of the Apache License 2.0 which is available at +// # http://www.apache.org/licenses/LICENSE-2.0 +// # +// # SPDX-License-Identifier: Apache-2.0 +// ################################################################################# + +#pragma once + +#include +#include +#include +#include +#include +#include "generated/val.pb.h" +#include "generated/types.pb.h" + +typedef struct +{ + char *path; + kuksa_val_v1_View view; + // kuksa_val_v1_Field *field; +} EntryRequest; + +void log_buffer_content(const uint8_t *buffer, size_t length); +bool encode_string(pb_ostream_t *stream, const pb_field_t *field, void *const *arg); +bool get_server_info(uint8_t *buffer, size_t buffer_size, size_t *message_length); +bool kuksa_get_request(uint8_t *buffer, size_t buffer_size, size_t *message_length); +bool should_serialize_field(const kuksa_val_v1_EntryRequest *request, kuksa_val_v1_Field field); +bool encode_get_request(kuksa_val_v1_GetRequest *request, uint8_t *buffer, size_t buffer_size, size_t *message_length); +kuksa_val_v1_EntryRequest init_entry_request(EntryRequest *req); +bool callback_string_encoder(pb_ostream_t *stream, const pb_field_t *field, void *const *arg); +bool encode_entries_callback(pb_ostream_t *stream, const pb_field_t *field, void *const *arg); + +kuksa_val_v1_Datapoint create_datapoint(float value); +kuksa_val_v1_DataEntry create_data_entry(const char *path, kuksa_val_v1_Datapoint datapoint); +kuksa_val_v1_EntryUpdate create_entry_update(kuksa_val_v1_DataEntry data_entry); +kuksa_val_v1_SetRequest create_set_request(kuksa_val_v1_EntryUpdate *updates); +bool encode_set_request(const kuksa_val_v1_SetRequest *set_request, uint8_t *buffer, size_t buffer_size, size_t *message_length); diff --git a/gRPC-on-ESP32/main/generated/.gitkeep b/gRPC-on-ESP32/main/generated/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/gRPC-on-ESP32/main/grpc.c b/gRPC-on-ESP32/main/grpc.c new file mode 100644 index 0000000..bd44e24 --- /dev/null +++ b/gRPC-on-ESP32/main/grpc.c @@ -0,0 +1,801 @@ +// ################################################################################# +// # Copyright (c) 2024 Contributors to the Eclipse Foundation +// # +// # See the NOTICE file(s) distributed with this work for additional +// # information regarding copyright ownership. +// # +// # This program and the accompanying materials are made available under the +// # terms of the Apache License 2.0 which is available at +// # http://www.apache.org/licenses/LICENSE-2.0 +// # +// # SPDX-License-Identifier: Apache-2.0 +// ################################################################################# + +#include "grpc.h" +#include +#include "esp_random.h" +#include "esp_tls.h" +#include +#include +#include +#include "esp_log.h" + +#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE +#include "esp_crt_bundle.h" +#endif + +// ==================================================================== +// DEFINES & MACROS +// ==================================================================== +static const char *TAG = "GRPC"; +#define GRPC_DEBUG 1 + +#define MAKE_NV(NAME, VALUE) \ + { \ + (uint8_t *)NAME, (uint8_t *)VALUE, strlen(NAME), strlen(VALUE), \ + NGHTTP2_NV_FLAG_NONE \ + } + +/** Flag indicating receive stream is reset */ +#define DATA_RECV_RST_STREAM 1 +/** Flag indicating frame is completely received */ +#define DATA_RECV_FRAME_COMPLETE 2 + +#define MESSAGE_PREFIX_SIZE 5 +#define MAX_GRPC_BUFFER_SIZE 1024 + +// ==================================================================== +// TYPES +// ==================================================================== + +typedef int (*_frame_data_recv_cb_t)(const char *data, size_t len, int flags); +typedef int (*_putpost_data_cb_t)(char *data, size_t len, uint32_t *data_flags); + +typedef struct +{ + nghttp2_session *http2_sess; /*!< Pointer to the HTTP2 session handle */ + char *hostname; /*!< The hostname we are connected to */ + struct esp_tls *http2_tls; /*!< Pointer to the TLS session handle */ +} grpc_handle_t; + +typedef struct +{ + uint8_t buf[MAX_GRPC_BUFFER_SIZE]; + size_t len; +} GRPCBuffer; + +// ==================================================================== +// STATIC VARS +// ==================================================================== + +static TaskHandle_t http2_task_handle = NULL; +static TaskHandle_t grpc_task_handle = NULL; + +static grpc_conn_data_t conn_data = {0}; +static grpc_init_t cfg = {0}; +static grpc_handle_t hd = {0}; + +static struct +{ + bool initialized : 1; + bool conn_configured : 1; + + bool trigger_connect : 1; // also SHOULD connect + bool connected : 1; + + bool message_pending : 1; + bool ping_pending : 1; + +} bools; + +static int64_t ping_recv_time = 0; // recv time +static uint8_t ping_data[8] = {0}; + +static GRPCBuffer buffer_recv; +static GRPCBuffer buffer_send; + +// ==================================================================== +// STATIC PROTOS +// ==================================================================== + +static bool grpc_task_start_connection(); +static void grpc_task(); + +static void handle_disconnect(); +static void http2_task(); +static bool http2_execute(); + +static bool _connect(); + +static void free_handle(); +static int send_post_data(char *buf, size_t length, uint32_t *data_flags); +static ssize_t _data_provider_cb(nghttp2_session *session, int32_t stream_id, uint8_t *buf, size_t length, uint32_t *data_flags, nghttp2_data_source *source, void *user_data); + +static ssize_t callback_send(nghttp2_session *session, const uint8_t *data, size_t length, int flags, void *user_data); +static ssize_t callback_recv(nghttp2_session *session, uint8_t *buf, size_t length, int flags, void *user_data); +static int callback_on_frame_send(nghttp2_session *session, const nghttp2_frame *frame, void *user_data); +static int callback_on_frame_recv(nghttp2_session *session, const nghttp2_frame *frame, void *user_data); +static int callback_on_stream_close(nghttp2_session *session, int32_t stream_id, uint32_t error_code, void *user_data); +static int callback_on_data_chunk_recv(nghttp2_session *session, uint8_t flags, int32_t stream_id, const uint8_t *data, size_t len, void *user_data); +static int callback_on_header(nghttp2_session *session, const nghttp2_frame *frame, const uint8_t *name, size_t namelen, const uint8_t *value, size_t valuelen, uint8_t flags, void *user_data); + +static const char *frame_type_to_str(int type); + +// ==================================================================== +// GLOBAL FUNCTIONS +// ==================================================================== + +bool grpc_init(grpc_init_t config) +{ + + int ret = xTaskCreatePinnedToCore(http2_task, "http2_task", config.http2_stack_size, NULL, config.http2_prio, &http2_task_handle, config.http2_core); + ESP_LOGI(TAG, "%d", ret); + if (ret != pdPASS) + { + ESP_LOGE(TAG, "Failed to create http2 task, ret: %d", ret); + return false; + } + + ret = xTaskCreatePinnedToCore(grpc_task, "grpc_task", config.grpc_stack_size, NULL, config.grpc_prio, &grpc_task_handle, config.grpc_core); + if (ret != pdPASS) + { + ESP_LOGE(TAG, "Failed to create GRPC task, ret: %d", ret); + return false; + } + ESP_LOGI(TAG, "%d", ret); + + memcpy(&cfg, &config, sizeof(grpc_init_t)); + vTaskSuspend(http2_task_handle); + bools.initialized = true; + return true; +} + +bool grpc_configure_connection(grpc_conn_data_t connection_data) +{ + if (!bools.initialized) + return false; + if (bools.conn_configured) + return false; + + memcpy(&conn_data, &connection_data, sizeof(grpc_conn_data_t)); + bools.conn_configured = true; + return true; +} + +bool grpc_connect() +{ + if (!bools.initialized) + return false; + if (!bools.conn_configured) + return false; + if (bools.connected) + return true; + + bools.trigger_connect = true; + return true; +} + +bool grpc_connected() +{ + return bools.connected; +} + +bool grpc_wait_for_connection(int timeout_ms) +{ + for (;;) + { + if (bools.connected) + return true; + if (timeout_ms <= 0) + return bools.connected; + timeout_ms -= 10; + vTaskDelay(10); + } +} + +bool gprc_send_message_pending() +{ + return bools.message_pending; +} + +bool grpc_ping(int timeout_ms, int64_t *_ping_time) +{ + if (!bools.conn_configured) + return false; + if (hd.http2_sess == NULL) + return false; + + esp_fill_random(ping_data, 8); + bools.ping_pending = true; + ESP_LOGI(TAG, "Ping data:"); + + int64_t sent_time = esp_timer_get_time(); + int ret = nghttp2_submit_ping(hd.http2_sess, NGHTTP2_FLAG_NONE, (const uint8_t *)ping_data); + if (ret != 0) + { + ESP_LOGE(TAG, "Submit ping failed, ret: %d (%s)", ret, nghttp2_strerror(ret)); + bools.ping_pending = false; + return false; + } + + for (;;) + { + if (!bools.ping_pending) + break; + if (timeout_ms < 0) + break; + timeout_ms -= 10; + vTaskDelay(10); + } + + if (!bools.ping_pending) + { + if (_ping_time != NULL) + { + *_ping_time = ping_recv_time - sent_time; + } + return true; + } + + return false; +} + +bool grpc_call_proc(char *path, char *proc, uint8_t *data, uint32_t len) +{ + if (hd.http2_sess == NULL) + return false; + if (!bools.connected) + return false; + + int max_len = (MAX_GRPC_BUFFER_SIZE - MESSAGE_PREFIX_SIZE); + if (len >= max_len) + { + ESP_LOGE(TAG, "Specified Length of %d exceeds the maximum allowed data size of %d", len, max_len); + return false; + } + + // printf("bools.message_pending is %s\n", BOOLSTR(bools.message_pending)); + for (;;) + { + // wait for prior message to complete + if (!bools.message_pending) + break; + + vTaskDelay(10); + } + // printf("bools.message_pending = true\n"); + bools.message_pending = true; + + memcpy(buffer_send.buf + MESSAGE_PREFIX_SIZE, data, len); + buffer_send.len = len; + + // Necessary for gRPC Message for DATA frame + // https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md + uint8_t *ml = (uint8_t *)&buffer_send.len; + buffer_send.buf[0] = 0x0; // no compression + buffer_send.buf[1] = *(ml + 3); // big-endian + buffer_send.buf[2] = *(ml + 2); + buffer_send.buf[3] = *(ml + 1); + buffer_send.buf[4] = *(ml + 0); + + char content_length[8 + 1] = {0}; + snprintf(content_length, 8, "%d", len + MESSAGE_PREFIX_SIZE); + + char full_path[128 + 1] = {0}; + snprintf(full_path, 128, "%s/%s", path, proc); + + const nghttp2_nv nva[] = { + MAKE_NV(":method", "POST"), + MAKE_NV(":scheme", "https"), + MAKE_NV(":authority", hd.hostname), + MAKE_NV(":path", full_path), + MAKE_NV("content-type", "application/grpc+proto"), + MAKE_NV("content-length", content_length), + }; + + nghttp2_data_provider dp; + dp.read_callback = _data_provider_cb; + dp.source.ptr = send_post_data; + + int ret = nghttp2_submit_request(hd.http2_sess, NULL, nva, sizeof(nva) / sizeof(nva[0]), &dp, NULL); + + if (ret < 0) + { + ESP_LOGE(TAG, "Submit Request failed: %d", ret); + bools.message_pending = false; + return false; + } + + return true; +} + +const char *grpc_status_code_to_str(GRPCStatusCode sc) +{ + static const char *sc_str[] = {"OK", "CANCELLED", "UNKNOWN", "INVALID_ARGUMENT", "DEADLINE_EXCEEDED", "NOT_FOUND", "ALREADY_EXISTS", "PERMISSION_DENIED", "RESOURCE_EXHAUSTED", "FAILED_PRECONDITION", "ABORTED", "OUT_OF_RANGE", "UNIMPLEMENTED", "INTERNAL", "UNAVAILABLE", "DATA_LOSS", "UNAUTHENTICATED"}; + int num_sc = sizeof(sc_str) / sizeof(sc_str[0]); + if (sc < 0 || sc >= num_sc) + return "UNKNOWN"; + return sc_str[sc]; +} + +// ==================================================================== +// STATIC FUNCTIONS +// ==================================================================== + +static bool grpc_task_start_connection() +{ + if (!bools.conn_configured) + { + return false; + } + + if (bools.connected) + { + ESP_LOGW(TAG, "you shouldn't be here"); + return false; + } + + // if(!wifi_get_internet_status()) + // { + // return false; + // } + + if (bools.trigger_connect) + { + bool success = _connect(); + return success; + } + + return false; +} + +static void grpc_task() +{ + ESP_LOGI(TAG, "Entered GRPC task"); + + for (;;) + { + bool connected = grpc_task_start_connection(); + + if (!connected) + { + vTaskDelay(1000); + continue; + } + + for (;;) + { + + if (!bools.connected) + break; + if (hd.http2_sess == NULL) + break; + + // TODO: process data + + vTaskDelay(100); + } + + vTaskSuspend(http2_task_handle); + } + + vTaskDelete(NULL); +} + +static void handle_disconnect() +{ + + if (bools.connected) + { + ESP_LOGW(TAG, "DISCONNECTED"); + } + + bools.connected = false; + free_handle(); // not safe here +} + +static void http2_task() +{ + ESP_LOGI(TAG, "Entered http2 task"); + + for (;;) + { + if (hd.http2_sess != NULL) + { + if (!http2_execute()) + { + ESP_LOGE(TAG, "Error in send/receive"); + handle_disconnect(); + } + vTaskDelay(10); + } + else + { + vTaskDelay(100); + } + } + + vTaskDelete(NULL); +} + +static bool http2_execute() +{ + if (hd.http2_sess == NULL) + return false; + int ret = nghttp2_session_send(hd.http2_sess); + if (ret != 0) + { + ESP_LOGE(TAG, "[sh2-execute] HTTP2 session send failed, ret: %d (%s)", ret, nghttp2_strerror(ret)); + return false; + } + + if (hd.http2_sess == NULL) + return false; + ret = nghttp2_session_recv(hd.http2_sess); + if (ret != 0) + { + ESP_LOGE(TAG, "[sh2-execute] HTTP2 session recv failed, ret: %d (%s)", ret, nghttp2_strerror(ret)); + return false; + } + + return true; +} + +static bool _connect() +{ + bools.connected = false; + free_handle(); + memset(&hd, 0, sizeof(grpc_handle_t)); + + const char *proto[] = {"h2", NULL}; + esp_tls_cfg_t tls_cfg = {0}; + tls_cfg.alpn_protos = proto; + // we will include the default cert bundle included by esp-tls + tls_cfg.crt_bundle_attach = esp_crt_bundle_attach; + tls_cfg.non_block = true; + tls_cfg.timeout_ms = 10 * 1000; + + if ((hd.http2_tls = esp_tls_conn_http_new(conn_data.uri, &tls_cfg)) == NULL) + { + ESP_LOGE(TAG, "Failed to initialize TLS."); + goto error; + } + + struct http_parser_url u; + http_parser_url_init(&u); + http_parser_parse_url(conn_data.uri, strlen(conn_data.uri), 0, &u); + hd.hostname = strndup(&conn_data.uri[u.field_data[UF_HOST].off], u.field_data[UF_HOST].len); + + ESP_LOGI(TAG, "Setting up HTTP2 connection with uri: %s", conn_data.uri); + nghttp2_session_callbacks *callbacks; + nghttp2_session_callbacks_new(&callbacks); + nghttp2_session_callbacks_set_send_callback(callbacks, callback_send); + nghttp2_session_callbacks_set_recv_callback(callbacks, callback_recv); + nghttp2_session_callbacks_set_on_frame_send_callback(callbacks, callback_on_frame_send); + nghttp2_session_callbacks_set_on_frame_recv_callback(callbacks, callback_on_frame_recv); + nghttp2_session_callbacks_set_on_stream_close_callback(callbacks, callback_on_stream_close); + nghttp2_session_callbacks_set_on_data_chunk_recv_callback(callbacks, callback_on_data_chunk_recv); + nghttp2_session_callbacks_set_on_header_callback(callbacks, callback_on_header); + + int ret = nghttp2_session_client_new(&hd.http2_sess, callbacks, &hd); + if (ret != 0) + { + ESP_LOGE(TAG, "New http2 session failed, ret: %d (%s)", ret, nghttp2_strerror(ret)); + nghttp2_session_callbacks_del(callbacks); + goto error; + } + nghttp2_session_callbacks_del(callbacks); + + /* Create the SETTINGS frame */ + ret = nghttp2_submit_settings(hd.http2_sess, NGHTTP2_FLAG_NONE, NULL, 0); + if (ret != 0) + { + ESP_LOGE(TAG, "New http2 session failed, ret: %d (%s)", ret, nghttp2_strerror(ret)); + goto error; + } + + vTaskResume(http2_task_handle); + + int timeout_ms = 10000; + for (;;) + { + if (bools.connected) + return true; + + // if(!wifi_get_internet_status()) + // { + // ESP_LOGW("Lost internet connection"); + // goto error; + // } + + vTaskDelay(10); + timeout_ms -= 10; + if (timeout_ms <= 0) + { + goto error; + } + } + + return true; + +error: + free_handle(); + vTaskSuspend(http2_task_handle); + return false; +} + +static void free_handle() +{ + ESP_LOGI(TAG, "Freeing handle"); + + if (hd.http2_sess) + { + nghttp2_session_del(hd.http2_sess); + hd.http2_sess = NULL; + } + + if (hd.http2_tls) + { + // esp_tls_conn_delete(hd.http2_tls); + hd.http2_tls = NULL; + } + + if (hd.hostname) + { + free(hd.hostname); + hd.hostname = NULL; + } +} + +static ssize_t _data_provider_cb(nghttp2_session *session, int32_t stream_id, uint8_t *buf, size_t length, uint32_t *data_flags, nghttp2_data_source *source, void *user_data) +{ + _putpost_data_cb_t data_cb = source->ptr; + return (*data_cb)((char *)buf, length, data_flags); +} + +// callback for sending data +static int send_post_data(char *buf, size_t length, uint32_t *data_flags) +{ + int copylen = buffer_send.len + MESSAGE_PREFIX_SIZE; + if (copylen < length) + { + ESP_LOGI(TAG, "[SEND] Sending %d bytes", copylen); + memcpy(buf, buffer_send.buf, buffer_send.len + MESSAGE_PREFIX_SIZE); + } + else + { + copylen = 0; + } + + // printf("bools.message_pending = false\n"); + bools.message_pending = false; + + (*data_flags) |= NGHTTP2_DATA_FLAG_EOF; + return copylen; +} + +static ssize_t callback_send(nghttp2_session *session, const uint8_t *data, size_t length, int flags, void *user_data) +{ + int copy_offset = 0; + int pending_data = length; + int rv = 0; + + /* Send data in 1000 byte chunks */ + while (copy_offset != length) + { + int chunk_len = pending_data > 1000 ? 1000 : pending_data; + + int subrv = esp_tls_conn_write(hd.http2_tls, data + copy_offset, chunk_len); + + if (subrv <= 0) + { + if (subrv == ESP_TLS_ERR_SSL_WANT_READ || subrv == ESP_TLS_ERR_SSL_WANT_WRITE) + { + subrv = NGHTTP2_ERR_WOULDBLOCK; + } + else + { + subrv = NGHTTP2_ERR_CALLBACK_FAILURE; + } + } + + if (subrv <= 0) + { + if (copy_offset == 0) + { + /* If no data is transferred, send the error code */ + rv = subrv; + } + break; + } + copy_offset += subrv; + pending_data -= subrv; + rv += subrv; + } + return rv; +} + +static ssize_t callback_recv(nghttp2_session *session, uint8_t *buf, size_t length, int flags, void *user_data) +{ + int rv = esp_tls_conn_read(hd.http2_tls, (char *)buf, (int)length); + if (rv < 0) + { + if (rv == ESP_TLS_ERR_SSL_WANT_READ || rv == ESP_TLS_ERR_SSL_WANT_WRITE) + { + rv = NGHTTP2_ERR_WOULDBLOCK; + } + else + { + rv = NGHTTP2_ERR_CALLBACK_FAILURE; + } + } + else if (rv == 0) + { + rv = NGHTTP2_ERR_EOF; + } + return rv; +} + +static int callback_on_frame_send(nghttp2_session *session, const nghttp2_frame *frame, void *user_data) +{ + ESP_LOGD(TAG, "[frame-send] frame type %s", frame_type_to_str(frame->hd.type)); + switch (frame->hd.type) + { + case NGHTTP2_HEADERS: + { + if (nghttp2_session_get_stream_user_data(session, frame->hd.stream_id)) + { + ESP_LOGD(TAG, "[frame-send] C ----------------------------> S (HEADERS)"); + ESP_LOGD(TAG, "[frame-send] headers nv-len = %d", frame->headers.nvlen); + const nghttp2_nv *nva = frame->headers.nva; + for (size_t i = 0; i < frame->headers.nvlen; ++i) + { + ESP_LOGD(TAG, "[frame-send] %s : %s", nva[i].name, nva[i].value); + } + } + } + break; + + case NGHTTP2_PING: + { + } + break; + } + return 0; +} + +static int callback_on_frame_recv(nghttp2_session *session, const nghttp2_frame *frame, void *user_data) +{ + int64_t t = esp_timer_get_time(); + + ESP_LOGD(TAG, "[frame-recv][sid: %d] frame type: %d (%s)", frame->hd.stream_id, frame->hd.type, frame_type_to_str(frame->hd.type)); + + switch (frame->hd.type) + { + case NGHTTP2_SETTINGS: + { + // const nghttp2_settings* p = &frame->settings; + if (!bools.connected) + { + ESP_LOGI(TAG, "CONNECTED"); + bools.connected = true; + } + } + break; + + case NGHTTP2_GOAWAY: + { + // const nghttp2_goaway* p = &frame->goaway; + handle_disconnect(); + } + break; + + case NGHTTP2_PING: + { + const nghttp2_ping *p = &frame->ping; + + if (bools.ping_pending) + { + // LOGI_HEX(p->opaque_data, 8); + bool match = memcmp(p->opaque_data, ping_data, 8) == 0; + if (match) + { + bools.ping_pending = false; + ping_recv_time = t; + } + } + } + break; + + case NGHTTP2_WINDOW_UPDATE: + { + const nghttp2_window_update *w = &frame->window_update; + + ESP_LOGI(TAG, "[Window Size] %d", w->window_size_increment); + } + break; + + default: + break; + } + + return 0; +} + +static int callback_on_stream_close(nghttp2_session *session, int32_t stream_id, uint32_t error_code, void *user_data) +{ + ESP_LOGD(TAG, "[stream-close][sid %d]", stream_id); + ESP_LOGI(TAG, "[RECV] Stream Closed"); + return 0; +} + +static int callback_on_data_chunk_recv(nghttp2_session *session, uint8_t flags, int32_t stream_id, const uint8_t *data, size_t len, void *user_data) +{ + ESP_LOGD(TAG, "[data-chunk-recv][sid:%d] %lu bytes", stream_id, (unsigned long int)len); + + if (len) + { + + buffer_recv.len = len - MESSAGE_PREFIX_SIZE; + memcpy(buffer_recv.buf, data + MESSAGE_PREFIX_SIZE, buffer_recv.len); + + ESP_LOGI(TAG, "Received data chunk with following lenght: %d", buffer_recv.len); + ESP_LOGI(TAG, "-----------------------------------------"); + + for (size_t i = 0; i < buffer_recv.len; ++i) + { + printf("%02X ", buffer_recv.buf[i]); + } + printf("\n"); + ESP_LOGI(TAG, "-----------------------------------------"); + } + return 0; +} + +static int callback_on_header(nghttp2_session *session, const nghttp2_frame *frame, const uint8_t *name, size_t namelen, const uint8_t *value, size_t valuelen, uint8_t flags, void *user_data) +{ + ESP_LOGD(TAG, "[hdr-recv][sid:%d] %s : %s", frame->hd.stream_id, name, value); + + if (strcmp((char *)name, "grpc-status") == 0) + { + int sc = atoi((const char *)value); + ESP_LOGI(TAG, "[hdr-recv] GRPC Status: %s (%d)", grpc_status_code_to_str((GRPCStatusCode)sc), sc); + } + + return 0; +} + +static const char *frame_type_to_str(int type) +{ + switch (type) + { + case NGHTTP2_HEADERS: + return "HEADERS"; + case NGHTTP2_RST_STREAM: + return "RST_STREAM"; + case NGHTTP2_GOAWAY: + return "GOAWAY"; + case NGHTTP2_DATA: + return "DATA"; + case NGHTTP2_SETTINGS: + return "SETTINGS"; + case NGHTTP2_PUSH_PROMISE: + return "PUSH_PROMISE"; + case NGHTTP2_PING: + return "PING"; + case NGHTTP2_WINDOW_UPDATE: + return "WINDOW_UPDATE"; + default: + return "other"; + } +} + +uint8_t *grpc_get_buffer_data() +{ + return buffer_recv.buf; +} + +size_t grpc_get_buffer_length() +{ + return buffer_recv.len; +} diff --git a/gRPC-on-ESP32/main/grpc.h b/gRPC-on-ESP32/main/grpc.h new file mode 100644 index 0000000..b0b67e8 --- /dev/null +++ b/gRPC-on-ESP32/main/grpc.h @@ -0,0 +1,70 @@ +// ################################################################################# +// # Copyright (c) 2024 Contributors to the Eclipse Foundation +// # +// # See the NOTICE file(s) distributed with this work for additional +// # information regarding copyright ownership. +// # +// # This program and the accompanying materials are made available under the +// # terms of the Apache License 2.0 which is available at +// # http://www.apache.org/licenses/LICENSE-2.0 +// # +// # SPDX-License-Identifier: Apache-2.0 +// ################################################################################# + +#pragma once +#include +#include +#include + +typedef struct +{ + int grpc_core; + int grpc_stack_size; + int grpc_prio; + + int http2_core; + int http2_stack_size; + int http2_prio; +} grpc_init_t; + +typedef struct +{ + const char *ca; + const char *uri; +} grpc_conn_data_t; + +// https://grpc.github.io/grpc/core/md_doc_statuscodes.html +typedef enum +{ + GRPC_SC_OK, + GRPC_SC_CANCELLED, + GRPC_SC_UNKNOWN, + GRPC_SC_INVALID_ARGUMENT, + GRPC_SC_DEADLINE_EXCEEDED, + GRPC_SC_NOT_FOUND, + GRPC_SC_ALREADY_EXISTS, + GRPC_SC_PERMISSION_DENIED, + GRPC_SC_RESOURCE_EXHAUSTED, + GRPC_SC_FAILED_PRECONDITION, + GRPC_SC_ABORTED, + GRPC_SC_OUT_OF_RANGE, + GRPC_SC_UNIMPLEMENTED, + GRPC_SC_INTERNAL, + GRPC_SC_UNAVAILABLE, + GRPC_SC_DATA_LOSS, + GRPC_SC_UNAUTHENTICATED +} GRPCStatusCode; + +bool grpc_init(grpc_init_t config); +bool grpc_configure_connection(grpc_conn_data_t connection_data); + +bool grpc_connect(); +bool grpc_connected(); +bool grpc_wait_for_connection(int timeout_ms); +bool gprc_send_message_pending(); +bool grpc_call_proc(char *path, char *proc, uint8_t *data, uint32_t len); +bool grpc_ping(int timeout_ms, int64_t *_ping_time); +const char *grpc_status_code_to_str(GRPCStatusCode sc); + +uint8_t *grpc_get_buffer_data(void); +size_t grpc_get_buffer_length(void); diff --git a/gRPC-on-ESP32/main/idf_component.yml b/gRPC-on-ESP32/main/idf_component.yml new file mode 100644 index 0000000..3bc0fbf --- /dev/null +++ b/gRPC-on-ESP32/main/idf_component.yml @@ -0,0 +1,6 @@ +dependencies: + espressif/nghttp: "^1.58.0" + espressif/sh2lib: + version: ^1.0.0 +description: HTTP2 Request Examples +version: 1.0.0 diff --git a/gRPC-on-ESP32/main/main.c b/gRPC-on-ESP32/main/main.c new file mode 100644 index 0000000..a763d44 --- /dev/null +++ b/gRPC-on-ESP32/main/main.c @@ -0,0 +1,196 @@ +// ################################################################################# +// # Copyright (c) 2024 Contributors to the Eclipse Foundation +// # +// # See the NOTICE file(s) distributed with this work for additional +// # information regarding copyright ownership. +// # +// # This program and the accompanying materials are made available under the +// # terms of the Apache License 2.0 which is available at +// # http://www.apache.org/licenses/LICENSE-2.0 +// # +// # SPDX-License-Identifier: Apache-2.0 +// ################################################################################# + +#include "common.h" +#include "grpc.h" +#include "nvs_flash.h" +#include "protocol_examples_common.h" +#include "esp_netif.h" +#include "esp_wifi.h" +#include "esp_event.h" +#include +#include +#include +#include "generated/val.pb.h" +#include "generated/types.pb.h" +#include "esp_log.h" +#include "encoder.h" +#include "decoder.h" + +// ------------------------------------------------------------------------------------------ +static const char *TAG = "MAIN"; +bool session_test = false; +const char *grpc_uri = "https://"; + +// ------------------------------------------------------------------------------------------ + +// Get Reqeust +// ------------------ +#define MESSAGEPB_PATH "/kuksa.val.v1.VAL" +#define MESSAGEPB_REQUEST "Get" +// ------------------ + +// Set Reqeust +// ------------------ +// #define MESSAGEPB_PATH "/kuksa.val.v1.VAL" +// #define MESSAGEPB_REQUEST "Set" +// ------------------ + +void app_main() +{ + + ESP_ERROR_CHECK(nvs_flash_init()); + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + + /* This helper function configures Wi-Fi or Ethernet, as selected in menuconfig. + * Read "Establishing Wi-Fi or Ethernet Connection" section in + * examples/protocols/README.md for more information about this function. + */ + ESP_ERROR_CHECK(example_connect()); + + ESP_LOGI(TAG, "Initializing the gRPC connection..."); + + grpc_init_t grpc_cfg = { + .grpc_core = 1, + .grpc_stack_size = 8000, + .grpc_prio = 10, + .http2_core = 1, + .http2_stack_size = 22000, + .http2_prio = 11, + }; + + grpc_init(grpc_cfg); + ESP_LOGI(TAG, "completed the configuration"); + + grpc_conn_data_t grpc_dat = { + .ca = "", + .uri = grpc_uri, + }; + + ESP_LOGD(TAG, "conn data: %s", grpc_dat.uri); + + grpc_configure_connection(grpc_dat); + + grpc_connect(); + + // @TEST: GRPC + for (;;) + { + static bool pinged = false; + static bool conn_prior = false; + + bool conn = grpc_connected(); + + if (conn && !conn_prior) + { + pinged = true; + session_test = false; + } + + if (conn) + { + if (!pinged) + { + int64_t rtt = 0; + bool ret = grpc_ping(1000, &rtt); + if (ret) + { + pinged = true; + int rtt_ms = rtt / 1000; + ESP_LOGI(TAG, "ping time: %d", rtt_ms); + } + } + + if (!session_test) + { + + // ------------------------------------------------------------------------------------------------------- + // See get request example below: + // ------------------------------------------------------------------------------------------------------- + + uint8_t buffer[256]; + size_t message_length; + + EntryRequest message = { + .path = "Vehicle.Speed", + .view = kuksa_val_v1_View_VIEW_CURRENT_VALUE, + }; + + kuksa_val_v1_EntryRequest entry_request = init_entry_request(&message); + + kuksa_val_v1_GetRequest get_request; + get_request.entries.funcs.encode = encode_entries_callback; + get_request.entries.arg = &entry_request; + + pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer)); + + // Encode the GetRequest + if (!pb_encode(&stream, kuksa_val_v1_GetRequest_fields, &get_request)) + { + ESP_LOGE(TAG, "Encoding failed: %s", PB_GET_ERROR(&stream)); + free(entry_request.path.arg); + return; + } + + message_length = stream.bytes_written; + ESP_LOGI(TAG, "Encoded GetRequest with length %zu bytes\n", message_length); + + free(entry_request.path.arg); + + grpc_call_proc(MESSAGEPB_PATH, MESSAGEPB_REQUEST, buffer, message_length); + + vTaskDelay(2000); + + uint8_t *resp_buf = grpc_get_buffer_data(); // Buffer for response + size_t resp_buf_len = grpc_get_buffer_length(); // Populate with actual response length + + if (decode_GetResponse(resp_buf, resp_buf_len)) + { + ESP_LOGI(TAG, "Decoding was successful"); + } + else + { + ESP_LOGE(TAG, "Decoding failed"); + } + + // ------------------------------------------------------------------------------------------------------- + // See set request example below: + // ------------------------------------------------------------------------------------------------------- + + // uint8_t buffer[128]; // Buffer to hold the encoded data + // size_t message_length; // Variable to store the message length after encoding + // kuksa_val_v1_Datapoint datapoint = create_datapoint(62.0); + // kuksa_val_v1_DataEntry data_entry = create_data_entry("Vehicle.Speed", datapoint); + // kuksa_val_v1_EntryUpdate entry_update = create_entry_update(data_entry); + // kuksa_val_v1_EntryUpdate updates[] = {entry_update}; + + // kuksa_val_v1_SetRequest set_request = create_set_request(updates); + + // if (encode_set_request(&set_request, buffer, sizeof(buffer), &message_length)) { + // printf("SetRequest encoded successfully, length = %zu\n", message_length); + // } + + // log_buffer_content(buffer, message_length); + + // grpc_call_proc(MESSAGEPB_PATH,MESSAGEPB_REQUEST, buffer, message_length); + + session_test = true; + conn_prior = conn; + } + } + + conn_prior = conn; + vTaskDelay(100); + } +} diff --git a/gRPC-on-ESP32/proto/types.proto b/gRPC-on-ESP32/proto/types.proto new file mode 100644 index 0000000..26122b5 --- /dev/null +++ b/gRPC-on-ESP32/proto/types.proto @@ -0,0 +1,288 @@ +/******************************************************************************** + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License 2.0 which is available at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +syntax = "proto3"; + +// I added V1 as in databroker. Is this good practice? +package kuksa.val.v1; +import "google/protobuf/timestamp.proto"; + +option go_package = "kuksa/val/v1"; + +// Describes a VSS entry +// When requesting an entry, the amount of information returned can +// be controlled by specifying either a `View` or a set of `Field`s. +message DataEntry { + // Defines the full VSS path of the entry. + string path = 1; // [field: FIELD_PATH] + + // The value (datapoint) + Datapoint value = 2; // [field: FIELD_VALUE] + + // Actuator target (only used if the entry is an actuator) + Datapoint actuator_target = 3; // [field: FIELD_ACTUATOR_TARGET] + + // Metadata for this entry + Metadata metadata = 10; // [field: FIELD_METADATA] +} + +message Datapoint { + google.protobuf.Timestamp timestamp = 1; + + oneof value { + string string = 11; + bool _bool = 12; + sint32 int32 = 13; + sint64 int64 = 14; + uint32 uint32 = 15; + uint64 uint64 = 16; + float _float = 17; + double _double = 18; + StringArray string_array = 21; + BoolArray bool_array = 22; + Int32Array int32_array = 23; + Int64Array int64_array = 24; + Uint32Array uint32_array = 25; + Uint64Array uint64_array = 26; + FloatArray float_array = 27; + DoubleArray double_array = 28; + } +} + +message Metadata { + // Data type + // The VSS data type of the entry (i.e. the value, min, max etc). + // + // NOTE: protobuf doesn't have int8, int16, uint8 or uint16 which means + // that these values must be serialized as int32 and uint32 respectively. + DataType data_type = 11; // [field: FIELD_METADATA_DATA_TYPE] + + // Entry type + EntryType entry_type = 12; // [field: FIELD_METADATA_ENTRY_TYPE] + + // Description + // Describes the meaning and content of the entry. + optional string description = 13; // [field: FIELD_METADATA_DESCRIPTION] + + // Comment [optional] + // A comment can be used to provide additional informal information + // on a entry. + optional string comment = 14; // [field: FIELD_METADATA_COMMENT] + + // Deprecation [optional] + // Whether this entry is deprecated. Can contain recommendations of what + // to use instead. + optional string deprecation = 15; // [field: FIELD_METADATA_DEPRECATION] + + // Unit [optional] + // The unit of measurement + optional string unit = 16; // [field: FIELD_METADATA_UNIT] + + // Value restrictions [optional] + // Restrict which values are allowed. + // Only restrictions matching the DataType {datatype} above are valid. + ValueRestriction value_restriction = 17; // [field: FIELD_METADATA_VALUE_RESTRICTION] + + // Entry type specific metadata + oneof entry_specific { + Actuator actuator = 20; // [field: FIELD_METADATA_ACTUATOR] + Sensor sensor = 30; // [field: FIELD_METADATA_SENSOR] + Attribute attribute = 40; // [field: FIELD_METADATA_ATTRIBUTE] + } +} + +/////////////////////// +// Actuator specific fields +message Actuator { + // Nothing for now +} + +//////////////////////// +// Sensor specific +message Sensor { + // Nothing for now +} + +//////////////////////// +// Attribute specific +message Attribute { + // Nothing for now. +} + +// Value restriction +// +// One ValueRestriction{type} for each type, since +// they don't make sense unless the types match +// +message ValueRestriction { + oneof type { + ValueRestrictionString string = 21; + // For signed VSS integers + ValueRestrictionInt _signed = 22; + // For unsigned VSS integers + ValueRestrictionUint _unsigned = 23; + // For floating point VSS values (float and double) + ValueRestrictionFloat floating_point = 24; + } +} + +message ValueRestrictionInt { + optional sint64 min = 1; + optional sint64 max = 2; + repeated sint64 allowed_values = 3; +} + +message ValueRestrictionUint { + optional uint64 min = 1; + optional uint64 max = 2; + repeated uint64 allowed_values = 3; +} + +message ValueRestrictionFloat { + optional double min = 1; + optional double max = 2; + + // allowed for doubles/floats not recommended + repeated double allowed_values = 3; +} + +// min, max doesn't make much sense for a string +message ValueRestrictionString { + repeated string allowed_values = 3; +} + +// VSS Data type of a signal +// +// Protobuf doesn't support int8, int16, uint8 or uint16. +// These are mapped to int32 and uint32 respectively. +// +enum DataType { + DATA_TYPE_UNSPECIFIED = 0; + DATA_TYPE_STRING = 1; + DATA_TYPE_BOOLEAN = 2; + DATA_TYPE_INT8 = 3; + DATA_TYPE_INT16 = 4; + DATA_TYPE_INT32 = 5; + DATA_TYPE_INT64 = 6; + DATA_TYPE_UINT8 = 7; + DATA_TYPE_UINT16 = 8; + DATA_TYPE_UINT32 = 9; + DATA_TYPE_UINT64 = 10; + DATA_TYPE_FLOAT = 11; + DATA_TYPE_DOUBLE = 12; + DATA_TYPE_TIMESTAMP = 13; + DATA_TYPE_STRING_ARRAY = 20; + DATA_TYPE_BOOLEAN_ARRAY = 21; + DATA_TYPE_INT8_ARRAY = 22; + DATA_TYPE_INT16_ARRAY = 23; + DATA_TYPE_INT32_ARRAY = 24; + DATA_TYPE_INT64_ARRAY = 25; + DATA_TYPE_UINT8_ARRAY = 26; + DATA_TYPE_UINT16_ARRAY = 27; + DATA_TYPE_UINT32_ARRAY = 28; + DATA_TYPE_UINT64_ARRAY = 29; + DATA_TYPE_FLOAT_ARRAY = 30; + DATA_TYPE_DOUBLE_ARRAY = 31; + DATA_TYPE_TIMESTAMP_ARRAY = 32; +} + +// Entry type +enum EntryType { + ENTRY_TYPE_UNSPECIFIED = 0; + ENTRY_TYPE_ATTRIBUTE = 1; + ENTRY_TYPE_SENSOR = 2; + ENTRY_TYPE_ACTUATOR = 3; +} + +// A `View` specifies a set of fields which should +// be populated in a `DataEntry` (in a response message) +enum View { + VIEW_UNSPECIFIED = 0; // Unspecified. Equivalent to VIEW_CURRENT_VALUE unless `fields` are explicitly set. + VIEW_CURRENT_VALUE = 1; // Populate DataEntry with value. + VIEW_TARGET_VALUE = 2; // Populate DataEntry with actuator target. + VIEW_METADATA = 3; // Populate DataEntry with metadata. + VIEW_FIELDS = 10; // Populate DataEntry only with requested fields. + VIEW_ALL = 20; // Populate DataEntry with everything. +} + +// A `Field` corresponds to a specific field of a `DataEntry`. +// +// It can be used to: +// * populate only specific fields of a `DataEntry` response. +// * specify which fields of a `DataEntry` should be set as +// part of a `Set` request. +// * subscribe to only specific fields of a data entry. +// * convey which fields of an updated `DataEntry` have changed. +enum Field { + FIELD_UNSPECIFIED = 0; // "*" i.e. everything + FIELD_PATH = 1; // path + FIELD_VALUE = 2; // value + FIELD_ACTUATOR_TARGET = 3; // actuator_target + FIELD_METADATA = 10; // metadata.* + FIELD_METADATA_DATA_TYPE = 11; // metadata.data_type + FIELD_METADATA_DESCRIPTION = 12; // metadata.description + FIELD_METADATA_ENTRY_TYPE = 13; // metadata.entry_type + FIELD_METADATA_COMMENT = 14; // metadata.comment + FIELD_METADATA_DEPRECATION = 15; // metadata.deprecation + FIELD_METADATA_UNIT = 16; // metadata.unit + FIELD_METADATA_VALUE_RESTRICTION = 17; // metadata.value_restriction.* + FIELD_METADATA_ACTUATOR = 20; // metadata.actuator.* + FIELD_METADATA_SENSOR = 30; // metadata.sensor.* + FIELD_METADATA_ATTRIBUTE = 40; // metadata.attribute.* +} + +// Error response shall be an HTTP-like code. +// Should follow https://www.w3.org/TR/viss2-transport/#status-codes. +message Error { + uint32 code = 1; + string reason = 2; + string message = 3; +} + +// Used in get/set requests to report errors for specific entries +message DataEntryError { + string path = 1; // vss path + Error error = 2; +} + +message StringArray { + repeated string values = 1; +} + +message BoolArray { + repeated bool values = 1; +} + +message Int32Array { + repeated sint32 values = 1; +} + +message Int64Array { + repeated sint64 values = 1; +} + +message Uint32Array { + repeated uint32 values = 1; +} + +message Uint64Array { + repeated uint64 values = 1; +} + +message FloatArray { + repeated float values = 1; +} + +message DoubleArray { + repeated double values = 1; +} diff --git a/gRPC-on-ESP32/proto/val.proto b/gRPC-on-ESP32/proto/val.proto new file mode 100644 index 0000000..c3818b0 --- /dev/null +++ b/gRPC-on-ESP32/proto/val.proto @@ -0,0 +1,115 @@ +/******************************************************************************** + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License 2.0 which is available at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +syntax = "proto3"; + +package kuksa.val.v1; + +option go_package = "kuksa/val/v1"; + +import "types.proto"; + +// Note on authorization: +// Tokens (auth-token or auth-uuid) are sent as (GRPC / http2) metadata. +// +// The auth-token is a JWT compliant token as the examples found here: +// https://github.com/eclipse/kuksa.val/tree/master/kuksa_certificates/jwt +// +// See also https://github.com/eclipse/kuksa.val/blob/master/doc/jwt.md +// +// Upon reception of auth-token, server shall generate an auth-uuid in metadata +// that the client can use instead of auth-token in subsequent calls. + +service VAL { + // Get entries + rpc Get(GetRequest) returns (GetResponse); + + // Set entries + rpc Set(SetRequest) returns (SetResponse); + + // Subscribe to a set of entries + // + // Returns a stream of notifications. + // + // InvalidArgument is returned if the request is malformed. + rpc Subscribe(SubscribeRequest) returns (stream SubscribeResponse); + + // Shall return information that allows the client to determine + // what server/server implementation/version it is talking to + // eg. kuksa-databroker 0.5.1 + rpc GetServerInfo(GetServerInfoRequest) returns (GetServerInfoResponse); +} + +// Define which data we want +message EntryRequest { + string path = 1; + View view = 2; + repeated Field fields = 3; +} + +// Request a set of entries. +message GetRequest { + repeated EntryRequest entries = 1; +} + +// Global errors are specified in `error`. +// Errors for individual entries are specified in `errors`. +message GetResponse { + repeated DataEntry entries = 1; + repeated DataEntryError errors = 2; + Error error = 3; +} + +// Define the data we want to set +message EntryUpdate { + DataEntry entry = 1; + repeated Field fields = 2; +} + +// A list of entries to be updated +message SetRequest { + repeated EntryUpdate updates = 1; +} + +// Global errors are specified in `error`. +// Errors for individual entries are specified in `errors`. +message SetResponse { + Error error = 1; + repeated DataEntryError errors = 2; +} + +// Define what to subscribe to +message SubscribeEntry { + string path = 1; + View view = 2; + repeated Field fields = 3; +} + +// Subscribe to changes in datapoints. +message SubscribeRequest { + repeated SubscribeEntry entries = 1; +} + +// A subscription response +message SubscribeResponse { + repeated EntryUpdate updates = 1; +} + +message GetServerInfoRequest { + // Nothing yet +} + +message GetServerInfoResponse { + string name = 1; + string version = 2; +} diff --git a/gRPC-on-ESP32/sdkconfig.ci b/gRPC-on-ESP32/sdkconfig.ci new file mode 100644 index 0000000..81b8c59 --- /dev/null +++ b/gRPC-on-ESP32/sdkconfig.ci @@ -0,0 +1,11 @@ +CONFIG_SPIRAM=y +CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y +CONFIG_EXAMPLE_CONNECT_ETHERNET=y +CONFIG_EXAMPLE_CONNECT_WIFI=n +CONFIG_EXAMPLE_USE_INTERNAL_ETHERNET=y +CONFIG_EXAMPLE_ETH_PHY_IP101=y +CONFIG_EXAMPLE_ETH_MDC_GPIO=23 +CONFIG_EXAMPLE_ETH_MDIO_GPIO=18 +CONFIG_EXAMPLE_ETH_PHY_RST_GPIO=5 +CONFIG_EXAMPLE_ETH_PHY_ADDR=1 +CONFIG_EXAMPLE_CONNECT_IPV6=y diff --git a/gRPC-on-ESP32/sdkconfig.defaults b/gRPC-on-ESP32/sdkconfig.defaults new file mode 100644 index 0000000..998be29 --- /dev/null +++ b/gRPC-on-ESP32/sdkconfig.defaults @@ -0,0 +1 @@ +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN=y