diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 62f751f92..9cf4d2829 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -105,6 +105,8 @@ jobs: target: esp32s3 - path: 'components/gfps_service/example' target: esp32s3 + - path: 'components/gps/example' + target: esp32s3 - path: 'components/gt911/example' target: esp32s3 - path: 'components/hid-rp/example' @@ -165,6 +167,8 @@ jobs: target: esp32s3 - path: 'components/mcp23x17/example' target: esp32s3 + - path: 'components/meshtastic/example' + target: esp32s3 - path: 'components/mt6701/example' target: esp32s3 - path: 'components/neopixel/example' @@ -219,6 +223,8 @@ jobs: target: esp32s3 - path: 'components/state_machine/example' target: esp32 + - path: 'components/sx126x/example' + target: esp32s3 - path: 'components/t-deck/example' target: esp32s3 - path: 'components/t-dongle-s3/example' diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index c6f873681..5aa3ec492 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -74,6 +74,7 @@ jobs: components/ft5x06 components/ftp components/gfps_service + components/gps components/gt911 components/hid_service components/hid-rp @@ -97,6 +98,7 @@ jobs: components/matouch-rotary-display components/max1704x components/mcp23x17 + components/meshtastic components/monitor components/motorgo-axis components/motorgo-mini @@ -128,6 +130,7 @@ jobs: components/st25dv components/st7123touch components/state_machine + components/sx126x components/t_keyboard components/t-deck components/t-dongle-s3 diff --git a/components/gps/CMakeLists.txt b/components/gps/CMakeLists.txt new file mode 100755 index 000000000..ffaaa2c4e --- /dev/null +++ b/components/gps/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES "base_component" "task" "driver" "esp_driver_uart" + ) diff --git a/components/gps/README.md b/components/gps/README.md new file mode 100755 index 000000000..96c593f5b --- /dev/null +++ b/components/gps/README.md @@ -0,0 +1,25 @@ +# GPS Component + +[![Badge](https://components.espressif.com/components/espp/gps/badge.svg)](https://components.espressif.com/components/espp/gps) + +The `Gps` component provides a driver for UART-attached GNSS receivers which +output standard NMEA-0183 sentences, such as the ATGM336H (found on the +M5Stack Cardputer-Adv LoRa+GPS Cap) and the u-blox MIA-M10Q (found on the +LilyGo T-Deck Plus). + +It contains two classes: + +- `espp::NmeaParser`: a platform-independent, incremental NMEA-0183 parser + (RMC / GGA, any talker id) with checksum validation, which maintains a + `espp::GpsFix` (position, altitude, speed, course, satellites, hdop, UTC + date/time with unix-timestamp conversion). +- `espp::Gps`: a UART-attached wrapper which reads the NMEA stream in a + background task and provides the latest fix thread-safely (or via + callback). It also supports writing raw configuration commands to the + receiver. + +## Example + +The [example](./example) shows how to use the `espp::Gps` component to parse +GNSS data, configurable (via menuconfig) for the M5Stack Cardputer-Adv +LoRa+GPS Cap, the LilyGo T-Deck Plus, or custom hardware. diff --git a/components/gps/example/CMakeLists.txt b/components/gps/example/CMakeLists.txt new file mode 100755 index 000000000..33a02c5da --- /dev/null +++ b/components/gps/example/CMakeLists.txt @@ -0,0 +1,25 @@ +# 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.20) + +# NOTE: we don't use the IDF component manager for the examples in this +# repository since the examples use the local versions of the components +set(ENV{IDF_COMPONENT_MANAGER} "0") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/" +) + +set( + COMPONENTS + "main esptool_py driver gps i2c logger task" + CACHE STRING + "List of components to include" + ) + +project(gps_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/gps/example/README.md b/components/gps/example/README.md new file mode 100755 index 000000000..a2120cb3f --- /dev/null +++ b/components/gps/example/README.md @@ -0,0 +1,26 @@ +# GPS Example + +This example shows how to use the `espp::Gps` component to parse NMEA data +from a UART-attached GNSS receiver and print the resulting fixes. + +## How to use example + +### Hardware Required + +One of: + +- M5Stack Cardputer-Adv with the LoRa+GPS Cap (ATGM336H, 115200 baud) +- LilyGo T-Deck Plus (u-blox MIA-M10Q, 9600 baud) +- Custom hardware with a UART NMEA GNSS receiver + +Select the board with `idf.py menuconfig` (`Example Configuration > +Hardware`). Note that GNSS receivers need sky view to get a fix - expect no +fix indoors. + +### Build and Flash + +Run `idf.py -p PORT flash monitor` to build, flash and monitor the project. + +(To exit the serial monitor, type ``Ctrl-]``.) + +See the Getting Started Guide for full steps to configure and use ESP-IDF to build projects. diff --git a/components/gps/example/main/CMakeLists.txt b/components/gps/example/main/CMakeLists.txt new file mode 100755 index 000000000..adc94b5b2 --- /dev/null +++ b/components/gps/example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES gps i2c logger + ) diff --git a/components/gps/example/main/Kconfig.projbuild b/components/gps/example/main/Kconfig.projbuild new file mode 100755 index 000000000..fb1de354b --- /dev/null +++ b/components/gps/example/main/Kconfig.projbuild @@ -0,0 +1,42 @@ +menu "Example Configuration" + +choice EXAMPLE_HARDWARE + prompt "Hardware" + default EXAMPLE_HARDWARE_CARDPUTER_ADV + help + Select the hardware to run this example on. + +config EXAMPLE_HARDWARE_CARDPUTER_ADV + depends on IDF_TARGET_ESP32S3 + bool "M5Stack Cardputer-Adv + LoRa+GPS Cap (ATGM336H)" + +config EXAMPLE_HARDWARE_TDECK_PLUS + depends on IDF_TARGET_ESP32S3 + bool "LilyGo T-Deck Plus (u-blox MIA-M10Q)" + +config EXAMPLE_HARDWARE_CUSTOM + bool "Custom" +endchoice + +config EXAMPLE_GPS_RX_GPIO + int "GPS RX GPIO Num (connected to the receiver's TX)" + range 0 50 + default 15 if EXAMPLE_HARDWARE_CARDPUTER_ADV + default 44 if EXAMPLE_HARDWARE_TDECK_PLUS + default 16 if EXAMPLE_HARDWARE_CUSTOM + +config EXAMPLE_GPS_TX_GPIO + int "GPS TX GPIO Num (connected to the receiver's RX)" + range 0 50 + default 13 if EXAMPLE_HARDWARE_CARDPUTER_ADV + default 43 if EXAMPLE_HARDWARE_TDECK_PLUS + default 17 if EXAMPLE_HARDWARE_CUSTOM + +config EXAMPLE_GPS_BAUD_RATE + int "GPS baud rate" + range 4800 921600 + default 115200 if EXAMPLE_HARDWARE_CARDPUTER_ADV + default 9600 if EXAMPLE_HARDWARE_TDECK_PLUS + default 9600 if EXAMPLE_HARDWARE_CUSTOM + +endmenu diff --git a/components/gps/example/main/gps_example.cpp b/components/gps/example/main/gps_example.cpp new file mode 100644 index 000000000..914554187 --- /dev/null +++ b/components/gps/example/main/gps_example.cpp @@ -0,0 +1,66 @@ +#include +#include + +#include "gps.hpp" +#include "logger.hpp" + +#if CONFIG_EXAMPLE_HARDWARE_CARDPUTER_ADV +#include "i2c.hpp" +#endif + +using namespace std::chrono_literals; + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "GPS Example", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting example!"); + +#if CONFIG_EXAMPLE_HARDWARE_CARDPUTER_ADV + // The Cardputer-Adv LoRa+GPS Cap (U214) gates the GPS LNA behind P1 of a + // PI4IOE5V6408 I2C IO expander; drive it high for better sensitivity. + // (P0 is the LoRa antenna switch.) + espp::I2c i2c({.port = I2C_NUM_0, + .sda_io_num = GPIO_NUM_8, + .scl_io_num = GPIO_NUM_9, + .sda_pullup_en = GPIO_PULLUP_ENABLE, + .scl_pullup_en = GPIO_PULLUP_ENABLE}); + { + std::error_code ec; + auto expander = i2c.add_device({.device_address = 0x43}, ec); + if (expander) { + const uint8_t init_regs[][2] = { + {0x03, 0x03}, // direction: P0, P1 outputs + {0x05, 0x03}, // output state: P0, P1 high + {0x07, 0x00}, // output high-impedance: disabled + }; + for (const auto ® : init_regs) { + expander->write(reg, 2, ec); + } + } else { + logger.warn("Could not configure IO expander: {}", ec.message()); + } + } +#endif + + //! [gps example] + espp::Gps gps({.uart_port = UART_NUM_1, + .tx_io_num = (gpio_num_t)CONFIG_EXAMPLE_GPS_TX_GPIO, + .rx_io_num = (gpio_num_t)CONFIG_EXAMPLE_GPS_RX_GPIO, + .baud_rate = CONFIG_EXAMPLE_GPS_BAUD_RATE, + .on_fix = + [&](const espp::GpsFix &fix) { + if (fix.valid) { + logger.info("Fix: {:.6f}, {:.6f} alt {:.1f} m, {} sats, hdop {:.1f}, " + "{:02}:{:02}:{:04.1f} UTC", + fix.latitude, fix.longitude, fix.altitude, fix.num_satellites, + fix.hdop, fix.hour, fix.minute, fix.second); + } else { + logger.info("Waiting for fix ({} sats in use)...", fix.num_satellites); + } + }, + .log_level = espp::Logger::Verbosity::INFO}); + //! [gps example] + + while (true) { + std::this_thread::sleep_for(10s); + } +} diff --git a/components/gps/example/sdkconfig.defaults b/components/gps/example/sdkconfig.defaults new file mode 100755 index 000000000..603c2cc6c --- /dev/null +++ b/components/gps/example/sdkconfig.defaults @@ -0,0 +1,11 @@ +CONFIG_IDF_TARGET="esp32s3" + +CONFIG_FREERTOS_HZ=1000 + +# set compiler optimization level to -O2 (compile for performance) +CONFIG_COMPILER_OPTIMIZATION_PERF=y + +# Common ESP-related +# +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 diff --git a/components/gps/idf_component.yml b/components/gps/idf_component.yml new file mode 100755 index 000000000..deaf3d7b4 --- /dev/null +++ b/components/gps/idf_component.yml @@ -0,0 +1,23 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "UART GNSS / GPS receiver component with NMEA-0183 parsing for ESP-IDF" +url: "https://github.com/esp-cpp/espp/tree/main/components/gps" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/wireless/gps.html" +examples: + - path: example +tags: + - cpp + - Component + - GPS + - GNSS + - NMEA + - UART + - Peripheral +dependencies: + idf: + version: '>=5.0' + espp/base_component: '>=1.0' + espp/task: '>=1.0' diff --git a/components/gps/include/gps.hpp b/components/gps/include/gps.hpp new file mode 100644 index 000000000..93d619b1b --- /dev/null +++ b/components/gps/include/gps.hpp @@ -0,0 +1,105 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include "base_component.hpp" +#include "nmea_parser.hpp" +#include "task.hpp" + +namespace espp { +/// Driver for UART-attached GNSS receivers which output NMEA-0183 sentences, +/// such as the ATGM336H (M5Stack Cardputer-Adv LoRa+GPS Cap) or the u-blox +/// MIA-M10Q (LilyGo T-Deck Plus). +/// +/// The driver installs the UART driver, then runs a task which reads the +/// NMEA stream, parses it (see espp::NmeaParser), and maintains the latest +/// fix, which can be retrieved thread-safely with fix() or delivered via the +/// fix callback. +/// +/// \section gps_example Example +/// \snippet gps_example.cpp gps example +class Gps : public BaseComponent { +public: + /// Callback invoked whenever an RMC sentence has been parsed (i.e. once + /// per receiver update cycle), with the current fix. + typedef std::function fix_callback_fn; + + /// Callback invoked for every valid NMEA sentence received (before + /// parsing), e.g. for logging or for parsing additional sentence types. + typedef std::function sentence_callback_fn; + + /// Configuration for the Gps driver. + struct Config { + uart_port_t uart_port = UART_NUM_1; ///< The UART port to use + gpio_num_t tx_io_num = GPIO_NUM_NC; ///< GPIO connected to the receiver's RX pin (for + ///< sending configuration commands); may be NC + gpio_num_t rx_io_num = GPIO_NUM_NC; ///< GPIO connected to the receiver's TX pin + uint32_t baud_rate = 9600; ///< UART baud rate (9600 for most receivers; the + ///< M5Stack LoRa+GPS Cap ships configured for 115200) + size_t rx_buffer_size = 2048; ///< UART RX ring buffer size + fix_callback_fn on_fix = nullptr; ///< Optional callback invoked on each fix update + sentence_callback_fn on_sentence = nullptr; ///< Optional callback invoked per NMEA sentence + bool auto_start = true; ///< Whether to install the driver and start the + ///< read task on construction + espp::Task::BaseConfig task_config = { + .name = "gps", + .stack_size_bytes = 6 * 1024, + }; ///< Configuration for the reader task + Logger::Verbosity log_level{Logger::Verbosity::WARN}; ///< Log verbosity for the driver + }; + + /// Constructor + /// \param config The configuration for the driver + explicit Gps(const Config &config); + + /// Destructor - stops the task and deletes the UART driver + ~Gps(); + + /// Install the UART driver and start the reader task. + /// \param ec The error code to set if there is an error + /// \return True if started successfully + bool start(std::error_code &ec); + + /// Stop the reader task and delete the UART driver. + /// \return True if stopped successfully + bool stop(); + + /// Whether the driver has been started + /// \return True if the driver is running + bool is_running() const { return running_; } + + /// Get a copy of the latest fix data. + /// \return The latest fix + GpsFix fix() const; + + /// Whether the receiver currently reports a valid fix + /// \return True if the latest RMC sentence reported a valid fix + bool has_fix() const; + + /// Write raw data to the receiver (e.g. NMEA/PCAS/UBX configuration + /// commands). Requires tx_io_num to be set. + /// \param data The data to write + /// \param ec The error code to set if there is an error + /// \return True if the data was written + bool write(std::string_view data, std::error_code &ec); + +protected: + bool read_task(std::mutex &m, std::condition_variable &cv); + void handle_line(std::string_view line); + + Config config_; + std::atomic running_{false}; + bool uart_installed_{false}; + std::unique_ptr task_; + mutable std::mutex fix_mutex_; + NmeaParser parser_; + std::string line_buffer_; +}; +} // namespace espp diff --git a/components/gps/include/gps_fix.hpp b/components/gps/include/gps_fix.hpp new file mode 100644 index 000000000..c341a2ba6 --- /dev/null +++ b/components/gps/include/gps_fix.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include + +namespace espp { +/// A GNSS fix, assembled from the parsed NMEA sentences. +struct GpsFix { + bool valid{false}; ///< Whether the receiver reports a valid fix + double latitude{0}; ///< Latitude in decimal degrees (+N / -S) + double longitude{0}; ///< Longitude in decimal degrees (+E / -W) + float altitude{0}; ///< Altitude above mean sea level, in meters + float speed_knots{0}; ///< Speed over ground, in knots + float course_degrees{0}; ///< Course over ground, in degrees true + uint8_t num_satellites{0}; ///< Number of satellites used in the fix + float hdop{0}; ///< Horizontal dilution of precision + uint8_t fix_quality{0}; ///< GGA fix quality (0 = none, 1 = GPS, 2 = DGPS, ...) + // UTC date / time of the fix + uint16_t year{0}; ///< UTC year (e.g. 2026); 0 if no date received yet + uint8_t month{0}; ///< UTC month (1-12) + uint8_t day{0}; ///< UTC day of month (1-31) + uint8_t hour{0}; ///< UTC hour (0-23) + uint8_t minute{0}; ///< UTC minute (0-59) + float second{0}; ///< UTC second, including fractional part + + /// Get the speed over ground in meters per second + /// \return The speed in m/s + float speed_mps() const { return speed_knots * 0.514444f; } + + /// Get the UTC time of the fix as a unix timestamp (seconds since epoch). + /// \return The unix timestamp, or 0 if no date/time has been received + uint64_t unix_timestamp() const { + if (year < 2000) { + return 0; + } + // days since epoch using the standard civil-date algorithm + int y = year; + int m = month; + int d = day; + y -= m <= 2; + int era = y / 400; + int yoe = y - era * 400; + int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + int64_t days = era * 146097LL + doe - 719468LL; + return (uint64_t)(days * 86400LL + hour * 3600LL + minute * 60LL + (int)second); + } +}; +} // namespace espp diff --git a/components/gps/include/nmea_parser.hpp b/components/gps/include/nmea_parser.hpp new file mode 100755 index 000000000..5e6096c5d --- /dev/null +++ b/components/gps/include/nmea_parser.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include "gps_fix.hpp" + +namespace espp { +/// Incremental parser for NMEA-0183 sentences from a GNSS receiver. +/// +/// Feed it complete sentences (with or without the trailing CR/LF) via +/// parse(); it validates the checksum and updates the fix state from the +/// sentences it understands (xxRMC, xxGGA - any talker id: GP, GN, BD, ...). +/// This class performs no I/O and is usable on any platform; see espp::Gps +/// for a UART-attached wrapper. +class NmeaParser { +public: + /// Parse a single NMEA sentence and update the fix state. + /// \param sentence The sentence, from '$' through the checksum (trailing + /// CR/LF permitted) + /// \return True if the sentence was valid (good checksum) and recognized + bool parse(std::string_view sentence); + + /// Get the current fix state, assembled from all sentences parsed so far. + /// \return The current fix + const GpsFix &fix() const { return fix_; } + + /// Verify the checksum of an NMEA sentence. + /// \param sentence The sentence, from '$' through the checksum + /// \return True if the sentence has a valid checksum + static bool checksum_valid(std::string_view sentence); + +protected: + bool parse_rmc(std::string_view body); + bool parse_gga(std::string_view body); + + GpsFix fix_{}; +}; +} // namespace espp diff --git a/components/gps/src/gps.cpp b/components/gps/src/gps.cpp new file mode 100644 index 000000000..87633f390 --- /dev/null +++ b/components/gps/src/gps.cpp @@ -0,0 +1,153 @@ +#include "gps.hpp" + +#include + +using namespace espp; + +Gps::Gps(const Config &config) + : BaseComponent("Gps", config.log_level) + , config_(config) { + if (config_.auto_start) { + std::error_code ec; + if (!start(ec)) { + logger_.error("Failed to start: {}", ec.message()); + } + } +} + +Gps::~Gps() { stop(); } + +bool Gps::start(std::error_code &ec) { + if (running_) { + logger_.warn("Already running"); + return true; + } + if (config_.rx_io_num == GPIO_NUM_NC) { + logger_.error("No RX pin configured"); + ec = std::make_error_code(std::errc::invalid_argument); + return false; + } + logger_.info("Starting GPS on UART{} (rx={}, tx={}, {} baud)", (int)config_.uart_port, + (int)config_.rx_io_num, (int)config_.tx_io_num, config_.baud_rate); + uart_config_t uart_config; + memset(&uart_config, 0, sizeof(uart_config)); + uart_config.baud_rate = (int)config_.baud_rate; + uart_config.data_bits = UART_DATA_8_BITS; + uart_config.parity = UART_PARITY_DISABLE; + uart_config.stop_bits = UART_STOP_BITS_1; + uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; + uart_config.source_clk = UART_SCLK_DEFAULT; + esp_err_t err = + uart_driver_install(config_.uart_port, (int)config_.rx_buffer_size, 256, 0, nullptr, 0); + if (err != ESP_OK) { + logger_.error("Failed to install UART driver: {}", esp_err_to_name(err)); + ec = std::make_error_code(std::errc::io_error); + return false; + } + uart_installed_ = true; + err = uart_param_config(config_.uart_port, &uart_config); + if (err == ESP_OK) { + err = uart_set_pin(config_.uart_port, config_.tx_io_num, config_.rx_io_num, UART_PIN_NO_CHANGE, + UART_PIN_NO_CHANGE); + } + if (err != ESP_OK) { + logger_.error("Failed to configure UART: {}", esp_err_to_name(err)); + uart_driver_delete(config_.uart_port); + uart_installed_ = false; + ec = std::make_error_code(std::errc::io_error); + return false; + } + line_buffer_.clear(); + line_buffer_.reserve(128); + running_ = true; + task_ = std::make_unique(espp::Task::Config{ + .callback = espp::Task::callback_m_cv_fn( + [this](std::mutex &m, std::condition_variable &cv) { return read_task(m, cv); }), + .task_config = config_.task_config, + .log_level = espp::Logger::Verbosity::WARN}); + task_->start(); + return true; +} + +bool Gps::stop() { + if (!running_) { + return false; + } + running_ = false; + task_.reset(); + if (uart_installed_) { + uart_driver_delete(config_.uart_port); + uart_installed_ = false; + } + return true; +} + +GpsFix Gps::fix() const { + std::lock_guard lock(fix_mutex_); + return parser_.fix(); +} + +bool Gps::has_fix() const { + std::lock_guard lock(fix_mutex_); + return parser_.fix().valid; +} + +bool Gps::write(std::string_view data, std::error_code &ec) { + if (!uart_installed_ || config_.tx_io_num == GPIO_NUM_NC) { + ec = std::make_error_code(std::errc::operation_not_supported); + return false; + } + int written = uart_write_bytes(config_.uart_port, data.data(), data.size()); + if (written != (int)data.size()) { + ec = std::make_error_code(std::errc::io_error); + return false; + } + return true; +} + +bool Gps::read_task(std::mutex &m, std::condition_variable &cv) { + uint8_t buffer[256]; + int len = uart_read_bytes(config_.uart_port, buffer, sizeof(buffer), pdMS_TO_TICKS(100)); + if (len <= 0) { + return false; // nothing received; don't stop the task + } + for (int i = 0; i < len; i++) { + char c = (char)buffer[i]; + if (c == '\n') { + if (!line_buffer_.empty()) { + handle_line(line_buffer_); + line_buffer_.clear(); + } + } else if (c != '\r') { + // protect against garbage / binary data filling memory + if (line_buffer_.size() < 120) { + line_buffer_.push_back(c); + } else { + line_buffer_.clear(); + } + } + } + return false; // don't stop the task +} + +void Gps::handle_line(std::string_view line) { + logger_.debug("NMEA: {}", line); + if (!NmeaParser::checksum_valid(line)) { + return; + } + if (config_.on_sentence) { + config_.on_sentence(line); + } + bool is_rmc = line.size() > 6 && line.substr(3, 3) == "RMC"; + GpsFix fix_copy; + { + std::lock_guard lock(fix_mutex_); + parser_.parse(line); + fix_copy = parser_.fix(); + } + // RMC is the last sentence of interest in a typical update cycle, so use + // it as the "fix updated" event + if (is_rmc && config_.on_fix) { + config_.on_fix(fix_copy); + } +} diff --git a/components/gps/src/nmea_parser.cpp b/components/gps/src/nmea_parser.cpp new file mode 100755 index 000000000..6293295bf --- /dev/null +++ b/components/gps/src/nmea_parser.cpp @@ -0,0 +1,169 @@ +#include "nmea_parser.hpp" + +#include +#include +#include +#include + +using namespace espp; + +namespace { +// split an NMEA sentence body into its comma-separated fields; empty fields +// are preserved. Returns the number of fields. +size_t split_fields(std::string_view body, std::array &fields) { + size_t count = 0; + size_t start = 0; + while (count < fields.size()) { + size_t comma = body.find(',', start); + if (comma == std::string_view::npos) { + fields[count++] = body.substr(start); + break; + } + fields[count++] = body.substr(start, comma - start); + start = comma + 1; + } + return count; +} + +float to_float(std::string_view s, float default_value = 0) { + if (s.empty()) { + return default_value; + } + // std::from_chars is not available on all toolchains; strtof needs a + // null-terminated buffer + char buffer[24]; + size_t len = std::min(s.size(), sizeof(buffer) - 1); + std::memcpy(buffer, s.data(), len); + buffer[len] = '\0'; + return std::strtof(buffer, nullptr); +} + +int to_int(std::string_view s, int default_value = 0) { + int value = default_value; + std::from_chars(s.data(), s.data() + s.size(), value); + return value; +} + +// convert an NMEA "[d]ddmm.mmmm" coordinate + hemisphere to decimal degrees +double to_degrees(std::string_view value, std::string_view hemisphere) { + if (value.empty()) { + return 0; + } + size_t dot = value.find('.'); + if (dot == std::string_view::npos || dot < 3) { + return 0; + } + // degrees are everything more than two digits left of the decimal point + int degrees = to_int(value.substr(0, dot - 2)); + char buffer[24]; + std::string_view minutes_str = value.substr(dot - 2); + size_t len = std::min(minutes_str.size(), sizeof(buffer) - 1); + std::memcpy(buffer, minutes_str.data(), len); + buffer[len] = '\0'; + double minutes = std::strtod(buffer, nullptr); + double result = degrees + minutes / 60.0; + if (hemisphere == "S" || hemisphere == "W") { + result = -result; + } + return result; +} +} // namespace + +bool NmeaParser::checksum_valid(std::string_view sentence) { + if (sentence.size() < 4 || sentence[0] != '$') { + return false; + } + size_t star = sentence.find('*'); + if (star == std::string_view::npos || star + 3 > sentence.size()) { + return false; + } + uint8_t checksum = 0; + for (size_t i = 1; i < star; i++) { + checksum ^= (uint8_t)sentence[i]; + } + auto hex_value = [](char c) -> int { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + return -1; + }; + int high = hex_value(sentence[star + 1]); + int low = hex_value(sentence[star + 2]); + if (high < 0 || low < 0) { + return false; + } + return checksum == (uint8_t)((high << 4) | low); +} + +bool NmeaParser::parse(std::string_view sentence) { + // strip trailing CR/LF + while (!sentence.empty() && (sentence.back() == '\r' || sentence.back() == '\n')) { + sentence.remove_suffix(1); + } + if (!checksum_valid(sentence)) { + return false; + } + // "$GPRMC,...*XX" -> type "RMC" (skipping the 2-character talker id), + // body between the first comma and the '*' + size_t star = sentence.find('*'); + size_t comma = sentence.find(','); + if (comma == std::string_view::npos || comma < 4 || comma > star) { + return false; + } + std::string_view type = sentence.substr(3, comma - 3); + std::string_view body = sentence.substr(comma + 1, star - comma - 1); + if (type == "RMC") { + return parse_rmc(body); + } else if (type == "GGA") { + return parse_gga(body); + } + return false; +} + +bool NmeaParser::parse_rmc(std::string_view body) { + // hhmmss.ss,A,ddmm.mm,N,dddmm.mm,W,speed,course,ddmmyy,... + std::array f; + size_t count = split_fields(body, f); + if (count < 9) { + return false; + } + if (f[0].size() >= 6) { + fix_.hour = to_int(f[0].substr(0, 2)); + fix_.minute = to_int(f[0].substr(2, 2)); + fix_.second = to_float(f[0].substr(4)); + } + fix_.valid = (f[1] == "A"); + if (fix_.valid) { + fix_.latitude = to_degrees(f[2], f[3]); + fix_.longitude = to_degrees(f[4], f[5]); + fix_.speed_knots = to_float(f[6]); + fix_.course_degrees = to_float(f[7]); + } + if (f[8].size() >= 6) { + fix_.day = to_int(f[8].substr(0, 2)); + fix_.month = to_int(f[8].substr(2, 2)); + fix_.year = 2000 + to_int(f[8].substr(4, 2)); + } + return true; +} + +bool NmeaParser::parse_gga(std::string_view body) { + // hhmmss.ss,ddmm.mm,N,dddmm.mm,W,quality,numsats,hdop,alt,M,... + std::array f; + size_t count = split_fields(body, f); + if (count < 9) { + return false; + } + fix_.fix_quality = to_int(f[5]); + fix_.num_satellites = to_int(f[6]); + fix_.hdop = to_float(f[7]); + if (fix_.fix_quality > 0) { + fix_.latitude = to_degrees(f[1], f[2]); + fix_.longitude = to_degrees(f[3], f[4]); + fix_.altitude = to_float(f[8]); + } + return true; +} diff --git a/components/m5stack-cardputer/CMakeLists.txt b/components/m5stack-cardputer/CMakeLists.txt old mode 100644 new mode 100755 index 90519bdd9..1cd2f3acd --- a/components/m5stack-cardputer/CMakeLists.txt +++ b/components/m5stack-cardputer/CMakeLists.txt @@ -2,6 +2,6 @@ idf_component_register( INCLUDE_DIRS "include" SRC_DIRS "src" - REQUIRES driver esp_adc esp_driver_i2s fatfs base_component adc bmi270 display display_drivers i2c interrupt led math neopixel spi task + REQUIRES driver esp_adc esp_driver_i2s fatfs base_component adc bmi270 display display_drivers gps i2c interrupt led math neopixel spi sx126x task REQUIRED_IDF_TARGETS "esp32s3" ) diff --git a/components/m5stack-cardputer/example/README.md b/components/m5stack-cardputer/example/README.md old mode 100644 new mode 100755 index 744aad43f..c6098523c --- a/components/m5stack-cardputer/example/README.md +++ b/components/m5stack-cardputer/example/README.md @@ -3,16 +3,36 @@ This example shows how to use the `espp::M5StackCardputer` hardware abstraction component to initialize and use the components on the M5Stack Cardputer: -- The display (with LVGL, via a small `Gui` class that implements a - keyboard-driven text editor) +- The display (with LVGL, via a small `Gui` class that presents a **tabbed + interface** switched from the keyboard - there is no touchscreen) - The 56-key QWERTY keyboard (typing, shift layer, and the fn layer's arrow / delete / esc keys) - The speaker (a key-click beep for every keypress) - The RGB LED (the G0 / BOOT button cycles its color) - The uSD card (mounted at startup if inserted) -- The battery voltage / state-of-charge measurement (shown in the status bar) -- The IMU on the Cardputer ADV (live accelerometer / gyroscope readings shown - in the top-right corner) +- The battery voltage / state-of-charge measurement +- The IMU on the Cardputer ADV (live accelerometer / gyroscope readings) +- The SX1262 LoRa radio on the LoRa+GPS Cap (send / receive text; Cardputer ADV + accessory) +- The ATGM336H GNSS receiver on the same Cap (position / satellites / UTC time + shown on the GPS tab) + +The UI is a tabview with five tabs, switched with **fn+Tab** (or jumped to +directly with fn+1 / fn+2 / fn+9): + +| Tab | Contents | +|-----|----------| +| **Text** | The text editor - type here; this is also where LoRa messages are composed | +| **LoRa** | Radio status and a scrolling log of sent / received messages with RSSI | +| **IMU** | Live accelerometer / gyroscope readings (ADV only) | +| **GPS** | GNSS fix status (position, satellites, UTC time) from the LoRa+GPS Cap | +| **Sys** | Board variant, battery, and speaker / mic volumes | +| **Help** | The list of controls | + +A slim status bar along the bottom stays visible on every tab for transient +messages (key names, volume changes, "Sending..."). While the LoRa tab is +active, the status bar instead mirrors the message you are composing (the text +box lives on the Text tab), so you can see what you are about to send. ## How to use example @@ -20,21 +40,39 @@ component to initialize and use the components on the M5Stack Cardputer: | Input | Action | |-------|--------| -| Printable keys (with shift layer) | Type into the text area | +| **Fn + Tab** | **Switch to the next tab** | +| Printable keys (with shift layer) | Type into the text area (Text tab) | | Backspace / Enter / Tab | Edit the text area | | Fn + `;` / `,` / `.` / `/` | Move the cursor (up / left / down / right) | | Fn + backspace | Delete forward | | Fn + `` ` `` | Clear the text area | -| Fn + 1 (F1) | Show / hide the controls help popup | -| Fn + 2 (F2) | Show / hide the IMU overlay (ADV) | +| Fn + 1 (F1) | Jump to the Help tab | +| Fn + 2 (F2) | Jump to the IMU tab (ADV) | +| Fn + 9 (F9) | Jump to the LoRa tab | +| Fn + 0 (F10) | Send the text area's contents over LoRa | | Fn + 3 (F3) | Start / stop recording from the microphone (ADV) | | Fn + 4 (F4) | Play back / stop the recording (ADV) | | Fn + 5 / 6 (F5 / F6) | Speaker volume down / up | | Fn + 7 / 8 (F7 / F8) | Microphone volume down / up (75% = 0 dB) | -| Fn + number row | Show F1-F12 in the status bar | | G0 (BOOT) button | Cycle the RGB LED color | -The controls are also printed to the log at startup. +The controls are also printed to the log at startup, and are shown on the Help +tab. + +### LoRa (with the LoRa+GPS Cap) + +The **LoRa** tab drives the SX1262 radio on the M5Stack LoRa+GPS Cap (a +Cardputer ADV accessory). Type a message on the Text tab, press **fn+0** to +transmit it, and received packets appear in the LoRa tab's log with their RSSI. + +This uses the same raw-LoRa settings as the T-Deck example (US LongFast +modulation - 906.875 MHz, SF11/BW250 - on a private sync word `0x12`), so a +Cardputer with the Cap and a T-Deck each running their example will exchange +text messages. This is *not* Meshtastic (that uses sync word `0x2B`) - see the +`meshtastic` component for Meshtastic interoperability. Attach the antenna +before transmitting, and make sure the frequency is legal in your region. If +the Cap is not attached (or the board is not an ADV), the LoRa tab reports that +the radio is unavailable. On the ADV the ES8311 codec runs the speaker and microphone in full duplex, so recording works while the speaker is active (key clicks and all). The diff --git a/components/m5stack-cardputer/example/main/gui.cpp b/components/m5stack-cardputer/example/main/gui.cpp index 7f5ed448f..1ff973e77 100644 --- a/components/m5stack-cardputer/example/main/gui.cpp +++ b/components/m5stack-cardputer/example/main/gui.cpp @@ -2,111 +2,175 @@ void Gui::init_ui() { std::lock_guard lock(mutex_); - init_background(); - init_textarea(); + init_tabview(); + init_text_tab(); + init_lora_tab(); + init_imu_tab(); + init_gps_tab(); + init_sys_tab(); + init_help_tab(); init_status_bar(); - init_imu_popup(); - init_help_panel(); } void Gui::deinit_ui() { std::lock_guard lock(mutex_); lv_obj_clean(lv_screen_active()); - background_ = nullptr; + tabview_ = nullptr; + text_tab_ = nullptr; + lora_tab_ = nullptr; + imu_tab_ = nullptr; + gps_tab_ = nullptr; + sys_tab_ = nullptr; + help_tab_ = nullptr; textarea_ = nullptr; status_label_ = nullptr; - imu_popup_ = nullptr; imu_label_ = nullptr; - help_panel_ = nullptr; + gps_label_ = nullptr; + sys_label_ = nullptr; + lora_status_label_ = nullptr; + lora_log_label_ = nullptr; } -void Gui::init_background() { - background_ = lv_obj_create(lv_screen_active()); - lv_obj_set_size(background_, lv_pct(100), lv_pct(100)); - lv_obj_center(background_); - lv_obj_set_style_border_width(background_, 0, LV_PART_MAIN); - lv_obj_set_style_radius(background_, 0, LV_PART_MAIN); - lv_obj_set_style_pad_all(background_, 0, LV_PART_MAIN); +void Gui::init_tabview() { + // the tabview fills the screen except for the status bar along the bottom; + // the tab bar (top) is the page switcher (driven from the keyboard - there + // is no touchscreen) + int width = lv_display_get_horizontal_resolution(lv_display_get_default()); + int height = lv_display_get_vertical_resolution(lv_display_get_default()); + tabview_ = lv_tabview_create(lv_screen_active()); + lv_tabview_set_tab_bar_position(tabview_, LV_DIR_TOP); + lv_tabview_set_tab_bar_size(tabview_, TAB_BAR_HEIGHT); + lv_obj_set_size(tabview_, width, height - STATUS_BAR_HEIGHT); + lv_obj_align(tabview_, LV_ALIGN_TOP_MID, 0, 0); + text_tab_ = lv_tabview_add_tab(tabview_, "Text"); + lora_tab_ = lv_tabview_add_tab(tabview_, "LoRa"); + imu_tab_ = lv_tabview_add_tab(tabview_, "IMU"); + gps_tab_ = lv_tabview_add_tab(tabview_, "GPS"); + sys_tab_ = lv_tabview_add_tab(tabview_, "Sys"); + help_tab_ = lv_tabview_add_tab(tabview_, "Help"); + // there is no touchscreen, so the content should not be swipe-scrollable + // (tabs are switched from the keyboard); tighten the tab content padding to + // make the most of the small screen + lv_obj_set_style_pad_all(lv_tabview_get_content(tabview_), 2, LV_PART_MAIN); + + // All six tabs do not fit across the 240px screen. LVGL flex-grows the tab + // buttons to equal width by default, which crams / clips the labels; instead + // give each button its natural width and let the tab bar scroll + // horizontally. The active button is scrolled into view on every tab change + // (see scroll_active_tab_into_view()). + lv_obj_t *tab_bar = lv_tabview_get_tab_bar(tabview_); + lv_obj_set_scrollbar_mode(tab_bar, LV_SCROLLBAR_MODE_OFF); + uint32_t tab_count = lv_tabview_get_tab_count(tabview_); + for (uint32_t i = 0; i < tab_count; i++) { + lv_obj_t *btn = lv_tabview_get_tab_button(tabview_, static_cast(i)); + if (btn) { + lv_obj_set_flex_grow(btn, 0); + lv_obj_set_width(btn, LV_SIZE_CONTENT); + lv_obj_set_style_pad_hor(btn, 10, LV_PART_MAIN); + } + } + scroll_active_tab_into_view(); } -void Gui::init_textarea() { - textarea_ = lv_textarea_create(background_); - lv_obj_set_size(textarea_, lv_pct(100), lv_pct(80)); +void Gui::init_text_tab() { + textarea_ = lv_textarea_create(text_tab_); + lv_obj_set_size(textarea_, lv_pct(100), lv_pct(100)); lv_obj_align(textarea_, LV_ALIGN_TOP_MID, 0, 0); - lv_textarea_set_placeholder_text(textarea_, "Type on the keyboard..."); + lv_textarea_set_placeholder_text(textarea_, "Type here; fn+0 sends over LoRa"); // the keyboard scanner callback drives this text area; there is no // touchscreen so it never needs to be clickable lv_obj_remove_flag(textarea_, LV_OBJ_FLAG_CLICKABLE); - // make sure the cursor is visible lv_obj_add_state(textarea_, LV_STATE_FOCUSED); } -void Gui::init_status_bar() { - status_label_ = lv_label_create(background_); - lv_obj_set_width(status_label_, lv_pct(100)); - lv_obj_align(status_label_, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_label_set_long_mode(status_label_, LV_LABEL_LONG_CLIP); - lv_label_set_text(status_label_, "Ready"); +void Gui::init_lora_tab() { + // a status line at the top and a scrolling log of sent / received messages + lv_obj_set_flex_flow(lora_tab_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(lora_tab_, 4, LV_PART_MAIN); + + lora_status_label_ = lv_label_create(lora_tab_); + lv_obj_set_width(lora_status_label_, lv_pct(100)); + lv_label_set_long_mode(lora_status_label_, LV_LABEL_LONG_WRAP); + lv_label_set_text(lora_status_label_, "LoRa: initializing..."); + + lora_log_label_ = lv_label_create(lora_tab_); + lv_obj_set_width(lora_log_label_, lv_pct(100)); + lv_label_set_long_mode(lora_log_label_, LV_LABEL_LONG_WRAP); + lv_label_set_text(lora_log_label_, "No messages yet."); } -void Gui::init_imu_popup() { - // a small popup in the top-right corner holding the live IMU readings and - // a hint for how to close it - imu_popup_ = lv_obj_create(background_); - lv_obj_set_size(imu_popup_, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_align(imu_popup_, LV_ALIGN_TOP_RIGHT, -2, 2); - lv_obj_set_style_pad_all(imu_popup_, 4, LV_PART_MAIN); - lv_obj_add_flag(imu_popup_, LV_OBJ_FLAG_HIDDEN); - imu_label_ = lv_label_create(imu_popup_); +void Gui::init_imu_tab() { + imu_label_ = lv_label_create(imu_tab_); + lv_obj_set_width(imu_label_, lv_pct(100)); + lv_label_set_long_mode(imu_label_, LV_LABEL_LONG_WRAP); lv_obj_align(imu_label_, LV_ALIGN_TOP_LEFT, 0, 0); lv_label_set_text(imu_label_, "IMU..."); } -void Gui::init_help_panel() { - // a centered popup listing the controls; hidden until toggled via fn+1. - // The content is taller than the panel, so the panel is scrollable (see - // handle_special_key(): the fn+arrow keys scroll it while it is open) - help_panel_ = lv_obj_create(background_); - lv_obj_set_size(help_panel_, lv_pct(92), lv_pct(92)); - lv_obj_center(help_panel_); - lv_obj_set_style_pad_all(help_panel_, 6, LV_PART_MAIN); - lv_obj_add_flag(help_panel_, LV_OBJ_FLAG_HIDDEN); - auto *label = lv_label_create(help_panel_); - // constrain the label to the panel's content width so long lines wrap - // instead of running off screen +void Gui::init_gps_tab() { + gps_label_ = lv_label_create(gps_tab_); + lv_obj_set_width(gps_label_, lv_pct(100)); + lv_label_set_long_mode(gps_label_, LV_LABEL_LONG_WRAP); + lv_obj_align(gps_label_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_label_set_text(gps_label_, "GPS..."); +} + +void Gui::init_sys_tab() { + sys_label_ = lv_label_create(sys_tab_); + lv_obj_set_width(sys_label_, lv_pct(100)); + lv_label_set_long_mode(sys_label_, LV_LABEL_LONG_WRAP); + lv_obj_align(sys_label_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_label_set_text(sys_label_, "System..."); +} + +void Gui::init_help_tab() { + auto *label = lv_label_create(help_tab_); lv_obj_set_width(label, lv_pct(100)); lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); lv_label_set_text(label, HELP_TEXT); lv_obj_align(label, LV_ALIGN_TOP_LEFT, 0, 0); } -bool Gui::toggle_imu_visible() { +void Gui::init_status_bar() { + // a slim bar along the very bottom, outside the tabview, so transient + // messages stay visible regardless of the active tab + int width = lv_display_get_horizontal_resolution(lv_display_get_default()); + status_label_ = lv_label_create(lv_screen_active()); + lv_obj_set_size(status_label_, width, STATUS_BAR_HEIGHT); + lv_obj_align(status_label_, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_label_set_long_mode(status_label_, LV_LABEL_LONG_CLIP); + lv_label_set_text(status_label_, "Ready"); +} + +Gui::Tab Gui::next_tab() { std::lock_guard lock(mutex_); - imu_visible_ = !imu_visible_; - if (imu_popup_) { - if (imu_visible_) { - lv_obj_remove_flag(imu_popup_, LV_OBJ_FLAG_HIDDEN); - lv_obj_move_foreground(imu_popup_); - } else { - lv_obj_add_flag(imu_popup_, LV_OBJ_FLAG_HIDDEN); - } - } - return imu_visible_; + uint32_t current = lv_tabview_get_tab_active(tabview_); + uint32_t next = (current + 1) % static_cast(Tab::COUNT); + lv_tabview_set_active(tabview_, next, LV_ANIM_OFF); + scroll_active_tab_into_view(); + return static_cast(next); } -bool Gui::toggle_help() { +void Gui::select_tab(Tab tab) { std::lock_guard lock(mutex_); - help_visible_ = !help_visible_; - if (help_panel_) { - if (help_visible_) { - lv_obj_remove_flag(help_panel_, LV_OBJ_FLAG_HIDDEN); - // make sure the popup is on top of the other UI elements - lv_obj_move_foreground(help_panel_); - } else { - lv_obj_add_flag(help_panel_, LV_OBJ_FLAG_HIDDEN); - } + lv_tabview_set_active(tabview_, static_cast(tab), LV_ANIM_OFF); + scroll_active_tab_into_view(); +} + +void Gui::scroll_active_tab_into_view() { + if (!tabview_) { + return; + } + lv_obj_t *btn = lv_tabview_get_tab_button( + tabview_, static_cast(lv_tabview_get_tab_active(tabview_))); + if (btn) { + lv_obj_scroll_to_view(btn, LV_ANIM_ON); } - return help_visible_; +} + +Gui::Tab Gui::active_tab() { + std::lock_guard lock(mutex_); + return static_cast(lv_tabview_get_tab_active(tabview_)); } void Gui::add_char(char c) { @@ -123,26 +187,21 @@ void Gui::add_char(char c) { void Gui::handle_special_key(SpecialKey key) { std::lock_guard lock(mutex_); - if (help_visible_ && help_panel_) { - // while the help popup is open, the arrow keys scroll it and esc closes - // it instead of acting on the text area - switch (key) { - case SpecialKey::UP: - lv_obj_scroll_to_y(help_panel_, lv_obj_get_scroll_y(help_panel_) - 20, LV_ANIM_ON); - return; - case SpecialKey::DOWN: - lv_obj_scroll_to_y(help_panel_, lv_obj_get_scroll_y(help_panel_) + 20, LV_ANIM_ON); + // On tabs other than Text, the up / down keys scroll the page so its content + // (the LoRa log, the help text, ...) can be read - there is no touchscreen to + // drag it. The editing keys only act on the Text tab. + if (static_cast(lv_tabview_get_tab_active(tabview_)) != Tab::TEXT) { + lv_obj_t *page = active_tab_content(); + if (!page) { return; - case SpecialKey::LEFT: - case SpecialKey::RIGHT: - // ignored while the help popup is open - return; - case SpecialKey::ESC: - toggle_help(); - return; - default: - break; } + static constexpr int scroll_step = 24; + if (key == SpecialKey::UP) { + lv_obj_scroll_to_y(page, lv_obj_get_scroll_y(page) - scroll_step, LV_ANIM_ON); + } else if (key == SpecialKey::DOWN) { + lv_obj_scroll_to_y(page, lv_obj_get_scroll_y(page) + scroll_step, LV_ANIM_ON); + } + return; } if (!textarea_) { return; @@ -167,18 +226,52 @@ void Gui::handle_special_key(SpecialKey key) { lv_textarea_set_text(textarea_, ""); break; default: - // the other special keys (F1-F12) are just shown in the status bar by - // the main loop + // the other special keys (F1-F12) are handled by the example (tab + // switching, audio, LoRa) break; } } +lv_obj_t *Gui::active_tab_content() { + switch (static_cast(lv_tabview_get_tab_active(tabview_))) { + case Tab::TEXT: + return text_tab_; + case Tab::LORA: + return lora_tab_; + case Tab::IMU: + return imu_tab_; + case Tab::GPS: + return gps_tab_; + case Tab::SYS: + return sys_tab_; + case Tab::HELP: + return help_tab_; + default: + return nullptr; + } +} + +std::string Gui::get_text() { + std::lock_guard lock(mutex_); + if (!textarea_) { + return {}; + } + const char *text = lv_textarea_get_text(textarea_); + return text ? std::string(text) : std::string(); +} + +void Gui::clear_text() { + std::lock_guard lock(mutex_); + if (textarea_) { + lv_textarea_set_text(textarea_, ""); + } +} + void Gui::set_status_text(std::string_view text) { std::lock_guard lock(mutex_); if (!status_label_) { return; } - // string_view is not guaranteed to be null-terminated const std::string text_str(text); lv_label_set_text(status_label_, text_str.c_str()); } @@ -188,12 +281,64 @@ void Gui::set_imu_text(std::string_view text) { if (!imu_label_) { return; } - // append the close hint so the popup is self-documenting (and copy since - // string_view is not guaranteed to be null-terminated) - const std::string text_str = std::string(text) + "\nfn+2 to close"; + const std::string text_str(text); lv_label_set_text(imu_label_, text_str.c_str()); } +void Gui::set_system_text(std::string_view text) { + std::lock_guard lock(mutex_); + if (!sys_label_) { + return; + } + const std::string text_str(text); + lv_label_set_text(sys_label_, text_str.c_str()); +} + +void Gui::set_gps_text(std::string_view text) { + std::lock_guard lock(mutex_); + if (!gps_label_) { + return; + } + const std::string text_str(text); + lv_label_set_text(gps_label_, text_str.c_str()); +} + +void Gui::set_lora_status(std::string_view text) { + std::lock_guard lock(mutex_); + if (!lora_status_label_) { + return; + } + const std::string text_str(text); + lv_label_set_text(lora_status_label_, text_str.c_str()); +} + +void Gui::add_lora_message(std::string_view text) { + std::lock_guard lock(mutex_); + lora_messages_.emplace_front(text); + while (lora_messages_.size() > MAX_LORA_MESSAGES) { + lora_messages_.pop_back(); + } + update_lora_log(); +} + +void Gui::update_lora_log() { + if (!lora_log_label_) { + return; + } + if (lora_messages_.empty()) { + lv_label_set_text(lora_log_label_, "No messages yet."); + return; + } + std::string text; + for (const auto &message : lora_messages_) { + if (!text.empty()) { + text += "\n"; + } + text += message; + } + lv_label_set_text(lora_log_label_, text.c_str()); +} + bool Gui::update(std::mutex &m, std::condition_variable &cv) { { std::lock_guard lock(mutex_); diff --git a/components/m5stack-cardputer/example/main/gui.hpp b/components/m5stack-cardputer/example/main/gui.hpp old mode 100644 new mode 100755 index 403334051..320559a8f --- a/components/m5stack-cardputer/example/main/gui.hpp +++ b/components/m5stack-cardputer/example/main/gui.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -20,34 +21,54 @@ /// other tasks (the keyboard scanner callback, the main loop, etc.) can /// safely call them. /// -/// For this example the Gui is a tiny text editor driven by the Cardputer's -/// keyboard: -/// * A text area fills most of the screen; typed characters are appended to -/// it and backspace / enter / the fn-layer arrow keys edit it. -/// * A status bar at the bottom shows the battery voltage and the most recent -/// key / modifier activity. +/// The UI is organized as a tabview so each subsystem gets its own uncrowded +/// page; the Cardputer has no touchscreen, so tabs are switched from the +/// keyboard (fn+Tab cycles; fn+1 / fn+2 / fn+9 jump to Help / IMU / LoRa). A +/// slim status bar along the bottom stays visible on every tab for transient +/// messages (key names, volume changes, "Sending..."). +/// +/// * "Text" tab: a text area filling the page; typed characters are appended +/// and backspace / enter / the fn-layer arrow keys edit it. This is also +/// where LoRa messages are composed (fn+0 sends the text area's contents). +/// * "LoRa" tab: the radio status and a scrolling log of sent / received +/// messages with their RSSI / SNR. +/// * "IMU" tab: the live accelerometer / gyroscope readings (ADV only). +/// * "GPS" tab: the GNSS fix status (position, satellites, time) from the +/// LoRa+GPS Cap. +/// * "Sys" tab: board info - variant, battery, and audio volumes. +/// * "Help" tab: the list of controls. class Gui { public: /// Alias for the special (fn layer) keys of the Cardputer keyboard using SpecialKey = espp::M5StackCardputer::SpecialKey; - /// The controls for this example; shown in the help popup (fn+1) and - /// intended to also be printed to the log at startup. - static constexpr const char *HELP_TEXT = "Type keys to enter text\n" - "fn+; , . / move cursor\n" + /// The tabs of the UI, in bar order. COUNT is the number of tabs. + enum class Tab : uint8_t { + TEXT = 0, ///< The text editor / LoRa message composer + LORA, ///< LoRa radio status and message log + IMU, ///< Live IMU readings (ADV only) + GPS, ///< GNSS fix status (LoRa+GPS Cap) + SYS, ///< Board / battery / volume info + HELP, ///< The controls list + COUNT, ///< The number of tabs + }; + + /// The controls for this example; shown on the Help tab and printed to the + /// log at startup. + static constexpr const char *HELP_TEXT = "fn+Tab switch tab\n" + "fn+1/2/9 Help / IMU / LoRa tab\n" + "fn+; / . scroll (this + other\n" + " tabs; cursor on Text)\n" + "Type keys to enter text\n" + "fn+, / / move cursor (Text)\n" "fn+` clear text\n" "fn+bksp delete forward\n" - "fn+1 (F1) show/hide help\n" - "fn+2 (F2) show/hide IMU\n" + "fn+0 (F10) send text over LoRa\n" "fn+3 (F3) record / stop\n" "fn+4 (F4) play recording\n" "fn+5/6 speaker vol -/+\n" "fn+7/8 mic vol -/+\n" - "G0 button cycle LED color\n" - "\n" - "While help is open:\n" - "fn+; / fn+. scroll\n" - "fn+` or fn+1 close"; + "G0 button cycle LED color"; /// Configuration for the Gui struct Config { @@ -67,6 +88,18 @@ class Gui { deinit_ui(); } + /// Switch to the next tab (wrapping around). Thread-safe. + /// @return The tab now active + Tab next_tab(); + + /// Select a specific tab. Thread-safe. + /// @param tab The tab to switch to + void select_tab(Tab tab); + + /// Get the currently active tab. Thread-safe. + /// @return The active tab + Tab active_tab(); + /// Add a character to the text area. Handles backspace ('\b') by deleting /// the character before the cursor; other characters (including '\n' and /// '\t') are inserted at the cursor. Thread-safe. @@ -79,56 +112,96 @@ class Gui { /// @param key The special key to handle void handle_special_key(SpecialKey key); - /// Set the text of the status bar. Thread-safe. + /// Get the current contents of the text area (e.g. to send over LoRa). + /// Thread-safe. + /// @return The text currently in the text area + std::string get_text(); + + /// Clear the text area. Thread-safe. + void clear_text(); + + /// Set the text of the status bar (transient one-line messages, visible on + /// every tab). Thread-safe. /// @param text The text to display void set_status_text(std::string_view text); - /// Set the text of the IMU popup (top-right corner). The popup also shows - /// how to close it (fn+2). Thread-safe. + /// Set the text of the IMU tab. Thread-safe. /// @param text The text to display void set_imu_text(std::string_view text); - /// Toggle the visibility of the IMU popup. Thread-safe. - /// @return true if the popup is now visible - bool toggle_imu_visible(); + /// Set the text of the Sys (board info) tab. Thread-safe. + /// @param text The text to display + void set_system_text(std::string_view text); + + /// Set the text of the GPS tab. Thread-safe. + /// @param text The text to display + void set_gps_text(std::string_view text); - /// Get whether the IMU popup is currently visible - /// @return true if the popup is visible - bool imu_visible() const { return imu_visible_; } + /// Set the LoRa status line (radio state / frequency, or an error), shown + /// at the top of the LoRa tab. Thread-safe. + /// @param text The text to display + void set_lora_status(std::string_view text); - /// Toggle the help popup (which lists the controls). Thread-safe. - /// @return true if the popup is now shown - bool toggle_help(); + /// Append a message to the LoRa log (newest at the top; oldest dropped once + /// full). Thread-safe. + /// @param text The message line to add (e.g. a received or sent packet) + void add_lora_message(std::string_view text); protected: void init_ui(); void deinit_ui(); // the individual pieces of the UI, called from init_ui() - void init_background(); - void init_textarea(); + void init_tabview(); + void init_text_tab(); + void init_lora_tab(); + void init_imu_tab(); + void init_gps_tab(); + void init_sys_tab(); + void init_help_tab(); void init_status_bar(); - void init_imu_popup(); - void init_help_panel(); + + // rebuild the LoRa log label from lora_messages_; called with the mutex held + void update_lora_log(); + + // the (scrollable) content object of the active tab; called with the mutex + // held + lv_obj_t *active_tab_content(); + + // scroll the (horizontally scrollable) tab bar so the active tab's button is + // visible; called with the mutex held + void scroll_active_tab_into_view(); // the LVGL update task: calls lv_task_handler() under the mutex bool update(std::mutex &m, std::condition_variable &cv); // LVGL objects - lv_obj_t *background_{nullptr}; + lv_obj_t *tabview_{nullptr}; + lv_obj_t *text_tab_{nullptr}; + lv_obj_t *lora_tab_{nullptr}; + lv_obj_t *imu_tab_{nullptr}; + lv_obj_t *gps_tab_{nullptr}; + lv_obj_t *sys_tab_{nullptr}; + lv_obj_t *help_tab_{nullptr}; lv_obj_t *textarea_{nullptr}; lv_obj_t *status_label_{nullptr}; - lv_obj_t *imu_popup_{nullptr}; lv_obj_t *imu_label_{nullptr}; - lv_obj_t *help_panel_{nullptr}; + lv_obj_t *gps_label_{nullptr}; + lv_obj_t *sys_label_{nullptr}; + lv_obj_t *lora_status_label_{nullptr}; + lv_obj_t *lora_log_label_{nullptr}; + + // recent LoRa log lines (newest at the front) + std::deque lora_messages_; + static constexpr size_t MAX_LORA_MESSAGES = 8; - // the IMU popup starts hidden; the app shows it (via toggle_imu_visible()) - // once the IMU has been successfully initialized - std::atomic imu_visible_{false}; - std::atomic help_visible_{false}; + static constexpr int TAB_BAR_HEIGHT = 28; + static constexpr int STATUS_BAR_HEIGHT = 18; espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, - .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; + // the tabview (nested containers + flex layout) uses + // more stack to render than a flat UI + .task_config = {.name = "gui", .stack_size_bytes = 10 * 1024}}}; espp::Logger logger_; std::recursive_mutex mutex_; }; diff --git a/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp b/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp index 80c309076..b24a36aa5 100644 --- a/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp +++ b/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp @@ -1,8 +1,13 @@ +#include #include #include #include #include +#include +#include #include +#include +#include #include #include @@ -37,6 +42,23 @@ static std::atomic playing{false}; static int64_t recording_start_us = 0; static int64_t recording_last_us = 0; +// LoRa send state: fn+0 (F10) captures the text to send and requests a +// transmit; the main loop performs the (blocking) transmit so the keyboard +// scanner task stays responsive. +static std::atomic lora_send_requested{false}; +static std::mutex lora_tx_mutex; +static std::string lora_tx_message; + +// render a received LoRa payload as printable text (non-printable bytes, e.g. +// from an unrelated transmission on the same frequency, are shown as '.') so a +// stray frame cannot corrupt the on-screen log +static std::string printable(const std::vector &data) { + std::string out(data.size(), '.'); + std::transform(data.begin(), data.end(), out.begin(), + [](uint8_t b) { return (b >= 0x20 && b < 0x7f) ? static_cast(b) : '.'; }); + return out; +} + // Synthesize a short fading sine beep and queue it on the speaker static void play_beep(espp::M5StackCardputer &cardputer, float frequency_hz) { static constexpr float duration_s = 0.05f; @@ -96,10 +118,22 @@ extern "C" void app_main(void) { // print the controls (also available on-screen via the fn+1 help popup) logger.info("Controls:\n{}", Gui::HELP_TEXT); - // whether the board has a working IMU / microphone; set after keyboard / - // variant detection below, referenced by the keypress callback + // whether the board has a working IMU / microphone / LoRa radio / GPS; set + // after keyboard / variant detection below, referenced by the keypress + // callback static bool have_imu = false; static bool have_mic = false; + static bool have_lora = false; + static bool have_gps = false; + static std::shared_ptr lora_radio; + + // The text being composed lives in the (hidden-when-not-active) Text tab, so + // on the LoRa tab mirror it into the status bar as it is typed - otherwise + // you cannot see what you are about to send. + auto show_lora_compose = [&]() { + std::string composed = gui.get_text(); + gui.set_status_text(composed.empty() ? "Type a message; fn+0 sends" : ("> " + composed)); + }; // the keyboard scanner delivers one event per key state change; use it to // drive the text editor, play key-click sounds, and show what's happening @@ -108,19 +142,25 @@ extern "C" void app_main(void) { if (!event.pressed) { return; } + // fn+Tab cycles through the tabs. fn does not change a key's character + // value, so fn+Tab arrives as a Tab press ('\t') with the fn modifier held. + if (event.modifiers.fn && event.value == '\t') { + Gui::Tab tab = gui.next_tab(); + if (tab == Gui::Tab::LORA && have_lora) { + show_lora_compose(); + } + play_beep(cardputer, 660.0f); + return; + } if (event.special == espp::M5StackCardputer::SpecialKey::F1) { - // toggle the help popup - bool shown = gui.toggle_help(); - gui.set_status_text(shown ? "Help (fn+1 to close)" : "Ready"); + // jump to the Help tab + gui.select_tab(Gui::Tab::HELP); + gui.set_status_text("Help tab"); play_beep(cardputer, 660.0f); } else if (event.special == espp::M5StackCardputer::SpecialKey::F2) { - // toggle the IMU popup - if (have_imu) { - bool visible = gui.toggle_imu_visible(); - gui.set_status_text(visible ? "IMU popup shown" : "IMU popup hidden"); - } else { - gui.set_status_text("No IMU on this board"); - } + // jump to the IMU tab + gui.select_tab(Gui::Tab::IMU); + gui.set_status_text(have_imu ? "IMU tab" : "No IMU on this board"); play_beep(cardputer, 660.0f); } else if (event.special == espp::M5StackCardputer::SpecialKey::F3) { // start / stop recording from the microphone @@ -169,12 +209,47 @@ extern "C" void app_main(void) { gui.set_status_text( fmt::format("Mic volume: {:.0f}% (75% = 0 dB)", cardputer.microphone_volume())); play_beep(cardputer, 660.0f); + } else if (event.special == espp::M5StackCardputer::SpecialKey::F9) { + // jump to the LoRa tab + gui.select_tab(Gui::Tab::LORA); + if (have_lora) { + show_lora_compose(); + } else { + gui.set_status_text("No LoRa module (attach the Cap)"); + } + play_beep(cardputer, 660.0f); + } else if (event.special == espp::M5StackCardputer::SpecialKey::F10) { + // send the current text area contents over LoRa (the main loop performs + // the actual, blocking transmit) + if (!have_lora) { + gui.set_status_text("No LoRa module (attach the Cap)"); + } else { + std::string text = gui.get_text(); + if (text.empty()) { + gui.set_status_text("Type a message first, then fn+0"); + } else { + { + std::lock_guard lk(lora_tx_mutex); + lora_tx_message = text; + } + lora_send_requested = true; + gui.clear_text(); + gui.select_tab(Gui::Tab::LORA); + gui.set_status_text("Sending over LoRa..."); + } + } + play_beep(cardputer, 660.0f); } else if (event.special != espp::M5StackCardputer::SpecialKey::NONE) { gui.handle_special_key(event.special); gui.set_status_text(espp::M5StackCardputer::special_key_name(event.special)); play_beep(cardputer, 660.0f); } else if (event.value != 0) { gui.add_char(event.value); + // on the LoRa tab, mirror the composed text into the status bar so it is + // visible as you type (the text box itself is on the Text tab) + if (gui.active_tab() == Gui::Tab::LORA && have_lora) { + show_lora_compose(); + } play_beep(cardputer, 880.0f); } else { // a modifier key by itself @@ -204,12 +279,12 @@ extern "C" void app_main(void) { // on one (warn and continue otherwise - the original has no IMU) if (cardputer.variant() == espp::M5StackCardputer::Variant::ADV) { have_imu = cardputer.initialize_imu(); - if (have_imu) { - // show the IMU popup by default (fn+2 toggles it) - gui.toggle_imu_visible(); - } else { + if (!have_imu) { logger.warn("Could not initialize the IMU!"); + gui.set_imu_text("IMU init failed"); } + } else { + gui.set_imu_text("No IMU on this board\n(Cardputer ADV only)"); } // On the ADV the ES8311 codec runs full duplex, so the microphone can be @@ -277,16 +352,95 @@ extern "C" void app_main(void) { // set the initial LED color cardputer.led(espp::Hsv(static_cast(led_hue), 1.0f, 0.2f)); - // Main loop: stream any active playback to the speaker, update the IMU - // popup, and periodically show the battery voltage / state of charge in - // the status bar + // Initialize the LoRa radio on the LoRa+GPS Cap (SX1262) and wire it to the + // LoRa tab. This uses the same radio settings as the T-Deck example (US + // LongFast modulation, private sync word 0x12), so a Cardputer and a T-Deck + // each running their example will exchange text messages. The Cap is a + // Cardputer ADV accessory; on a board without it, initialization fails and + // the LoRa tab reports that it is unavailable. + espp::Sx126x::RadioConfig lora_config{}; + lora_config.sync_word = 0x12; // private link, matches the t-deck example + have_lora = cardputer.initialize_lora(lora_config); + if (have_lora) { + lora_radio = cardputer.lora(); + // deliver received packets to the LoRa tab (runs in the BSP interrupt task) + lora_radio->set_receive_callback([&](const espp::Sx126x::RxPacket &packet) { + gui.add_lora_message( + fmt::format("RX {:.0f}dBm: {}", packet.status.rssi, printable(packet.data))); + }); + std::error_code ec; + if (lora_radio->start_receive(ec)) { + gui.set_lora_status(fmt::format("Listening @ {:.3f} MHz, SF11/BW250 (fn+0 sends)", + lora_radio->radio_config().frequency_hz / 1e6f)); + } else { + logger.error("Failed to start LoRa receive: {}", ec.message()); + gui.set_lora_status(fmt::format("LoRa RX failed: {}", ec.message())); + have_lora = false; + } + } else { + logger.warn("Could not initialize LoRa (is the LoRa+GPS Cap attached? ADV only)"); + gui.set_lora_status("LoRa unavailable (attach the Cap)"); + } + + // Initialize the GNSS receiver on the same LoRa+GPS Cap (ATGM336H, 115200 + // baud) and show the fix on the GPS tab. GPS and LoRa are both on the Cap, + // so use the LoRa result as the "Cap present" signal; the fix callback runs + // on the GPS reader task (its GUI calls are thread-safe). + if (have_lora) { + gui.set_gps_text("GPS: acquiring fix...\n(needs a clear sky view)"); + have_gps = cardputer.initialize_gps([&](const espp::GpsFix &fix) { + if (fix.valid) { + gui.set_gps_text(fmt::format("Fix: {} sats HDOP {:.1f}\n{:.5f}, {:.5f}\nAlt {:.0f} m\n" + "{:02d}:{:02d}:{:04.1f} UTC\n{:.1f} kn {:.0f} deg", + (int)fix.num_satellites, fix.hdop, fix.latitude, fix.longitude, + fix.altitude, (int)fix.hour, (int)fix.minute, fix.second, + fix.speed_knots, fix.course_degrees)); + } else { + gui.set_gps_text(fmt::format("Acquiring fix...\n{} sats in view\n(needs a clear sky view)", + (int)fix.num_satellites)); + } + }); + if (!have_gps) { + logger.warn("Could not initialize the GPS!"); + gui.set_gps_text("GPS init failed (see log)"); + } + } else { + gui.set_gps_text("GPS unavailable\n(attach the LoRa+GPS Cap)"); + } + + // Main loop: service LoRa sends, stream any active playback to the speaker, + // update the IMU / Sys tabs, and periodically show the battery voltage / + // state of charge in the status bar static constexpr auto loop_period = 50ms; - const int loops_per_imu_update = 2; // 100 ms + const int loops_per_imu_update = 2; // 100 ms + const int loops_per_sys_update = std::chrono::seconds(1) / loop_period; // 1 s const int loops_per_battery_update = std::chrono::seconds(5) / loop_period; size_t play_offset = 0; bool was_recording = false; int loop_count = 0; while (true) { + // service a LoRa send requested from the keyboard (fn+0). transmit() + // blocks for the packet's time-on-air (a few hundred ms at SF11) then + // returns the radio to receive, so doing it here keeps the keyboard and + // LVGL tasks responsive. + if (have_lora && lora_send_requested.exchange(false)) { + std::string msg; + { + std::lock_guard lk(lora_tx_mutex); + msg = lora_tx_message; + } + std::span payload{reinterpret_cast(msg.data()), msg.size()}; + std::error_code ec; + if (lora_radio->transmit(payload, 3s, ec)) { + gui.add_lora_message("TX: " + msg); + gui.set_status_text("Sent over LoRa"); + logger.info("LoRa sent: {}", msg); + } else { + gui.add_lora_message(fmt::format("TX failed: {}", ec.message())); + gui.set_status_text("LoRa send failed"); + logger.error("LoRa transmit failed: {}", ec.message()); + } + } // feed the active playback in chunks, advancing by however much the // speaker's stream buffer accepted if (playing) { @@ -318,7 +472,9 @@ extern "C" void app_main(void) { } was_recording = now_recording; - if (have_imu && gui.imu_visible() && (loop_count % loops_per_imu_update) == 0) { + // update the IMU tab only while it is the active tab (the readings are + // only visible there) + if (have_imu && gui.active_tab() == Gui::Tab::IMU && (loop_count % loops_per_imu_update) == 0) { auto imu = cardputer.imu(); std::error_code ec; if (imu->update(std::chrono::duration(loop_period * loops_per_imu_update).count(), @@ -329,8 +485,20 @@ extern "C" void app_main(void) { accel.x, accel.y, accel.z, gyro.x, gyro.y, gyro.z)); } } - // don't overwrite the recording / playback status with the battery - if ((loop_count % loops_per_battery_update) == 0 && !recording && !playing) { + // keep the Sys tab's board info current + if ((loop_count % loops_per_sys_update) == 0) { + gui.set_system_text(fmt::format( + "Board: {}\nBattery: {:.2f} V ({:.0f}%)\nSpeaker {:.0f}% Mic {:.0f}%\nAudio {} Hz\n" + "LoRa {} GPS {}", + espp::M5StackCardputer::variant_name(cardputer.variant()), cardputer.battery_voltage(), + cardputer.battery_soc(), cardputer.volume(), cardputer.microphone_volume(), + cardputer.audio_sample_rate(), have_lora ? "on" : "off", have_gps ? "on" : "off")); + } + // Periodically show the battery in the status bar - but not on the LoRa + // tab, where the status bar mirrors the message being composed (see the + // keypress callback), nor while recording / playing. + if ((loop_count % loops_per_battery_update) == 0 && !recording && !playing && + gui.active_tab() != Gui::Tab::LORA) { gui.set_status_text(fmt::format("Battery: {:.2f} V ({:.0f}%)", cardputer.battery_voltage(), cardputer.battery_soc())); } diff --git a/components/m5stack-cardputer/idf_component.yml b/components/m5stack-cardputer/idf_component.yml old mode 100644 new mode 100755 index 72b45d7cd..3038df302 --- a/components/m5stack-cardputer/idf_component.yml +++ b/components/m5stack-cardputer/idf_component.yml @@ -22,12 +22,14 @@ dependencies: espp/bmi270: '>=1.0' espp/display: '>=1.0' espp/display_drivers: '>=1.0' + espp/gps: '>=1.0' espp/i2c: '>=1.0' espp/interrupt: '>=1.0' espp/led: '>=1.0' espp/math: '>=1.0' espp/neopixel: '>=1.0' espp/spi: '>=1.0' + espp/sx126x: '>=1.0' espp/task: '>=1.0' targets: - esp32s3 diff --git a/components/m5stack-cardputer/include/m5stack-cardputer.hpp b/components/m5stack-cardputer/include/m5stack-cardputer.hpp old mode 100644 new mode 100755 index 7a1936ed3..5df294870 --- a/components/m5stack-cardputer/include/m5stack-cardputer.hpp +++ b/components/m5stack-cardputer/include/m5stack-cardputer.hpp @@ -28,6 +28,7 @@ #include "base_component.hpp" #include "bmi270.hpp" #include "fast_math.hpp" +#include "gps.hpp" #include "i2c.hpp" #include "interrupt.hpp" #include "led.hpp" @@ -35,6 +36,7 @@ #include "oneshot_adc.hpp" #include "spi.hpp" #include "st7789.hpp" +#include "sx126x.hpp" #include "task.hpp" namespace espp { @@ -59,6 +61,8 @@ namespace espp { /// - IR transmitter and Grove port pin definitions /// - Internal I2C bus accessor (ADV only; also hosts a BMI270 IMU at 0x68 /// which can be used with the espp bmi270 component) +/// - LoRa radio + GNSS receiver on the LoRa+GPS Cap (U201 / U214) expansion +/// module for the Cardputer ADV (SX1262 + ATGM336H) /// /// The class is a singleton and can be accessed using the get() method. /// @@ -566,6 +570,67 @@ class M5StackCardputer : public BaseComponent { /// (successfully) initialized std::shared_ptr imu() const { return imu_; } + ///////////////////////////////////////////////////////////////////////////// + // LoRa + GPS Cap (U201 / U214 expansion module for the Cardputer ADV) + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the LoRa radio (SX1262) on the LoRa+GPS Cap + /// \param radio_config The radio (modem) configuration to apply + /// \return True if the radio was initialized properly + /// \note The Cap mounts on the Cardputer ADV's rear expansion connector; + /// the radio shares the uSD card's SPI bus with its own chip select, + /// so the radio and uSD card can be used together. + /// \note On the U214 Cap the radio's RF switch is controlled through a + /// PI4IOE5V6408 I2C IO expander, which this method configures to + /// connect the antenna. If no expander is found (e.g. the older U201 + /// Cap), initialization proceeds without it. + /// \note The radio's DIO1 interrupt is automatically serviced via the + /// board's interrupt handler, so packets are delivered through the + /// callbacks registered on the returned driver (see + /// espp::Sx126x::set_receive_callback and friends). + bool initialize_lora(const Sx126x::RadioConfig &radio_config = {}); + + /// Get the LoRa radio + /// \return A shared pointer to the LoRa radio driver + /// \note The radio is only available if initialize_lora() succeeded + std::shared_ptr lora() const { return lora_; } + + /// Initialize the GNSS receiver (ATGM336H) on the LoRa+GPS Cap + /// \param fix_cb Optional callback invoked on each fix update + /// \param baud_rate The baud rate of the GPS UART. The Cap ships + /// configured for 115200 baud. + /// \return True if the GPS was initialized properly + bool initialize_gps(const Gps::fix_callback_fn &fix_cb = nullptr, uint32_t baud_rate = 115200); + + /// Get the GPS + /// \return A shared pointer to the GPS driver + /// \note The GPS is only available if initialize_gps() succeeded + std::shared_ptr gps() const { return gps_; } + + /// Get the GPIO pin for the LoRa radio chip select + /// \return The GPIO pin for the LoRa radio chip select + static constexpr auto lora_cs_gpio() { return lora_cs_io; } + + /// Get the GPIO pin for the LoRa radio DIO1 (interrupt) line + /// \return The GPIO pin for the LoRa radio DIO1 line + static constexpr auto lora_dio1_gpio() { return lora_dio1_io; } + + /// Get the GPIO pin for the LoRa radio BUSY line + /// \return The GPIO pin for the LoRa radio BUSY line + static constexpr auto lora_busy_gpio() { return lora_busy_io; } + + /// Get the GPIO pin for the LoRa radio reset line + /// \return The GPIO pin for the LoRa radio reset line + static constexpr auto lora_reset_gpio() { return lora_reset_io; } + + /// Get the GPIO pin for the GPS UART TX (ESP32 -> GPS) + /// \return The GPIO pin for the GPS UART TX + static constexpr auto gps_tx_gpio() { return gps_tx_io; } + + /// Get the GPIO pin for the GPS UART RX (GPS -> ESP32) + /// \return The GPIO pin for the GPS UART RX + static constexpr auto gps_rx_gpio() { return gps_rx_io; } + ///////////////////////////////////////////////////////////////////////////// // Misc. pins (IR transmitter, Grove port) ///////////////////////////////////////////////////////////////////////////// @@ -593,6 +658,8 @@ class M5StackCardputer : public BaseComponent { bool keyboard_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified); void detect_variant(); bool initialize_keyboard_matrix(); + bool ensure_expansion_spi_bus(); + bool enable_cap_expander_outputs(uint8_t mask); bool initialize_keyboard_tca8418(); void scan_keyboard_matrix(); void process_tca8418_events(); @@ -783,6 +850,22 @@ class M5StackCardputer : public BaseComponent { static constexpr gpio_num_t sdcard_miso = GPIO_NUM_39; static constexpr gpio_num_t sdcard_cs = GPIO_NUM_12; + // LoRa + GPS Cap (U201 / U214) on the rear expansion connector. The + // SX1262 shares the uSD card's SPI bus with its own chip select. + static constexpr gpio_num_t lora_cs_io = GPIO_NUM_5; + static constexpr gpio_num_t lora_dio1_io = GPIO_NUM_4; + static constexpr gpio_num_t lora_busy_io = GPIO_NUM_6; + static constexpr gpio_num_t lora_reset_io = GPIO_NUM_3; + static constexpr int lora_spi_clock_speed = 8 * 1000 * 1000; + static constexpr gpio_num_t gps_tx_io = GPIO_NUM_13; // ESP32 -> GPS + static constexpr gpio_num_t gps_rx_io = GPIO_NUM_15; // GPS -> ESP32 + static constexpr auto gps_uart_port = UART_NUM_1; + // PI4IOE5V6408 IO expander on the U214 Cap (internal I2C bus): + // P0 = LoRa RF switch (high = antenna connected), P1 = GPS LNA enable + static constexpr uint8_t cap_expander_address = 0x43; + static constexpr uint8_t cap_expander_lora_rf_switch_mask = 0x01; + static constexpr uint8_t cap_expander_gps_lna_mask = 0x02; + // RGB LED (WS2812 on the StampS3 module) static constexpr gpio_num_t rgb_led_io = GPIO_NUM_21; @@ -820,6 +903,24 @@ class M5StackCardputer : public BaseComponent { // sdcard sdmmc_card_t *sdcard_{nullptr}; + // whether the (shared) uSD / LoRa Cap SPI bus has been initialized + bool expansion_spi_bus_initialized_{false}; + + // LoRa + GPS Cap + spi_device_handle_t lora_spi_handle_{nullptr}; + std::shared_ptr lora_{nullptr}; + std::shared_ptr gps_{nullptr}; + espp::Interrupt::PinConfig lora_dio1_interrupt_pin_{ + .gpio_num = lora_dio1_io, + .callback = + [this](const auto &event) { + if (lora_) { + std::error_code ec; + lora_->handle_dio1_interrupt(ec); + } + }, + .active_level = espp::Interrupt::ActiveLevel::HIGH, + .interrupt_type = espp::Interrupt::Type::RISING_EDGE}; // RGB LED std::shared_ptr rgb_led_{nullptr}; diff --git a/components/m5stack-cardputer/src/lora_gps_cap.cpp b/components/m5stack-cardputer/src/lora_gps_cap.cpp new file mode 100755 index 000000000..a0f24515f --- /dev/null +++ b/components/m5stack-cardputer/src/lora_gps_cap.cpp @@ -0,0 +1,199 @@ +#include "m5stack-cardputer.hpp" + +#include + +using namespace espp; + +///////////////////////////////////////////////////////////////////////////// +// LoRa + GPS Cap (U201 / U214 expansion module for the Cardputer ADV) +///////////////////////////////////////////////////////////////////////////// + +bool M5StackCardputer::ensure_expansion_spi_bus() { + if (expansion_spi_bus_initialized_) { + return true; + } + spi_bus_config_t bus_cfg; + memset(&bus_cfg, 0, sizeof(bus_cfg)); + bus_cfg.mosi_io_num = sdcard_mosi; + bus_cfg.miso_io_num = sdcard_miso; + bus_cfg.sclk_io_num = sdcard_sclk; + bus_cfg.quadwp_io_num = -1; + bus_cfg.quadhd_io_num = -1; + bus_cfg.max_transfer_sz = 4092; + + auto ret = spi_bus_initialize(sdcard_spi_num, &bus_cfg, SDSPI_DEFAULT_DMA); + if (ret != ESP_OK) { + logger_.error("Failed to initialize expansion SPI bus: {}", esp_err_to_name(ret)); + return false; + } + expansion_spi_bus_initialized_ = true; + return true; +} + +bool M5StackCardputer::enable_cap_expander_outputs(uint8_t mask) { + // The U214 Cap has a PI4IOE5V6408 IO expander on the internal I2C bus + // controlling the LoRa RF switch (P0) and the GPS LNA (P1). The older U201 + // Cap has no expander, in which case this is a no-op. + auto *i2c = internal_i2c(); + if (!i2c) { + logger_.warn("No internal I2C bus (original Cardputer?); cannot configure the Cap's IO " + "expander - the LoRa+GPS Cap is designed for the Cardputer ADV"); + return false; + } + if (!i2c->probe_device(cap_expander_address)) { + logger_.info("No IO expander found on the Cap (older U201 Cap?); assuming the RF switch / " + "GPS LNA are hardwired"); + return true; + } + // registers: 0x03 = IO direction (1 = output), 0x05 = output state, + // 0x07 = output high-impedance (1 = Hi-Z, the power-on default) + const uint8_t reg_direction = 0x03; + const uint8_t reg_output = 0x05; + const uint8_t reg_high_z = 0x07; + auto update_register = [&](uint8_t reg, uint8_t set_mask, uint8_t clear_mask) -> bool { + uint8_t value = 0; + if (!i2c->write_read(cap_expander_address, ®, 1, &value, 1)) { + return false; + } + value = (value | set_mask) & ~clear_mask; + const uint8_t data[2] = {reg, value}; + return i2c->write(cap_expander_address, data, 2); + }; + if (!update_register(reg_direction, mask, 0) || !update_register(reg_output, mask, 0) || + !update_register(reg_high_z, 0, mask)) { + logger_.error("Failed to configure the Cap's IO expander"); + return false; + } + return true; +} + +bool M5StackCardputer::initialize_lora(const Sx126x::RadioConfig &radio_config) { + if (lora_) { + logger_.warn("LoRa radio already initialized, not initializing again!"); + return false; + } + + // ensure the (shared) SPI bus is initialized + if (!ensure_expansion_spi_bus()) { + return false; + } + + logger_.info("Initializing LoRa radio"); + + // connect the antenna (via the Cap's IO expander, if present). A false return + // here means the expander is present but could not be configured (a hardwired + // Cap with no expander returns true), so the RF switch may be left Hi-Z and + // the antenna disconnected - warn loudly but continue so the radio still comes + // up for diagnostics. + if (!enable_cap_expander_outputs(cap_expander_lora_rf_switch_mask)) { + logger_.warn("Failed to enable the LoRa RF switch on the Cap's IO expander; the antenna may " + "be disconnected and range will be severely degraded"); + } + + // add the radio to the SPI bus + spi_device_interface_config_t dev_cfg; + memset(&dev_cfg, 0, sizeof(dev_cfg)); + dev_cfg.mode = 0; + dev_cfg.clock_speed_hz = lora_spi_clock_speed; + dev_cfg.spics_io_num = lora_cs_io; + dev_cfg.queue_size = 1; + auto ret = spi_bus_add_device(sdcard_spi_num, &dev_cfg, &lora_spi_handle_); + if (ret != ESP_OK) { + logger_.error("Failed to add LoRa radio to SPI bus: {}", esp_err_to_name(ret)); + return false; + } + + // configure the radio's control pins + gpio_set_direction(lora_busy_io, GPIO_MODE_INPUT); + gpio_set_direction(lora_reset_io, GPIO_MODE_OUTPUT); + gpio_set_level(lora_reset_io, 1); + + // The SX126x requires chip select to be held asserted across the write and + // read phases of a command, so write_then_read is implemented as a single + // full-duplex transfer of the concatenated length. + auto handle = lora_spi_handle_; + auto write_fn = [handle](const uint8_t *data, size_t length) -> bool { + spi_transaction_t t; + memset(&t, 0, sizeof(t)); + t.length = length * 8; + t.tx_buffer = data; + return spi_device_polling_transmit(handle, &t) == ESP_OK; + }; + auto write_then_read_fn = [handle](const uint8_t *write_data, size_t write_length, + uint8_t *read_data, size_t read_length) -> bool { + std::vector tx(write_length + read_length, 0); + std::vector rx(write_length + read_length, 0); + memcpy(tx.data(), write_data, write_length); + spi_transaction_t t; + memset(&t, 0, sizeof(t)); + t.length = tx.size() * 8; + t.tx_buffer = tx.data(); + t.rx_buffer = rx.data(); + if (spi_device_polling_transmit(handle, &t) != ESP_OK) { + return false; + } + memcpy(read_data, rx.data() + write_length, read_length); + return true; + }; + + std::error_code ec; + lora_ = std::make_shared( + Sx126x::Config{.variant = Sx126x::Variant::SX1262, + .write = write_fn, + .write_then_read = write_then_read_fn, + .is_busy = []() -> bool { return gpio_get_level(lora_busy_io); }, + .reset = [](bool level) { gpio_set_level(lora_reset_io, level); }, + // The Stamp LoRa-1262 Mini module has a TCXO powered from + // DIO3 at 3.0V and uses the LDO regulator (matching + // M5Stack's own RadioLib example: begin(..., 3.0, true)). + // Without powering the TCXO the oscillator never starts, + // so the radio can talk over SPI but cannot transmit or + // receive. The RF switch is driven by the Cap's IO + // expander (not DIO2), but enabling DIO2 control is + // harmless as it is unconnected on this module. + .tcxo_voltage = 3.0f, + .use_dio2_as_rf_switch = true, + .use_dcdc_regulator = false, + .radio_config = radio_config, + .auto_init = false, + .log_level = get_log_level()}); + if (!lora_->initialize(ec)) { + logger_.error("Failed to initialize LoRa radio: {}", ec.message()); + lora_.reset(); + spi_bus_remove_device(lora_spi_handle_); + lora_spi_handle_ = nullptr; + return false; + } + + // service the radio's IRQs whenever DIO1 goes high + interrupts_.add_interrupt(lora_dio1_interrupt_pin_); + + return true; +} + +bool M5StackCardputer::initialize_gps(const Gps::fix_callback_fn &fix_cb, uint32_t baud_rate) { + if (gps_) { + logger_.warn("GPS already initialized, not initializing again!"); + return false; + } + + logger_.info("Initializing GPS"); + + // enable the GPS LNA (via the Cap's IO expander, if present) + enable_cap_expander_outputs(cap_expander_gps_lna_mask); + + gps_ = std::make_shared(Gps::Config{.uart_port = gps_uart_port, + .tx_io_num = gps_tx_io, + .rx_io_num = gps_rx_io, + .baud_rate = baud_rate, + .on_fix = fix_cb, + .auto_start = false, + .log_level = get_log_level()}); + std::error_code ec; + if (!gps_->start(ec)) { + logger_.error("Failed to start GPS: {}", ec.message()); + gps_.reset(); + return false; + } + return true; +} diff --git a/components/m5stack-cardputer/src/sdcard.cpp b/components/m5stack-cardputer/src/sdcard.cpp old mode 100644 new mode 100755 index b6326e5dc..0377f795c --- a/components/m5stack-cardputer/src/sdcard.cpp +++ b/components/m5stack-cardputer/src/sdcard.cpp @@ -16,19 +16,10 @@ bool M5StackCardputer::initialize_sdcard(const SdCardConfig &config) { logger_.info("Initializing SD card"); - // The uSD card is on its own SPI bus (not shared with the LCD) - spi_bus_config_t bus_cfg; - memset(&bus_cfg, 0, sizeof(bus_cfg)); - bus_cfg.mosi_io_num = sdcard_mosi; - bus_cfg.miso_io_num = sdcard_miso; - bus_cfg.sclk_io_num = sdcard_sclk; - bus_cfg.quadwp_io_num = -1; - bus_cfg.quadhd_io_num = -1; - bus_cfg.max_transfer_sz = 4092; - - auto ret = spi_bus_initialize(sdcard_spi_num, &bus_cfg, SDSPI_DEFAULT_DMA); - if (ret != ESP_OK) { - logger_.error("Failed to initialize SPI bus for SD card: {}", esp_err_to_name(ret)); + // The uSD card is on its own SPI bus (not shared with the LCD, but shared + // with the LoRa+GPS Cap's radio) + if (!ensure_expansion_spi_bus()) { + logger_.error("Failed to initialize SPI bus for SD card"); return false; } @@ -46,7 +37,7 @@ bool M5StackCardputer::initialize_sdcard(const SdCardConfig &config) { mount_config.allocation_unit_size = config.allocation_unit_size; logger_.debug("Mounting filesystem"); - ret = esp_vfs_fat_sdspi_mount(mount_point, &host, &slot_config, &mount_config, &sdcard_); + auto ret = esp_vfs_fat_sdspi_mount(mount_point, &host, &slot_config, &mount_config, &sdcard_); if (ret != ESP_OK) { if (ret == ESP_FAIL) { @@ -57,7 +48,11 @@ bool M5StackCardputer::initialize_sdcard(const SdCardConfig &config) { "resistors in place.", esp_err_to_name(ret)); } - spi_bus_free(sdcard_spi_num); + // only free the bus if the LoRa radio isn't using it + if (!lora_) { + spi_bus_free(sdcard_spi_num); + expansion_spi_bus_initialized_ = false; + } sdcard_ = nullptr; return false; } diff --git a/components/meshtastic/CMakeLists.txt b/components/meshtastic/CMakeLists.txt new file mode 100755 index 000000000..d53579830 --- /dev/null +++ b/components/meshtastic/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES "base_component" "mbedtls" "esp_hw_support" "esp_common" + ) diff --git a/components/meshtastic/README.md b/components/meshtastic/README.md new file mode 100755 index 000000000..07fbddf6f --- /dev/null +++ b/components/meshtastic/README.md @@ -0,0 +1,71 @@ +# Meshtastic Component + +[![Badge](https://components.espressif.com/components/espp/meshtastic/badge.svg)](https://components.espressif.com/components/espp/meshtastic) + +The `MeshtasticNode` component is a minimal, radio-agnostic implementation of +the Meshtastic® over-the-air mesh protocol. It implements enough of the +protocol to interoperate with stock Meshtastic devices on a shared channel +(the public "LongFast" channel by default): framing, AES-CTR encryption with +the well-known default channel key, sending and receiving text messages, node +info, and positions, receive-side deduplication, and optional managed-flood +rebroadcasting. + +It is decoupled from any particular radio - you provide a transmit function +and feed it received frames. It pairs naturally with the espp `sx126x` +component (an SX1262 LoRa radio, as found on the LilyGo T-Deck and the M5Stack +Cardputer LoRa+GPS Cap), which is what the example uses. Call `modem_config()` +to get the exact LoRa modulation parameters (frequency, bandwidth, spreading +factor, coding rate, sync word) to apply to your radio for the configured +region / preset / channel. + +## Scope + +This component targets basic interoperability, not feature parity with the +Meshtastic firmware. It implements: + +- The public / PSK-encrypted channel model (default and custom PSK channels) +- Text messages, node info (so your node appears in others' node lists), and + positions (which you can feed from the espp `gps` component) +- The frequency-slot / channel-hash algorithms for the US, EU868, EU433 and + ANZ regions +- Receive-side dedup and optional rebroadcasting + +It does **not** implement: PKI-encrypted direct messages, MQTT, telemetry, +store-and-forward, the admin protocol, or SNR-weighted rebroadcast timing. A +node in the default receive/originate-only mode (no rebroadcast) still fully +interoperates and will appear in stock devices' node lists. + +## Protobuf handling + +Meshtastic payloads are Protocol Buffers. Rather than pull in a code +generator, this component includes small hand-written encoders/decoders for +the specific messages it uses (`Data`, `User`, `Position`) in +`meshtastic_protobuf.hpp`. The protobuf wire format is a stable, public +format, and only the handful of fields needed for interop are handled; unknown +fields are skipped on decode. + +## Legal / trademark notice + +This is an independent, clean-room implementation of the *published* +Meshtastic protocol. It is **not affiliated with, endorsed by, or sponsored +by Meshtastic LLC.** "Meshtastic®" is a registered trademark of Meshtastic +LLC; the name is used here only nominatively, to describe compatibility. Do +not use the Meshtastic name or logo to brand products or services built on +this component. See . + +The Meshtastic firmware and protobuf definitions are licensed GPL-3.0. This +component contains no code copied from those projects - the protocol constants +(default PSK, packet layout, hash algorithms) and the protobuf field +definitions used here are facts about the wire format, gathered from the +public protocol documentation and `.proto` schema. If you intend to +distribute a product based on this component, review the licensing and +trademark implications for your use case. + +## Example + +The [example](./example) builds a working Meshtastic node on either the LilyGo +T-Deck or the M5Stack Cardputer-Adv (with the LoRa+GPS Cap), selectable via +menuconfig. It initializes the board's SX1262 radio via the BSP, applies the +modem configuration, periodically broadcasts node info and a text ping, and +prints received text messages, node info and positions. Point a stock +Meshtastic device at the same region/channel and the two will see each other. diff --git a/components/meshtastic/example/CMakeLists.txt b/components/meshtastic/example/CMakeLists.txt new file mode 100755 index 000000000..e9c22973b --- /dev/null +++ b/components/meshtastic/example/CMakeLists.txt @@ -0,0 +1,25 @@ +# 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.20) + +# NOTE: we don't use the IDF component manager for the examples in this +# repository since the examples use the local versions of the components +set(ENV{IDF_COMPONENT_MANAGER} "0") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/" +) + +set( + COMPONENTS + "main esptool_py meshtastic sx126x logger task t-deck m5stack-cardputer" + CACHE STRING + "List of components to include" + ) + +project(meshtastic_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/meshtastic/example/README.md b/components/meshtastic/example/README.md new file mode 100755 index 000000000..8898f66c8 --- /dev/null +++ b/components/meshtastic/example/README.md @@ -0,0 +1,40 @@ +# Meshtastic Example + +This example builds a minimal Meshtastic-compatible node on either the LilyGo +T-Deck or the M5Stack Cardputer-Adv (with the LoRa+GPS Cap), selectable via +menuconfig. It brings up the board's SX1262 radio through the BSP, applies the +LongFast modem configuration for the selected region, listens for Meshtastic +traffic, periodically broadcasts its node info and a text ping, and prints +received text messages, node info, and positions. + +Point a stock Meshtastic device (set to the same region and the default +LongFast channel) at it and the two should see each other: your espp node will +appear in the device's node list, and text messages will flow both ways. + +## How to use example + +### Hardware Required + +- LilyGo T-Deck / T-Deck Plus, **or** M5Stack Cardputer-Adv with the LoRa+GPS + Cap (U201 / U214) +- Ideally a second Meshtastic device (or another espp node) to talk to + +### Configure + +Run `idf.py menuconfig` and set, under `Example Configuration`: + +- **Hardware** - your board +- **LoRa region** - this MUST match the other Meshtastic devices and be legal + where you are +- Optionally the node name and ping intervals + +> ⚠️ Always attach the antenna before transmitting, and make sure the region +> you select is legal for you. + +### Build and Flash + +Run `idf.py -p PORT flash monitor` to build, flash and monitor the project. + +(To exit the serial monitor, type ``Ctrl-]``.) + +See the Getting Started Guide for full steps to configure and use ESP-IDF to build projects. diff --git a/components/meshtastic/example/main/CMakeLists.txt b/components/meshtastic/example/main/CMakeLists.txt new file mode 100755 index 000000000..b46960811 --- /dev/null +++ b/components/meshtastic/example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES meshtastic sx126x logger task t-deck m5stack-cardputer + ) diff --git a/components/meshtastic/example/main/Kconfig.projbuild b/components/meshtastic/example/main/Kconfig.projbuild new file mode 100755 index 000000000..15e4f15d6 --- /dev/null +++ b/components/meshtastic/example/main/Kconfig.projbuild @@ -0,0 +1,60 @@ +menu "Example Configuration" + +choice EXAMPLE_HARDWARE + prompt "Hardware" + default EXAMPLE_HARDWARE_TDECK + help + Select the board to run this example on. Both use an SX1262 LoRa radio + exposed through the board's BSP component. + +config EXAMPLE_HARDWARE_TDECK + depends on IDF_TARGET_ESP32S3 + bool "LilyGo T-Deck / T-Deck Plus" + +config EXAMPLE_HARDWARE_CARDPUTER_ADV + depends on IDF_TARGET_ESP32S3 + bool "M5Stack Cardputer-Adv + LoRa+GPS Cap" +endchoice + +choice EXAMPLE_REGION + prompt "LoRa region" + default EXAMPLE_REGION_US + help + The LoRa regulatory region. This MUST match the region of the Meshtastic + devices you want to talk to, and must be legal where you are. + +config EXAMPLE_REGION_US + bool "US (902-928 MHz)" + +config EXAMPLE_REGION_EU_868 + bool "EU 868 (869 MHz)" + +config EXAMPLE_REGION_ANZ + bool "Australia / New Zealand (915-928 MHz)" +endchoice + +config EXAMPLE_LONG_NAME + string "Node long name" + default "espp node" + +config EXAMPLE_SHORT_NAME + string "Node short name (<= 4 chars)" + default "espp" + +config EXAMPLE_REBROADCAST + bool "Rebroadcast (relay) other nodes' packets" + default n + help + Whether to act as a mesh router and rebroadcast packets from other nodes + (managed flood). Leave off for a simple receive/originate-only node, + which still fully interoperates. + +config EXAMPLE_TEXT_INTERVAL_S + int "Text ping interval (seconds, 0 to disable)" + default 30 + +config EXAMPLE_NODEINFO_INTERVAL_S + int "Node info broadcast interval (seconds)" + default 300 + +endmenu diff --git a/components/meshtastic/example/main/meshtastic_example.cpp b/components/meshtastic/example/main/meshtastic_example.cpp new file mode 100755 index 000000000..a3ecbf5c6 --- /dev/null +++ b/components/meshtastic/example/main/meshtastic_example.cpp @@ -0,0 +1,157 @@ +#include +#include +#include +#include + +#include "logger.hpp" +#include "meshtastic.hpp" +#include "sx126x.hpp" +#include "task.hpp" + +#if CONFIG_EXAMPLE_HARDWARE_TDECK +#include "t-deck.hpp" +#elif CONFIG_EXAMPLE_HARDWARE_CARDPUTER_ADV +#include "m5stack-cardputer.hpp" +#endif + +using namespace std::chrono_literals; + +#if CONFIG_EXAMPLE_REGION_US +static constexpr auto REGION = espp::meshtastic::Region::US; +#elif CONFIG_EXAMPLE_REGION_EU_868 +static constexpr auto REGION = espp::meshtastic::Region::EU_868; +#elif CONFIG_EXAMPLE_REGION_ANZ +static constexpr auto REGION = espp::meshtastic::Region::ANZ; +#endif + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "Meshtastic Example", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting Meshtastic example!"); + + // Compute the modem config first (frequency / SF / BW / CR) so we can set + // up the radio to match the channel. We use a throwaway node just to derive + // the config; the real node is created once we know it works. (Cheap - it + // does no I/O.) + auto probe = + espp::MeshtasticNode({.region = REGION, .preset = espp::meshtastic::ModemPreset::LONG_FAST}); + auto modem = probe.modem_config(); + logger.info("Meshtastic LongFast: {:.3f} MHz, SF{}, BW {} kHz, CR 4/{}", + modem.frequency_hz / 1e6f, modem.spreading_factor, modem.bandwidth_hz / 1000, + modem.coding_rate); + + // translate the meshtastic modem config into an sx126x radio config + auto to_bandwidth = [](uint32_t hz) { + switch (hz) { + case 125000: + return espp::Sx126x::Bandwidth::BW_125_KHZ; + case 250000: + return espp::Sx126x::Bandwidth::BW_250_KHZ; + case 500000: + return espp::Sx126x::Bandwidth::BW_500_KHZ; + default: + return espp::Sx126x::Bandwidth::BW_250_KHZ; + } + }; + espp::Sx126x::RadioConfig radio_config{ + .frequency_hz = modem.frequency_hz, + .tx_power_dbm = 22, + .spreading_factor = (espp::Sx126x::SpreadingFactor)modem.spreading_factor, + .bandwidth = to_bandwidth(modem.bandwidth_hz), + .coding_rate = (espp::Sx126x::CodingRate)(modem.coding_rate - 4), + .preamble_length = modem.preamble_length, + .crc_enabled = modem.crc_enabled, + .sync_word = modem.sync_word, + }; + + // bring up the board and its LoRa radio via the BSP +#if CONFIG_EXAMPLE_HARDWARE_TDECK + auto &board = espp::TDeck::get(); +#elif CONFIG_EXAMPLE_HARDWARE_CARDPUTER_ADV + auto &board = espp::M5StackCardputer::get(); +#endif + if (!board.initialize_lora(radio_config)) { + logger.error("Failed to initialize the LoRa radio!"); + return; + } + auto radio = board.lora(); + + //! [meshtastic example] + // build the mesh node, transmitting through the radio + auto node = std::make_shared(espp::MeshtasticNode::Config { + .long_name = CONFIG_EXAMPLE_LONG_NAME, .short_name = CONFIG_EXAMPLE_SHORT_NAME, +#if CONFIG_EXAMPLE_HARDWARE_TDECK + .hw_model = espp::meshtastic::HardwareModel::T_DECK, +#elif CONFIG_EXAMPLE_HARDWARE_CARDPUTER_ADV + .hw_model = espp::meshtastic::HardwareModel::M5STACK_CARDPUTER_ADV, +#endif + .region = REGION, .preset = espp::meshtastic::ModemPreset::LONG_FAST, + .transmit = [radio](std::span frame) -> bool { + std::error_code ec; + bool ok = radio->transmit(frame, 5s, ec); + if (!ok) { + // returning to receive is handled by transmit(); log failures + } + return ok; + }, +#if CONFIG_EXAMPLE_REBROADCAST + .rebroadcast = true, +#else + .rebroadcast = false, +#endif + .on_text = + [&](const std::string &text, const espp::meshtastic::PacketMetadata &meta) { + logger.info("[TEXT] from 0x{:08x} ({} hops, SNR {:.1f}): {}", meta.header.from, + meta.hops_taken, meta.snr, text); + }, + .on_nodeinfo = + [&](const espp::meshtastic::User &user, const espp::meshtastic::PacketMetadata &meta) { + logger.info("[NODE] 0x{:08x} is '{}' ({})", meta.header.from, user.long_name, + user.short_name); + }, + .on_position = + [&](const espp::meshtastic::Position &pos, const espp::meshtastic::PacketMetadata &meta) { + logger.info("[POS ] 0x{:08x} @ {:.5f}, {:.5f}", meta.header.from, pos.latitude(), + pos.longitude()); + }, + .log_level = espp::Logger::Verbosity::INFO, + }); + + // deliver received radio packets to the mesh node + radio->set_receive_callback([node](const espp::Sx126x::RxPacket &packet) { + node->handle_frame(packet.data, packet.status.rssi, packet.status.snr); + }); + + // start listening + std::error_code ec; + if (!radio->start_receive(ec)) { + logger.error("Failed to start receiving: {}", ec.message()); + return; + } + logger.info("Node {} listening for Meshtastic traffic", node->node_id()); + + // announce ourselves so we appear in other nodes' node lists + node->send_node_info(); + //! [meshtastic example] + +#if CONFIG_EXAMPLE_TEXT_INTERVAL_S > 0 + int text_count = 0; +#endif + auto last_nodeinfo = std::chrono::steady_clock::now(); + while (true) { +#if CONFIG_EXAMPLE_TEXT_INTERVAL_S > 0 + std::this_thread::sleep_for(std::chrono::seconds(CONFIG_EXAMPLE_TEXT_INTERVAL_S)); + std::string message = "hello from " + std::string(CONFIG_EXAMPLE_SHORT_NAME) + " #" + + std::to_string(text_count++); + logger.info("Sending text: {}", message); + node->send_text(message); +#else + std::this_thread::sleep_for(10s); +#endif + // periodically re-broadcast node info + if (std::chrono::steady_clock::now() - last_nodeinfo > + std::chrono::seconds(CONFIG_EXAMPLE_NODEINFO_INTERVAL_S)) { + node->send_node_info(); + last_nodeinfo = std::chrono::steady_clock::now(); + } + } +} diff --git a/components/meshtastic/example/sdkconfig.defaults b/components/meshtastic/example/sdkconfig.defaults new file mode 100755 index 000000000..d3ec02735 --- /dev/null +++ b/components/meshtastic/example/sdkconfig.defaults @@ -0,0 +1,29 @@ +CONFIG_IDF_TARGET="esp32s3" + +CONFIG_FREERTOS_HZ=1000 + +# set compiler optimization level to -O2 (compile for performance) +CONFIG_COMPILER_OPTIMIZATION_PERF=y + +# 16MB on the T-Deck, 8MB on the Cardputer-Adv; 16MB is a safe upper bound +# for building. Lower it to 8MB when flashing a Cardputer. +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="16MB" +CONFIG_ESPTOOLPY_FLASHMODE_QIO=y + +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y + +# Common ESP-related +# +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 +CONFIG_ESP_TIMER_TASK_STACK_SIZE=6144 + +# set the functions into IRAM +CONFIG_SPI_MASTER_IN_IRAM=y + +# Both target boards have PSRAM (T-Deck: octal; Cardputer-Adv: none/quad +# depending on module). Enable it with ignore-not-found so the example boots +# regardless; this example does not require it. +CONFIG_SPIRAM=y +CONFIG_SPIRAM_IGNORE_NOTFOUND=y diff --git a/components/meshtastic/idf_component.yml b/components/meshtastic/idf_component.yml new file mode 100755 index 000000000..9611e5e40 --- /dev/null +++ b/components/meshtastic/idf_component.yml @@ -0,0 +1,20 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Minimal radio-agnostic Meshtastic-compatible mesh node (framing, AES-CTR, protobuf) for ESP-IDF" +url: "https://github.com/esp-cpp/espp/tree/main/components/meshtastic" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/wireless/meshtastic.html" +examples: + - path: example +tags: + - cpp + - Component + - Meshtastic + - LoRa + - Mesh +dependencies: + idf: + version: '>=5.0' + espp/base_component: '>=1.0' diff --git a/components/meshtastic/include/meshtastic.hpp b/components/meshtastic/include/meshtastic.hpp new file mode 100644 index 000000000..689e2ded3 --- /dev/null +++ b/components/meshtastic/include/meshtastic.hpp @@ -0,0 +1,189 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "base_component.hpp" +#include "meshtastic_crypto.hpp" +#include "meshtastic_protobuf.hpp" +#include "meshtastic_protocol.hpp" +#include "meshtastic_types.hpp" + +namespace espp { +/// A minimal, radio-agnostic Meshtastic-compatible mesh node. +/// +/// This class implements enough of the Meshtastic over-the-air protocol to +/// interoperate with stock Meshtastic devices on a shared channel (the public +/// "LongFast" channel by default): it frames, encrypts (AES-CTR with the +/// well-known default key), sends and receives text messages, node info and +/// positions, and performs receive-side deduplication and (optionally) +/// managed-flood rebroadcasting. +/// +/// It is decoupled from any particular radio: you provide a transmit function +/// and feed it received frames (e.g. from an espp::Sx126x driver). Call +/// modem_config() to get the exact LoRa modulation parameters to apply to +/// your radio for the configured region / preset / channel. +/// +/// \note This is an independent, clean-room implementation of the published +/// Meshtastic protocol; it is not affiliated with or endorsed by +/// Meshtastic LLC. "Meshtastic" is a registered trademark of Meshtastic +/// LLC. See the component README for details. +/// +/// \note Scope: this implements the public default channel with PSK +/// encryption. It does not implement PKI-encrypted direct messages, +/// MQTT, store-and-forward, or the admin protocol. +/// +/// \section meshtastic_example Example +/// \snippet meshtastic_example.cpp meshtastic example +class MeshtasticNode : public BaseComponent { +public: + /// Function used to transmit a framed packet over the radio. Return true on + /// success. + typedef std::function frame)> transmit_fn; + + /// Callback invoked when a text message is received. + /// \param text The message text + /// \param metadata The packet metadata (sender, RSSI, SNR, ...) + typedef std::function + text_callback_fn; + + /// Callback invoked when node info (a User message) is received. + typedef std::function + nodeinfo_callback_fn; + + /// Callback invoked when a position is received. + typedef std::function + position_callback_fn; + + /// Callback invoked for any received Data message (after decryption and + /// decode), for handling port numbers not covered by the specific + /// callbacks above. + typedef std::function + data_callback_fn; + + /// Configuration for the Meshtastic node. + struct Config { + uint32_t node_num{0}; ///< This node's number. If 0, a stable pseudo-random + ///< number is derived from the ESP32's MAC address. + std::string long_name{"espp node"}; ///< This node's long name + std::string short_name{"espp"}; ///< This node's short name (<= 4 chars) + meshtastic::HardwareModel hw_model{ + meshtastic::HardwareModel::PRIVATE_HW}; ///< This node's hardware model + + meshtastic::Region region{meshtastic::Region::US}; ///< Regulatory region + meshtastic::ModemPreset preset{meshtastic::ModemPreset::LONG_FAST}; ///< Modem preset + std::string channel_name{}; ///< Channel name; empty uses the default + ///< channel (the preset's display name) + std::vector psk{1}; ///< The channel PSK. Default {1} = the public + ///< default key. See meshtastic::expand_psk. + + transmit_fn transmit; ///< Function used to transmit frames + + uint8_t hop_limit{3}; ///< Hop limit for originated broadcasts (0-7) + bool rebroadcast{false}; ///< Whether to rebroadcast (relay) others' packets + ///< (managed flood). False = receive/originate only, + ///< which still fully interoperates. + + text_callback_fn on_text{nullptr}; ///< Called on received text messages + nodeinfo_callback_fn on_nodeinfo{nullptr}; ///< Called on received node info + position_callback_fn on_position{nullptr}; ///< Called on received positions + data_callback_fn on_data{nullptr}; ///< Called on any received Data message + + Logger::Verbosity log_level{Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Constructor + /// \param config The configuration for the node + explicit MeshtasticNode(const Config &config); + + /// Get the LoRa modem configuration to apply to the radio for the + /// configured region / preset / channel (frequency, bandwidth, spreading + /// factor, coding rate, sync word). + /// \return The modem configuration + const meshtastic::ModemConfig &modem_config() const { return modem_config_; } + + /// Get this node's number. + /// \return The node number + uint32_t node_num() const { return config_.node_num; } + + /// Get this node's id string (e.g. "!a1b2c3d4"). + /// \return The node id string + std::string node_id() const; + + /// Send a text message on the channel. + /// \param text The message text (UTF-8, up to ~200 bytes) + /// \param destination The destination node, or BROADCAST_ADDR (default) to + /// broadcast to the channel + /// \param want_ack Whether to request an acknowledgement + /// \return True if the message was transmitted + bool send_text(std::string_view text, uint32_t destination = meshtastic::BROADCAST_ADDR, + bool want_ack = false); + + /// Broadcast this node's info (User message). Stock devices need this to + /// show your node's name in their node lists. + /// \return True if the node info was transmitted + bool send_node_info(); + + /// Broadcast a position. + /// \param position The position to broadcast + /// \return True if the position was transmitted + bool send_position(const meshtastic::Position &position); + + /// Send an arbitrary Data message on the channel (advanced use). + /// \param data The Data message to send + /// \param destination The destination node, or BROADCAST_ADDR to broadcast + /// \param want_ack Whether to request an acknowledgement + /// \return True if the message was transmitted + bool send_data(const meshtastic::DataMessage &data, + uint32_t destination = meshtastic::BROADCAST_ADDR, bool want_ack = false); + + /// Handle a raw frame received from the radio. Decrypts, decodes, performs + /// deduplication, dispatches callbacks, and (if configured) rebroadcasts. + /// \param frame The raw received frame (header + encrypted payload) + /// \param rssi The receive RSSI in dBm + /// \param snr The receive SNR in dB + /// \return True if the frame was a valid, newly-seen packet for our channel + bool handle_frame(std::span frame, float rssi = 0, float snr = 0); + + /// Set the callback invoked when a text message is received. + /// \param callback The callback + void set_text_callback(const text_callback_fn &callback); + + /// Set the callback invoked when node info is received. + /// \param callback The callback + void set_nodeinfo_callback(const nodeinfo_callback_fn &callback); + + /// Set the callback invoked when a position is received. + /// \param callback The callback + void set_position_callback(const position_callback_fn &callback); + +protected: + uint32_t next_packet_id(); + bool already_seen(uint32_t from, uint32_t id); + bool send_packet(const meshtastic::DataMessage &data, uint32_t destination, bool want_ack); + void maybe_rebroadcast(const meshtastic::PacketHeader &header, std::span frame); + + Config config_; + meshtastic::ModemConfig modem_config_{}; + std::vector key_{}; // expanded AES key (empty = no encryption) + uint8_t channel_hash_{0}; // our channel's hash byte + std::string resolved_channel_name_; // channel name used for hashing + + std::mutex mutex_; + uint32_t packet_id_counter_{0}; + // recently-seen (from, id) pairs for deduplication + std::deque seen_packets_; + static constexpr size_t MAX_SEEN_PACKETS = 128; +}; +} // namespace espp diff --git a/components/meshtastic/include/meshtastic_crypto.hpp b/components/meshtastic/include/meshtastic_crypto.hpp new file mode 100755 index 000000000..6ad76ccf9 --- /dev/null +++ b/components/meshtastic/include/meshtastic_crypto.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +namespace espp::meshtastic { + +/// The well-known default channel pre-shared key (16 bytes, AES-128). A +/// 1-byte PSK of value N is shorthand for this key with (N - 1) added to the +/// final byte; the default channel uses N = 1 (i.e. exactly this key). +std::span default_psk(); + +/// Expand a configured PSK into an AES key: +/// - empty or {0}: encryption disabled (returns empty) +/// - 1 byte with value N: the default key with (N - 1) added to its last byte +/// - 16 bytes: used directly as an AES-128 key +/// - 32 bytes: used directly as an AES-256 key +/// \param psk The configured PSK +/// \return The expanded key (0, 16, or 32 bytes) +std::vector expand_psk(std::span psk); + +/// Encrypt or decrypt a packet payload in place using AES-CTR with the +/// Meshtastic nonce construction (packet id and sender node number, little +/// endian). CTR mode is symmetric, so the same function is used for both +/// directions. +/// \param key The AES key (16 bytes for AES-128 or 32 bytes for AES-256) +/// \param packet_id The packet id from the header +/// \param from_node The sender node number from the header +/// \param data The payload to encrypt / decrypt, modified in place +/// \return True on success +bool crypt_payload(std::span key, uint32_t packet_id, uint32_t from_node, + std::span data); + +} // namespace espp::meshtastic diff --git a/components/meshtastic/include/meshtastic_protobuf.hpp b/components/meshtastic/include/meshtastic_protobuf.hpp new file mode 100755 index 000000000..fbbb06372 --- /dev/null +++ b/components/meshtastic/include/meshtastic_protobuf.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +#include "meshtastic_types.hpp" + +namespace espp::meshtastic { + +/// Encode a Data message to protobuf wire format. +/// \param data The message to encode +/// \return The encoded bytes +std::vector encode_data(const DataMessage &data); + +/// Decode a Data message from protobuf wire format. +/// \param bytes The encoded bytes +/// \return The decoded message, or nullopt if the bytes are not a valid +/// protobuf encoding +std::optional decode_data(std::span bytes); + +/// Encode a User (node info) message to protobuf wire format. +/// \param user The message to encode +/// \return The encoded bytes +std::vector encode_user(const User &user); + +/// Decode a User (node info) message from protobuf wire format. +/// \param bytes The encoded bytes +/// \return The decoded message, or nullopt if the bytes are not a valid +/// protobuf encoding +std::optional decode_user(std::span bytes); + +/// Encode a Position message to protobuf wire format. +/// \param position The message to encode +/// \return The encoded bytes +std::vector encode_position(const Position &position); + +/// Decode a Position message from protobuf wire format. +/// \param bytes The encoded bytes +/// \return The decoded message, or nullopt if the bytes are not a valid +/// protobuf encoding +std::optional decode_position(std::span bytes); + +} // namespace espp::meshtastic diff --git a/components/meshtastic/include/meshtastic_protocol.hpp b/components/meshtastic/include/meshtastic_protocol.hpp new file mode 100755 index 000000000..377dd4ace --- /dev/null +++ b/components/meshtastic/include/meshtastic_protocol.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "meshtastic_types.hpp" + +namespace espp::meshtastic { + +/// The djb2 string hash, used to derive the frequency slot from the channel +/// name. +/// \param str The string to hash +/// \return The 32-bit hash +uint32_t djb2_hash(std::string_view str); + +/// The xor-of-all-bytes hash, used (over the channel name and PSK) to derive +/// the channel hash byte carried in the packet header. +/// \param data The bytes to hash +/// \return The 8-bit hash +uint8_t xor_hash(std::span data); + +/// Compute the channel hash byte for a channel. +/// \param channel_name The (resolved) channel name, e.g. "LongFast" +/// \param psk The expanded pre-shared key (16 or 32 bytes) +/// \return The channel hash byte carried in the packet header +uint8_t channel_hash(std::string_view channel_name, std::span psk); + +/// Get the display name of a modem preset. This is the name used for the +/// default channel (and therefore the frequency-slot hash) when no explicit +/// channel name is configured. +/// \param preset The modem preset +/// \return The display name, e.g. "LongFast" +const char *preset_display_name(ModemPreset preset); + +/// Compute the full modem configuration (frequency, bandwidth, spreading +/// factor, coding rate) for a region / preset / channel name, following the +/// Meshtastic frequency-slot algorithm. +/// \param region The regulatory region +/// \param preset The modem preset +/// \param channel_name The channel name, or empty to use the preset's +/// display name (the default-channel behavior) +/// \param frequency_slot_override 1-based frequency slot override, or 0 to +/// derive the slot from the channel name hash (the default) +/// \return The modem configuration +ModemConfig compute_modem_config(Region region, ModemPreset preset, + std::string_view channel_name = "", + uint32_t frequency_slot_override = 0); + +/// Pack a packet header into its 16-byte wire format (little-endian). +/// \param header The header to pack +/// \param out The buffer to pack into (must be at least HEADER_LENGTH bytes) +void pack_header(const PacketHeader &header, uint8_t *out); + +/// Unpack a packet header from its 16-byte wire format. +/// \param data The frame data (must be at least HEADER_LENGTH bytes) +/// \return The unpacked header +PacketHeader unpack_header(const uint8_t *data); + +} // namespace espp::meshtastic diff --git a/components/meshtastic/include/meshtastic_types.hpp b/components/meshtastic/include/meshtastic_types.hpp new file mode 100644 index 000000000..91b80f9a3 --- /dev/null +++ b/components/meshtastic/include/meshtastic_types.hpp @@ -0,0 +1,148 @@ +#pragma once + +#include +#include +#include + +namespace espp::meshtastic { + +/// The broadcast node address +static constexpr uint32_t BROADCAST_ADDR = 0xFFFFFFFF; + +/// The length of the unencrypted packet header +static constexpr size_t HEADER_LENGTH = 16; + +/// The maximum length of a LoRa frame (header + encrypted payload) +static constexpr size_t MAX_FRAME_LENGTH = 255; + +/// The maximum length of the encoded (plaintext) Data protobuf +static constexpr size_t MAX_DATA_LENGTH = 233; + +/// The LoRa sync word used by Meshtastic networks +static constexpr uint8_t SYNC_WORD = 0x2B; + +/// The preamble length (in symbols) used by Meshtastic networks +static constexpr uint16_t PREAMBLE_LENGTH = 16; + +/// LoRa regulatory regions (frequency plans) +enum class Region : uint8_t { + US, ///< United States (902.0 - 928.0 MHz) + EU_868, ///< Europe 868 (869.4 - 869.65 MHz) + EU_433, ///< Europe 433 (433.0 - 434.0 MHz) + ANZ, ///< Australia / New Zealand (915.0 - 928.0 MHz) +}; + +/// Modem presets (named LoRa modulation configurations) +enum class ModemPreset : uint8_t { + LONG_FAST = 0, ///< SF11 / 250 kHz / CR 4/5 (the network default) + LONG_SLOW = 1, ///< SF12 / 125 kHz / CR 4/8 + MEDIUM_SLOW = 3, ///< SF10 / 250 kHz / CR 4/5 + MEDIUM_FAST = 4, ///< SF9 / 250 kHz / CR 4/5 + SHORT_SLOW = 5, ///< SF8 / 250 kHz / CR 4/5 + SHORT_FAST = 6, ///< SF7 / 250 kHz / CR 4/5 + LONG_MODERATE = 7, ///< SF11 / 125 kHz / CR 4/8 + SHORT_TURBO = 8, ///< SF7 / 500 kHz / CR 4/5 +}; + +/// Application port numbers (meshtastic.PortNum) +enum class PortNum : uint32_t { + UNKNOWN_APP = 0, + TEXT_MESSAGE_APP = 1, + REMOTE_HARDWARE_APP = 2, + POSITION_APP = 3, + NODEINFO_APP = 4, + ROUTING_APP = 5, + ADMIN_APP = 6, + WAYPOINT_APP = 8, + TELEMETRY_APP = 67, + TRACEROUTE_APP = 70, + NEIGHBORINFO_APP = 71, +}; + +/// Hardware models (meshtastic.HardwareModel, subset) +enum class HardwareModel : uint32_t { + UNSET = 0, + T_DECK = 50, + M5STACK_CARDPUTER_ADV = 112, + PRIVATE_HW = 255, +}; + +/// The unencrypted 16-byte packet header, present at the start of every +/// frame. All multi-byte fields are little-endian on the air. +struct PacketHeader { + uint32_t to{BROADCAST_ADDR}; ///< Destination node number + uint32_t from{0}; ///< Source node number + uint32_t id{0}; ///< Packet id (unique per sender) + uint8_t hop_limit{0}; ///< Remaining hops (0-7) + bool want_ack{false}; ///< Whether the sender wants an ack + bool via_mqtt{false}; ///< Whether the packet passed through MQTT + uint8_t hop_start{0}; ///< The hop limit the packet started with + uint8_t channel{0}; ///< Channel hash byte + uint8_t next_hop{0}; ///< Next-hop node (low byte), 0 = any + uint8_t relay_node{0}; ///< Relaying node (low byte) +}; + +/// The meshtastic.Data protobuf message (the decrypted packet payload) +struct DataMessage { + PortNum portnum{PortNum::UNKNOWN_APP}; ///< Which app the payload is for + std::vector payload{}; ///< The app payload + bool want_response{false}; ///< Whether the sender wants a response + uint32_t dest{0}; ///< Original destination (for multi-hop) + uint32_t source{0}; ///< Original source (for multi-hop) + uint32_t request_id{0}; ///< The request this is a response to + uint32_t reply_id{0}; ///< The message this is a reply to + uint32_t emoji{0}; ///< Emoji tapback code point +}; + +/// The meshtastic.User protobuf message (node info) +struct User { + std::string id{}; ///< Node id string, e.g. "!a1b2c3d4" + std::string long_name{}; ///< Full name of the node + std::string short_name{}; ///< Short name (up to 4 characters) + HardwareModel hw_model{HardwareModel::UNSET}; ///< The hardware model + bool is_licensed{false}; ///< Whether the operator is a licensed ham + uint32_t role{0}; ///< Device role (meshtastic.Config.DeviceConfig.Role) + std::vector public_key{}; ///< X25519 public key (optional) +}; + +/// The meshtastic.Position protobuf message (subset) +struct Position { + int32_t latitude_i{0}; ///< Latitude * 1e7 + int32_t longitude_i{0}; ///< Longitude * 1e7 + int32_t altitude{0}; ///< Altitude above MSL, meters + uint32_t time{0}; ///< Unix timestamp of the fix + uint32_t ground_speed{0}; ///< Speed over ground, m/s + uint32_t ground_track{0}; ///< Course over ground, degrees * 1e5 + uint32_t sats_in_view{0}; ///< Number of satellites used + uint32_t precision_bits{0}; ///< Position precision (32 = full precision) + bool has_latitude{false}; ///< Whether latitude_i is set + bool has_longitude{false}; ///< Whether longitude_i is set + bool has_altitude{false}; ///< Whether altitude is set + + /// Get the latitude in decimal degrees + double latitude() const { return latitude_i / 1e7; } + /// Get the longitude in decimal degrees + double longitude() const { return longitude_i / 1e7; } +}; + +/// LoRa modem configuration needed to join a Meshtastic channel; apply this +/// to your radio driver (see espp::Sx126x::RadioConfig). +struct ModemConfig { + uint32_t frequency_hz{0}; ///< RF center frequency, Hz + uint32_t bandwidth_hz{0}; ///< Signal bandwidth, Hz + uint8_t spreading_factor{0}; ///< Spreading factor (7-12) + uint8_t coding_rate{0}; ///< Coding rate denominator (5-8, i.e. 4/x) + uint16_t preamble_length{PREAMBLE_LENGTH}; ///< Preamble length, symbols + uint8_t sync_word{SYNC_WORD}; ///< LoRa sync word + bool crc_enabled{true}; ///< Payload CRC enabled +}; + +/// Metadata for a received packet, passed alongside decoded payloads +struct PacketMetadata { + PacketHeader header{}; ///< The packet header + float rssi{0}; ///< Receive RSSI, dBm + float snr{0}; ///< Receive SNR, dB + uint8_t hops_taken{0}; ///< hop_start - hop_limit (0 = heard directly) +}; + +} // namespace espp::meshtastic diff --git a/components/meshtastic/src/meshtastic.cpp b/components/meshtastic/src/meshtastic.cpp new file mode 100644 index 000000000..400a28185 --- /dev/null +++ b/components/meshtastic/src/meshtastic.cpp @@ -0,0 +1,289 @@ +#include "meshtastic.hpp" + +#include +#include + +#include +#include + +using namespace espp; +using namespace espp::meshtastic; + +MeshtasticNode::MeshtasticNode(const Config &config) + : BaseComponent("Meshtastic", config.log_level) + , config_(config) { + // derive a node number from the MAC if one wasn't provided + if (config_.node_num == 0) { + uint8_t mac[6] = {0}; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + config_.node_num = + ((uint32_t)mac[2] << 24) | ((uint32_t)mac[3] << 16) | ((uint32_t)mac[4] << 8) | mac[5]; + if (config_.node_num == 0 || config_.node_num == BROADCAST_ADDR) { + config_.node_num = 0x0badf00d; + } + } + + // compute the modem configuration for the region / preset / channel + modem_config_ = compute_modem_config(config_.region, config_.preset, config_.channel_name); + + // expand the PSK and compute the channel hash + key_ = expand_psk(config_.psk); + // expand_psk() returns an empty key both when encryption is intentionally + // disabled (empty PSK, or the single sentinel byte 0) and when the PSK length + // is invalid. Distinguish the two so an invalid key does not silently fall + // back to an unencrypted channel without any indication. + const bool psk_disabled = config_.psk.empty() || (config_.psk.size() == 1 && config_.psk[0] == 0); + if (key_.empty() && !psk_disabled) { + logger_.error("Invalid PSK length {} (expected 1, 16, or 32 bytes); channel will be " + "UNENCRYPTED", + config_.psk.size()); + } + resolved_channel_name_ = + config_.channel_name.empty() ? preset_display_name(config_.preset) : config_.channel_name; + // the channel hash uses the expanded key; for an unencrypted channel the + // PSK contribution is empty + channel_hash_ = channel_hash(resolved_channel_name_, key_); + + logger_.info("Meshtastic node {} ({}) on {} channel '{}' @ {:.3f} MHz (hash 0x{:02x})", node_id(), + config_.long_name, key_.empty() ? "unencrypted" : "encrypted", + resolved_channel_name_, modem_config_.frequency_hz / 1e6f, channel_hash_); +} + +std::string MeshtasticNode::node_id() const { + char buffer[12]; + std::snprintf(buffer, sizeof(buffer), "!%08lx", (unsigned long)config_.node_num); + return std::string(buffer); +} + +uint32_t MeshtasticNode::next_packet_id() { + // Packet ids must be nonzero and non-repeating within a session. Seed the + // counter once from the hardware RNG (so two nodes are unlikely to start on + // the same id) then increment monotonically. Returning a fresh esp_random() + // each time can repeat, which makes peers treat new packets as duplicates + // and breaks ACK / response matching. + if (packet_id_counter_ == 0) { + packet_id_counter_ = esp_random() | 1u; // nonzero seed + } + uint32_t id = packet_id_counter_++; + if (packet_id_counter_ == 0) { + packet_id_counter_ = 1; // skip 0 on wrap so it stays the "unseeded" sentinel + } + return id; +} + +bool MeshtasticNode::already_seen(uint32_t from, uint32_t id) { + uint64_t key = ((uint64_t)from << 32) | id; + if (std::any_of(seen_packets_.begin(), seen_packets_.end(), + [key](uint64_t seen) { return seen == key; })) { + return true; + } + seen_packets_.push_back(key); + if (seen_packets_.size() > MAX_SEEN_PACKETS) { + seen_packets_.pop_front(); + } + return false; +} + +bool MeshtasticNode::send_text(std::string_view text, uint32_t destination, bool want_ack) { + DataMessage data; + data.portnum = PortNum::TEXT_MESSAGE_APP; + data.payload.assign(text.begin(), text.end()); + return send_packet(data, destination, want_ack); +} + +bool MeshtasticNode::send_node_info() { + User user; + user.id = node_id(); + user.long_name = config_.long_name; + user.short_name = config_.short_name; + user.hw_model = config_.hw_model; + DataMessage data; + data.portnum = PortNum::NODEINFO_APP; + data.payload = encode_user(user); + return send_packet(data, BROADCAST_ADDR, false); +} + +bool MeshtasticNode::send_position(const Position &position) { + DataMessage data; + data.portnum = PortNum::POSITION_APP; + data.payload = encode_position(position); + return send_packet(data, BROADCAST_ADDR, false); +} + +bool MeshtasticNode::send_data(const DataMessage &data, uint32_t destination, bool want_ack) { + return send_packet(data, destination, want_ack); +} + +bool MeshtasticNode::send_packet(const DataMessage &data, uint32_t destination, bool want_ack) { + if (!config_.transmit) { + logger_.error("No transmit function configured"); + return false; + } + std::vector plaintext = encode_data(data); + if (plaintext.size() > MAX_DATA_LENGTH) { + logger_.error("Payload too large ({} > {} bytes)", plaintext.size(), MAX_DATA_LENGTH); + return false; + } + + PacketHeader header; + header.to = destination; + header.from = config_.node_num; + header.want_ack = want_ack; + header.via_mqtt = false; + header.channel = channel_hash_; + header.next_hop = 0; + header.relay_node = 0; + { + std::lock_guard lock(mutex_); + header.id = next_packet_id(); + header.hop_limit = config_.hop_limit; + header.hop_start = config_.hop_limit; + // remember our own packet so we don't act on our own rebroadcasts + already_seen(header.from, header.id); + } + + // encrypt the payload in place (CTR mode; no-op copy if unencrypted) + if (!key_.empty()) { + if (!crypt_payload(key_, header.id, header.from, plaintext)) { + logger_.error("Failed to encrypt payload"); + return false; + } + } + + std::vector frame(HEADER_LENGTH + plaintext.size()); + pack_header(header, frame.data()); + std::copy(plaintext.begin(), plaintext.end(), frame.begin() + HEADER_LENGTH); + + logger_.debug("Transmitting packet id 0x{:08x} to 0x{:08x} ({} bytes)", header.id, header.to, + frame.size()); + return config_.transmit(frame); +} + +bool MeshtasticNode::handle_frame(std::span frame, float rssi, float snr) { + if (frame.size() < HEADER_LENGTH || frame.size() > MAX_FRAME_LENGTH) { + logger_.debug("Ignoring frame of invalid size {}", frame.size()); + return false; + } + PacketHeader header = unpack_header(frame.data()); + + // ignore our own transmissions + if (header.from == config_.node_num) { + return false; + } + + // only handle packets on our channel (the hash is a hint, not a guarantee - + // decryption/decoding is the real check, but this cheaply rejects other + // channels) + if (header.channel != channel_hash_) { + logger_.debug("Ignoring packet for channel 0x{:02x} (ours is 0x{:02x})", header.channel, + channel_hash_); + return false; + } + + bool seen; + { + std::lock_guard lock(mutex_); + seen = already_seen(header.from, header.id); + } + if (seen) { + logger_.debug("Ignoring already-seen packet 0x{:08x} from 0x{:08x}", header.id, header.from); + return false; + } + + // decrypt the payload + std::vector payload(frame.begin() + HEADER_LENGTH, frame.end()); + if (!key_.empty()) { + if (!crypt_payload(key_, header.id, header.from, payload)) { + logger_.warn("Failed to decrypt packet 0x{:08x}", header.id); + return false; + } + } + + auto data = decode_data(payload); + if (!data) { + logger_.debug("Packet 0x{:08x} did not decode as a Data message (wrong channel key?)", + header.id); + return false; + } + + PacketMetadata metadata; + metadata.header = header; + metadata.rssi = rssi; + metadata.snr = snr; + metadata.hops_taken = + header.hop_start >= header.hop_limit ? header.hop_start - header.hop_limit : 0; + + logger_.debug("Received {} packet 0x{:08x} from 0x{:08x} ({} hops, RSSI {:.0f}, SNR {:.1f})", + (int)data->portnum, header.id, header.from, metadata.hops_taken, rssi, snr); + + // dispatch to the specific callbacks + switch (data->portnum) { + case PortNum::TEXT_MESSAGE_APP: + if (config_.on_text) { + config_.on_text(std::string(data->payload.begin(), data->payload.end()), metadata); + } + break; + case PortNum::NODEINFO_APP: + if (config_.on_nodeinfo) { + auto user = decode_user(data->payload); + if (user) { + config_.on_nodeinfo(*user, metadata); + } + } + break; + case PortNum::POSITION_APP: + if (config_.on_position) { + auto position = decode_position(data->payload); + if (position) { + config_.on_position(*position, metadata); + } + } + break; + default: + break; + } + if (config_.on_data) { + config_.on_data(*data, metadata); + } + + // rebroadcast (managed flood) if configured and there are hops remaining + if (config_.rebroadcast && header.to != config_.node_num) { + maybe_rebroadcast(header, frame); + } + + return true; +} + +void MeshtasticNode::maybe_rebroadcast(const PacketHeader &header, std::span frame) { + if (header.hop_limit == 0 || header.id == 0) { + return; + } + if (!config_.transmit) { + return; + } + // decrement the hop limit and re-pack the header, keeping the (still + // encrypted) payload untouched + std::vector out(frame.begin(), frame.end()); + PacketHeader relayed = header; + relayed.hop_limit = header.hop_limit - 1; + relayed.relay_node = config_.node_num & 0xff; + pack_header(relayed, out.data()); + logger_.debug("Rebroadcasting packet 0x{:08x} (hop limit {} -> {})", header.id, header.hop_limit, + relayed.hop_limit); + config_.transmit(out); +} + +void MeshtasticNode::set_text_callback(const text_callback_fn &callback) { + std::lock_guard lock(mutex_); + config_.on_text = callback; +} + +void MeshtasticNode::set_nodeinfo_callback(const nodeinfo_callback_fn &callback) { + std::lock_guard lock(mutex_); + config_.on_nodeinfo = callback; +} + +void MeshtasticNode::set_position_callback(const position_callback_fn &callback) { + std::lock_guard lock(mutex_); + config_.on_position = callback; +} diff --git a/components/meshtastic/src/meshtastic_crypto.cpp b/components/meshtastic/src/meshtastic_crypto.cpp new file mode 100755 index 000000000..e3d0ef116 --- /dev/null +++ b/components/meshtastic/src/meshtastic_crypto.cpp @@ -0,0 +1,64 @@ +#include "meshtastic_crypto.hpp" + +#include +#include + +#include + +namespace espp::meshtastic { + +std::span default_psk() { + // the well-known Meshtastic default channel key + static constexpr std::array psk = {0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59, + 0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01}; + return std::span{psk}; +} + +std::vector expand_psk(std::span psk) { + if (psk.empty() || (psk.size() == 1 && psk[0] == 0)) { + return {}; // encryption disabled + } + if (psk.size() == 1) { + auto def = default_psk(); + std::vector key(def.begin(), def.end()); + key.back() += psk[0] - 1; + return key; + } + if (psk.size() == 16 || psk.size() == 32) { + return std::vector(psk.begin(), psk.end()); + } + return {}; // invalid key length +} + +bool crypt_payload(std::span key, uint32_t packet_id, uint32_t from_node, + std::span data) { + if (key.size() != 16 && key.size() != 32) { + return false; + } + // nonce / initial counter block: packet id (LE u64) then sender (LE u64) + uint8_t nonce[16] = {0}; + nonce[0] = packet_id & 0xff; + nonce[1] = (packet_id >> 8) & 0xff; + nonce[2] = (packet_id >> 16) & 0xff; + nonce[3] = (packet_id >> 24) & 0xff; + nonce[8] = from_node & 0xff; + nonce[9] = (from_node >> 8) & 0xff; + nonce[10] = (from_node >> 16) & 0xff; + nonce[11] = (from_node >> 24) & 0xff; + + mbedtls_aes_context ctx; + mbedtls_aes_init(&ctx); + int ret = mbedtls_aes_setkey_enc(&ctx, key.data(), key.size() * 8); + if (ret != 0) { + mbedtls_aes_free(&ctx); + return false; + } + size_t nc_off = 0; + uint8_t stream_block[16] = {0}; + ret = mbedtls_aes_crypt_ctr(&ctx, data.size(), &nc_off, nonce, stream_block, data.data(), + data.data()); + mbedtls_aes_free(&ctx); + return ret == 0; +} + +} // namespace espp::meshtastic diff --git a/components/meshtastic/src/meshtastic_protobuf.cpp b/components/meshtastic/src/meshtastic_protobuf.cpp new file mode 100644 index 000000000..7d1505f32 --- /dev/null +++ b/components/meshtastic/src/meshtastic_protobuf.cpp @@ -0,0 +1,418 @@ +#include "meshtastic_protobuf.hpp" + +#include +#include + +namespace espp::meshtastic { + +namespace { + +// Minimal protobuf wire-format helpers. The protobuf encoding is a public, +// stable format: each field is a tag (field number << 3 | wire type) +// followed by a varint (type 0), a length-delimited byte string (type 2), or +// a fixed 32/64-bit value (types 5 / 1). + +enum WireType : uint8_t { + WIRE_VARINT = 0, + WIRE_FIXED64 = 1, + WIRE_LENGTH = 2, + WIRE_FIXED32 = 5, +}; + +void put_varint(std::vector &out, uint64_t value) { + while (value >= 0x80) { + out.push_back((uint8_t)(value & 0x7f) | 0x80); + value >>= 7; + } + out.push_back((uint8_t)value); +} + +void put_tag(std::vector &out, uint32_t field, WireType wire) { + put_varint(out, ((uint64_t)field << 3) | wire); +} + +void put_varint_field(std::vector &out, uint32_t field, uint64_t value) { + put_tag(out, field, WIRE_VARINT); + put_varint(out, value); +} + +void put_fixed32_field(std::vector &out, uint32_t field, uint32_t value) { + put_tag(out, field, WIRE_FIXED32); + out.push_back(value & 0xff); + out.push_back((value >> 8) & 0xff); + out.push_back((value >> 16) & 0xff); + out.push_back((value >> 24) & 0xff); +} + +void put_bytes_field(std::vector &out, uint32_t field, std::span bytes) { + put_tag(out, field, WIRE_LENGTH); + put_varint(out, bytes.size()); + out.insert(out.end(), bytes.begin(), bytes.end()); +} + +void put_string_field(std::vector &out, uint32_t field, std::string_view str) { + put_bytes_field(out, field, std::span{reinterpret_cast(str.data()), str.size()}); +} + +// int32 fields are encoded as (sign-extended) 64-bit varints +void put_int32_field(std::vector &out, uint32_t field, int32_t value) { + put_varint_field(out, field, (uint64_t)(int64_t)value); +} + +struct Reader { + const uint8_t *data; + size_t size; + size_t pos{0}; + + bool read_varint(uint64_t &value) { + value = 0; + int shift = 0; + while (pos < size && shift < 64) { + uint8_t b = data[pos++]; + value |= (uint64_t)(b & 0x7f) << shift; + if (!(b & 0x80)) { + return true; + } + shift += 7; + } + return false; + } + + bool read_fixed32(uint32_t &value) { + if (pos + 4 > size) { + return false; + } + value = (uint32_t)data[pos] | ((uint32_t)data[pos + 1] << 8) | ((uint32_t)data[pos + 2] << 16) | + ((uint32_t)data[pos + 3] << 24); + pos += 4; + return true; + } + + bool read_bytes(std::span &bytes) { + uint64_t length = 0; + if (!read_varint(length) || pos + length > size) { + return false; + } + bytes = std::span{&data[pos], (size_t)length}; + pos += length; + return true; + } + + bool skip(WireType wire) { + switch (wire) { + case WIRE_VARINT: { + uint64_t v; + return read_varint(v); + } + case WIRE_FIXED64: + if (pos + 8 > size) { + return false; + } + pos += 8; + return true; + case WIRE_LENGTH: { + std::span b; + return read_bytes(b); + } + case WIRE_FIXED32: { + uint32_t v; + return read_fixed32(v); + } + } + return false; + } + + bool done() const { return pos >= size; } +}; + +} // namespace + +std::vector encode_data(const DataMessage &data) { + std::vector out; + out.reserve(data.payload.size() + 16); + if (data.portnum != PortNum::UNKNOWN_APP) { + put_varint_field(out, 1, (uint64_t)data.portnum); + } + if (!data.payload.empty()) { + put_bytes_field(out, 2, data.payload); + } + if (data.want_response) { + put_varint_field(out, 3, 1); + } + if (data.dest) { + put_fixed32_field(out, 4, data.dest); + } + if (data.source) { + put_fixed32_field(out, 5, data.source); + } + if (data.request_id) { + put_fixed32_field(out, 6, data.request_id); + } + if (data.reply_id) { + put_fixed32_field(out, 7, data.reply_id); + } + if (data.emoji) { + put_fixed32_field(out, 8, data.emoji); + } + return out; +} + +std::optional decode_data(std::span bytes) { + DataMessage data; + Reader reader{bytes.data(), bytes.size()}; + while (!reader.done()) { + uint64_t tag; + if (!reader.read_varint(tag)) { + return std::nullopt; + } + uint32_t field = tag >> 3; + WireType wire = (WireType)(tag & 0x07); + uint64_t varint; + uint32_t fixed; + std::span span; + switch (field) { + case 1: + if (!reader.read_varint(varint)) { + return std::nullopt; + } + data.portnum = (PortNum)varint; + break; + case 2: + if (!reader.read_bytes(span)) { + return std::nullopt; + } + data.payload.assign(span.begin(), span.end()); + break; + case 3: + if (!reader.read_varint(varint)) { + return std::nullopt; + } + data.want_response = varint != 0; + break; + case 4: + if (!reader.read_fixed32(fixed)) { + return std::nullopt; + } + data.dest = fixed; + break; + case 5: + if (!reader.read_fixed32(fixed)) { + return std::nullopt; + } + data.source = fixed; + break; + case 6: + if (!reader.read_fixed32(fixed)) { + return std::nullopt; + } + data.request_id = fixed; + break; + case 7: + if (!reader.read_fixed32(fixed)) { + return std::nullopt; + } + data.reply_id = fixed; + break; + case 8: + if (!reader.read_fixed32(fixed)) { + return std::nullopt; + } + data.emoji = fixed; + break; + default: + if (!reader.skip(wire)) { + return std::nullopt; + } + break; + } + } + return data; +} + +std::vector encode_user(const User &user) { + std::vector out; + put_string_field(out, 1, user.id); + put_string_field(out, 2, user.long_name); + put_string_field(out, 3, user.short_name); + put_varint_field(out, 5, (uint64_t)user.hw_model); + if (user.is_licensed) { + put_varint_field(out, 6, 1); + } + if (user.role) { + put_varint_field(out, 7, user.role); + } + if (!user.public_key.empty()) { + put_bytes_field(out, 8, user.public_key); + } + return out; +} + +std::optional decode_user(std::span bytes) { + User user; + Reader reader{bytes.data(), bytes.size()}; + while (!reader.done()) { + uint64_t tag; + if (!reader.read_varint(tag)) { + return std::nullopt; + } + uint32_t field = tag >> 3; + WireType wire = (WireType)(tag & 0x07); + uint64_t varint; + std::span span; + switch (field) { + case 1: + if (!reader.read_bytes(span)) { + return std::nullopt; + } + user.id.assign(span.begin(), span.end()); + break; + case 2: + if (!reader.read_bytes(span)) { + return std::nullopt; + } + user.long_name.assign(span.begin(), span.end()); + break; + case 3: + if (!reader.read_bytes(span)) { + return std::nullopt; + } + user.short_name.assign(span.begin(), span.end()); + break; + case 5: + if (!reader.read_varint(varint)) { + return std::nullopt; + } + user.hw_model = (HardwareModel)varint; + break; + case 6: + if (!reader.read_varint(varint)) { + return std::nullopt; + } + user.is_licensed = varint != 0; + break; + case 7: + if (!reader.read_varint(varint)) { + return std::nullopt; + } + user.role = (uint32_t)varint; + break; + case 8: + if (!reader.read_bytes(span)) { + return std::nullopt; + } + user.public_key.assign(span.begin(), span.end()); + break; + default: + if (!reader.skip(wire)) { + return std::nullopt; + } + break; + } + } + return user; +} + +std::vector encode_position(const Position &position) { + std::vector out; + if (position.has_latitude) { + put_fixed32_field(out, 1, (uint32_t)position.latitude_i); + } + if (position.has_longitude) { + put_fixed32_field(out, 2, (uint32_t)position.longitude_i); + } + if (position.has_altitude) { + put_int32_field(out, 3, position.altitude); + } + if (position.time) { + put_fixed32_field(out, 4, position.time); + } + if (position.ground_speed) { + put_varint_field(out, 15, position.ground_speed); + } + if (position.ground_track) { + put_varint_field(out, 16, position.ground_track); + } + if (position.sats_in_view) { + put_varint_field(out, 19, position.sats_in_view); + } + if (position.precision_bits) { + put_varint_field(out, 23, position.precision_bits); + } + return out; +} + +std::optional decode_position(std::span bytes) { + Position position; + Reader reader{bytes.data(), bytes.size()}; + while (!reader.done()) { + uint64_t tag; + if (!reader.read_varint(tag)) { + return std::nullopt; + } + uint32_t field = tag >> 3; + WireType wire = (WireType)(tag & 0x07); + uint64_t varint; + uint32_t fixed; + switch (field) { + case 1: + if (!reader.read_fixed32(fixed)) { + return std::nullopt; + } + position.latitude_i = (int32_t)fixed; + position.has_latitude = true; + break; + case 2: + if (!reader.read_fixed32(fixed)) { + return std::nullopt; + } + position.longitude_i = (int32_t)fixed; + position.has_longitude = true; + break; + case 3: + if (!reader.read_varint(varint)) { + return std::nullopt; + } + position.altitude = (int32_t)(int64_t)varint; + position.has_altitude = true; + break; + case 4: + if (!reader.read_fixed32(fixed)) { + return std::nullopt; + } + position.time = fixed; + break; + case 15: + if (!reader.read_varint(varint)) { + return std::nullopt; + } + position.ground_speed = (uint32_t)varint; + break; + case 16: + if (!reader.read_varint(varint)) { + return std::nullopt; + } + position.ground_track = (uint32_t)varint; + break; + case 19: + if (!reader.read_varint(varint)) { + return std::nullopt; + } + position.sats_in_view = (uint32_t)varint; + break; + case 23: + if (!reader.read_varint(varint)) { + return std::nullopt; + } + position.precision_bits = (uint32_t)varint; + break; + default: + if (!reader.skip(wire)) { + return std::nullopt; + } + break; + } + } + return position; +} + +} // namespace espp::meshtastic diff --git a/components/meshtastic/src/meshtastic_protocol.cpp b/components/meshtastic/src/meshtastic_protocol.cpp new file mode 100755 index 000000000..d5afca0a2 --- /dev/null +++ b/components/meshtastic/src/meshtastic_protocol.cpp @@ -0,0 +1,169 @@ +#include "meshtastic_protocol.hpp" + +#include +#include + +namespace espp::meshtastic { + +namespace { +struct RegionInfo { + float freq_start_mhz; + float freq_end_mhz; + float spacing_mhz; +}; + +RegionInfo region_info(Region region) { + switch (region) { + case Region::US: + return {902.0f, 928.0f, 0.0f}; + case Region::EU_868: + return {869.4f, 869.65f, 0.0f}; + case Region::EU_433: + return {433.0f, 434.0f, 0.0f}; + case Region::ANZ: + return {915.0f, 928.0f, 0.0f}; + } + return {902.0f, 928.0f, 0.0f}; +} + +struct PresetParams { + uint32_t bandwidth_hz; + uint8_t spreading_factor; + uint8_t coding_rate; // denominator of 4/x +}; + +PresetParams preset_params(ModemPreset preset) { + switch (preset) { + case ModemPreset::SHORT_TURBO: + return {500000, 7, 5}; + case ModemPreset::SHORT_FAST: + return {250000, 7, 5}; + case ModemPreset::SHORT_SLOW: + return {250000, 8, 5}; + case ModemPreset::MEDIUM_FAST: + return {250000, 9, 5}; + case ModemPreset::MEDIUM_SLOW: + return {250000, 10, 5}; + case ModemPreset::LONG_MODERATE: + return {125000, 11, 8}; + case ModemPreset::LONG_SLOW: + return {125000, 12, 8}; + case ModemPreset::LONG_FAST: + default: + return {250000, 11, 5}; + } +} +} // namespace + +uint32_t djb2_hash(std::string_view str) { + return std::accumulate(str.begin(), str.end(), uint32_t{5381}, + [](uint32_t hash, unsigned char c) { + return ((hash << 5) + hash) + c; // hash * 33 + c + }); +} + +uint8_t xor_hash(std::span data) { + return std::accumulate(data.begin(), data.end(), uint8_t{0}, + [](uint8_t hash, uint8_t b) -> uint8_t { return hash ^ b; }); +} + +uint8_t channel_hash(std::string_view channel_name, std::span psk) { + uint8_t name_hash = xor_hash( + std::span{reinterpret_cast(channel_name.data()), channel_name.size()}); + return name_hash ^ xor_hash(psk); +} + +const char *preset_display_name(ModemPreset preset) { + switch (preset) { + case ModemPreset::SHORT_TURBO: + return "ShortTurbo"; + case ModemPreset::SHORT_FAST: + return "ShortFast"; + case ModemPreset::SHORT_SLOW: + return "ShortSlow"; + case ModemPreset::MEDIUM_FAST: + return "MediumFast"; + case ModemPreset::MEDIUM_SLOW: + return "MediumSlow"; + case ModemPreset::LONG_MODERATE: + return "LongMod"; + case ModemPreset::LONG_SLOW: + return "LongSlow"; + case ModemPreset::LONG_FAST: + default: + return "LongFast"; + } +} + +ModemConfig compute_modem_config(Region region, ModemPreset preset, std::string_view channel_name, + uint32_t frequency_slot_override) { + auto params = preset_params(preset); + auto info = region_info(region); + float bw_mhz = params.bandwidth_hz / 1e6f; + uint32_t num_channels = + (uint32_t)std::floor((info.freq_end_mhz - info.freq_start_mhz) / (info.spacing_mhz + bw_mhz)); + if (num_channels == 0) { + num_channels = 1; + } + std::string name(channel_name); + if (name.empty()) { + name = preset_display_name(preset); + } + uint32_t slot; + if (frequency_slot_override > 0) { + slot = (frequency_slot_override - 1) % num_channels; + } else { + slot = djb2_hash(name) % num_channels; + } + // freq = start + bw/2 + slot * bw (all in MHz); compute in Hz to avoid + // floating point error at the kHz level + uint64_t start_hz = (uint64_t)std::llround((double)info.freq_start_mhz * 1e6); + uint64_t freq_hz = start_hz + params.bandwidth_hz / 2 + (uint64_t)slot * params.bandwidth_hz; + + ModemConfig config; + config.frequency_hz = (uint32_t)freq_hz; + config.bandwidth_hz = params.bandwidth_hz; + config.spreading_factor = params.spreading_factor; + config.coding_rate = params.coding_rate; + config.preamble_length = PREAMBLE_LENGTH; + config.sync_word = SYNC_WORD; + config.crc_enabled = true; + return config; +} + +void pack_header(const PacketHeader &header, uint8_t *out) { + auto put_u32 = [](uint8_t *p, uint32_t v) { + p[0] = v & 0xff; + p[1] = (v >> 8) & 0xff; + p[2] = (v >> 16) & 0xff; + p[3] = (v >> 24) & 0xff; + }; + put_u32(&out[0], header.to); + put_u32(&out[4], header.from); + put_u32(&out[8], header.id); + out[12] = (header.hop_limit & 0x07) | (header.want_ack ? 0x08 : 0x00) | + (header.via_mqtt ? 0x10 : 0x00) | ((header.hop_start & 0x07) << 5); + out[13] = header.channel; + out[14] = header.next_hop; + out[15] = header.relay_node; +} + +PacketHeader unpack_header(const uint8_t *data) { + auto get_u32 = [](const uint8_t *p) -> uint32_t { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); + }; + PacketHeader header; + header.to = get_u32(&data[0]); + header.from = get_u32(&data[4]); + header.id = get_u32(&data[8]); + header.hop_limit = data[12] & 0x07; + header.want_ack = (data[12] & 0x08) != 0; + header.via_mqtt = (data[12] & 0x10) != 0; + header.hop_start = (data[12] >> 5) & 0x07; + header.channel = data[13]; + header.next_hop = data[14]; + header.relay_node = data[15]; + return header; +} + +} // namespace espp::meshtastic diff --git a/components/sx126x/CMakeLists.txt b/components/sx126x/CMakeLists.txt new file mode 100755 index 000000000..3616d6e54 --- /dev/null +++ b/components/sx126x/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES "base_peripheral" "pthread" + ) diff --git a/components/sx126x/README.md b/components/sx126x/README.md new file mode 100755 index 000000000..177a5cfd0 --- /dev/null +++ b/components/sx126x/README.md @@ -0,0 +1,36 @@ +# SX126x Component + +[![Badge](https://components.espressif.com/components/espp/sx126x/badge.svg)](https://components.espressif.com/components/espp/sx126x) + +The `Sx126x` component provides a driver for the Semtech SX126x series of +sub-GHz LoRa radio transceivers (SX1261, SX1262) and compatible chips such as +the LLCC68. These radios are found on many popular development boards, +including the LilyGo T-Deck (SX1262) and the M5Stack Cardputer-Adv LoRa+GPS +Cap (SX1262). + +Features: + +- LoRa modem configuration (spreading factor, bandwidth, coding rate, + preamble length, sync word, CRC, IQ inversion) +- Interrupt-driven (DIO1) or polled operation +- Blocking and non-blocking transmit +- Continuous receive with RSSI / SNR packet status +- Channel activity detection (CAD) +- TCXO (DIO3) and RF-switch (DIO2) control +- Time-on-air calculation +- Datasheet errata workarounds (TX modulation quality, TX clamp, IQ polarity) + +The radio is a command-based SPI peripheral; the driver is written against the +`espp::BasePeripheral` interface and requires a `write_then_read` +implementation which keeps chip select asserted across the write and read +phases (e.g. a single full-duplex transfer via `espp::Spi::Device::transfer`), +which allows it to share an SPI bus with other devices (as on the T-Deck, +where the radio shares the bus with the display and uSD card). + +## Example + +The [example](./example) shows how to use the `espp::Sx126x` component to +configure the radio and send / receive LoRa packets, configurable (via +menuconfig) for the LilyGo T-Deck, the M5Stack Cardputer-Adv with the +LoRa+GPS Cap, or custom hardware. Flash it onto two boards and they will +ping each other. diff --git a/components/sx126x/example/CMakeLists.txt b/components/sx126x/example/CMakeLists.txt new file mode 100755 index 000000000..42d49dcf9 --- /dev/null +++ b/components/sx126x/example/CMakeLists.txt @@ -0,0 +1,25 @@ +# 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.20) + +# NOTE: we don't use the IDF component manager for the examples in this +# repository since the examples use the local versions of the components +set(ENV{IDF_COMPONENT_MANAGER} "0") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/" +) + +set( + COMPONENTS + "main esptool_py driver i2c interrupt logger spi task sx126x" + CACHE STRING + "List of components to include" + ) + +project(sx126x_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/sx126x/example/README.md b/components/sx126x/example/README.md new file mode 100755 index 000000000..57d9b601a --- /dev/null +++ b/components/sx126x/example/README.md @@ -0,0 +1,30 @@ +# SX126x Example + +This example shows how to use the `espp::Sx126x` component to configure an +SX1262 LoRa radio and send / receive packets. Each board running the example +transmits a ping packet periodically and prints any packets it receives +(with RSSI / SNR), so flashing it onto two boards gives a simple link test. + +## How to use example + +### Hardware Required + +One (ideally two) of: + +- LilyGo T-Deck / T-Deck Plus (SX1262 on the shared SPI bus) +- M5Stack Cardputer-Adv with the LoRa+GPS Cap (U201 / U214, SX1262) +- Custom hardware with an SX1261 / SX1262 / LLCC68 connected via SPI + +Select the board with `idf.py menuconfig` (`Example Configuration > +Hardware`), and make sure the configured frequency matches your hardware +variant and is legal in your region (default: 906.875 MHz, the Meshtastic US +LongFast frequency slot; EU boards should use 869.525 MHz). Always attach the +antenna before transmitting. + +### Build and Flash + +Run `idf.py -p PORT flash monitor` to build, flash and monitor the project. + +(To exit the serial monitor, type ``Ctrl-]``.) + +See the Getting Started Guide for full steps to configure and use ESP-IDF to build projects. diff --git a/components/sx126x/example/main/CMakeLists.txt b/components/sx126x/example/main/CMakeLists.txt new file mode 100755 index 000000000..2631fe614 --- /dev/null +++ b/components/sx126x/example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES sx126x i2c interrupt logger spi task driver + ) diff --git a/components/sx126x/example/main/Kconfig.projbuild b/components/sx126x/example/main/Kconfig.projbuild new file mode 100755 index 000000000..a23f83a52 --- /dev/null +++ b/components/sx126x/example/main/Kconfig.projbuild @@ -0,0 +1,104 @@ +menu "Example Configuration" + +choice EXAMPLE_HARDWARE + prompt "Hardware" + default EXAMPLE_HARDWARE_TDECK + help + Select the hardware to run this example on. + +config EXAMPLE_HARDWARE_TDECK + depends on IDF_TARGET_ESP32S3 + bool "LilyGo T-Deck / T-Deck Plus (SX1262)" + +config EXAMPLE_HARDWARE_CARDPUTER_ADV + depends on IDF_TARGET_ESP32S3 + bool "M5Stack Cardputer-Adv + LoRa+GPS Cap (SX1262)" + +config EXAMPLE_HARDWARE_CUSTOM + bool "Custom" +endchoice + +config EXAMPLE_RADIO_FREQUENCY_HZ + int "Radio frequency (Hz)" + range 150000000 960000000 + default 906875000 + help + RF center frequency in Hz. Make sure this matches your hardware variant + and is legal in your region! Default is the Meshtastic US LongFast + frequency slot (906.875 MHz). EU868 users should use 869525000. + +config EXAMPLE_SPI_SCLK_GPIO + int "SPI SCLK GPIO Num" + range 0 50 + default 40 if EXAMPLE_HARDWARE_TDECK + default 40 if EXAMPLE_HARDWARE_CARDPUTER_ADV + default 12 if EXAMPLE_HARDWARE_CUSTOM + +config EXAMPLE_SPI_MOSI_GPIO + int "SPI MOSI GPIO Num" + range 0 50 + default 41 if EXAMPLE_HARDWARE_TDECK + default 14 if EXAMPLE_HARDWARE_CARDPUTER_ADV + default 11 if EXAMPLE_HARDWARE_CUSTOM + +config EXAMPLE_SPI_MISO_GPIO + int "SPI MISO GPIO Num" + range 0 50 + default 38 if EXAMPLE_HARDWARE_TDECK + default 39 if EXAMPLE_HARDWARE_CARDPUTER_ADV + default 13 if EXAMPLE_HARDWARE_CUSTOM + +config EXAMPLE_RADIO_CS_GPIO + int "Radio chip select (NSS) GPIO Num" + range 0 50 + default 9 if EXAMPLE_HARDWARE_TDECK + default 5 if EXAMPLE_HARDWARE_CARDPUTER_ADV + default 10 if EXAMPLE_HARDWARE_CUSTOM + +config EXAMPLE_RADIO_DIO1_GPIO + int "Radio DIO1 (IRQ) GPIO Num" + range 0 50 + default 45 if EXAMPLE_HARDWARE_TDECK + default 4 if EXAMPLE_HARDWARE_CARDPUTER_ADV + default 14 if EXAMPLE_HARDWARE_CUSTOM + +config EXAMPLE_RADIO_BUSY_GPIO + int "Radio BUSY GPIO Num" + range 0 50 + default 13 if EXAMPLE_HARDWARE_TDECK + default 6 if EXAMPLE_HARDWARE_CARDPUTER_ADV + default 15 if EXAMPLE_HARDWARE_CUSTOM + +config EXAMPLE_RADIO_RESET_GPIO + int "Radio NRESET GPIO Num" + range 0 50 + default 17 if EXAMPLE_HARDWARE_TDECK + default 3 if EXAMPLE_HARDWARE_CARDPUTER_ADV + default 16 if EXAMPLE_HARDWARE_CUSTOM + +config EXAMPLE_POWER_ENABLE_GPIO + int "Board power-enable GPIO Num (-1 if none)" + range -1 50 + default 10 if EXAMPLE_HARDWARE_TDECK + default -1 if EXAMPLE_HARDWARE_CARDPUTER_ADV + default -1 if EXAMPLE_HARDWARE_CUSTOM + help + GPIO which must be driven high to power the radio (e.g. the T-Deck + peripheral power rail on GPIO 10). Set to -1 if not needed. + +config EXAMPLE_RADIO_HAS_TCXO + bool "Radio module has a TCXO on DIO3" + default y if EXAMPLE_HARDWARE_TDECK + default n + help + Whether the radio module uses DIO3 to power a TCXO (true for the + T-Deck's HPD16A module, at 1.8V). + +config EXAMPLE_TRANSMIT_INTERVAL_MS + int "Transmit interval (ms)" + range 1000 3600000 + default 5000 + help + How often to transmit a ping packet. + +endmenu diff --git a/components/sx126x/example/main/sx126x_example.cpp b/components/sx126x/example/main/sx126x_example.cpp new file mode 100755 index 000000000..df7a80393 --- /dev/null +++ b/components/sx126x/example/main/sx126x_example.cpp @@ -0,0 +1,176 @@ +#include +#include +#include +#include +#include + +#include + +#include "interrupt.hpp" +#include "logger.hpp" +#include "spi.hpp" +#include "sx126x.hpp" +#include "task.hpp" + +#if CONFIG_EXAMPLE_HARDWARE_CARDPUTER_ADV +#include "i2c.hpp" +#endif + +using namespace std::chrono_literals; + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "SX126x Example", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting example!"); + + static constexpr gpio_num_t spi_sclk = (gpio_num_t)CONFIG_EXAMPLE_SPI_SCLK_GPIO; + static constexpr gpio_num_t spi_mosi = (gpio_num_t)CONFIG_EXAMPLE_SPI_MOSI_GPIO; + static constexpr gpio_num_t spi_miso = (gpio_num_t)CONFIG_EXAMPLE_SPI_MISO_GPIO; + static constexpr gpio_num_t radio_cs = (gpio_num_t)CONFIG_EXAMPLE_RADIO_CS_GPIO; + static constexpr gpio_num_t radio_dio1 = (gpio_num_t)CONFIG_EXAMPLE_RADIO_DIO1_GPIO; + static constexpr gpio_num_t radio_busy = (gpio_num_t)CONFIG_EXAMPLE_RADIO_BUSY_GPIO; + static constexpr gpio_num_t radio_reset = (gpio_num_t)CONFIG_EXAMPLE_RADIO_RESET_GPIO; + +#if CONFIG_EXAMPLE_POWER_ENABLE_GPIO >= 0 + // some boards (e.g. T-Deck) gate power to the radio behind a GPIO + static constexpr gpio_num_t power_enable = (gpio_num_t)CONFIG_EXAMPLE_POWER_ENABLE_GPIO; + gpio_set_direction(power_enable, GPIO_MODE_OUTPUT); + gpio_set_level(power_enable, 1); + std::this_thread::sleep_for(100ms); +#endif + +#if CONFIG_EXAMPLE_HARDWARE_CARDPUTER_ADV + // The Cardputer-Adv LoRa+GPS Cap (U214) routes the radio's RF switch + // through P0 of a PI4IOE5V6408 I2C IO expander - it must be driven high to + // connect the antenna. P1 enables the GPS LNA. + espp::I2c i2c({.port = I2C_NUM_0, + .sda_io_num = GPIO_NUM_8, + .scl_io_num = GPIO_NUM_9, + .sda_pullup_en = GPIO_PULLUP_ENABLE, + .scl_pullup_en = GPIO_PULLUP_ENABLE}); + { + static constexpr uint8_t expander_address = 0x43; + std::error_code ec; + auto expander = i2c.add_device({.device_address = expander_address}, ec); + if (!expander) { + logger.error("Failed to add IO expander device: {}", ec.message()); + return; + } + // set P0 (RF switch) and P1 (GPS LNA) as outputs, driven high, not Hi-Z + const uint8_t init_regs[][2] = { + {0x03, 0x03}, // direction: P0, P1 outputs + {0x05, 0x03}, // output state: P0, P1 high + {0x07, 0x00}, // output high-impedance: disabled + }; + for (const auto ® : init_regs) { + expander->write(reg, 2, ec); + if (ec) { + logger.error("Failed to configure IO expander: {}", ec.message()); + return; + } + } + } +#endif + + //! [sx126x example] + // create the (shared) SPI bus and add the radio as a device on it + espp::Spi spi({.host = SPI2_HOST, + .sclk_io_num = spi_sclk, + .mosi_io_num = spi_mosi, + .miso_io_num = spi_miso}); + std::error_code ec; + auto radio_device = spi.add_device( + {.mode = 0, .clock_speed_hz = 8 * 1000 * 1000, .cs_io_num = radio_cs, .queue_size = 1}, ec); + if (!radio_device) { + logger.error("Failed to add radio SPI device: {}", ec.message()); + return; + } + + // configure the radio's control pins + gpio_set_direction(radio_busy, GPIO_MODE_INPUT); + gpio_set_direction(radio_reset, GPIO_MODE_OUTPUT); + + // The SX126x requires chip select to be held asserted across the write and + // read phases of a command, so implement write_then_read as a single + // full-duplex transfer of the concatenated length. + auto write_fn = [&](const uint8_t *data, size_t length) -> bool { + std::error_code ec; + return radio_device->write(std::span{data, length}, {}, ec); + }; + auto write_then_read_fn = [&](const uint8_t *write_data, size_t write_length, uint8_t *read_data, + size_t read_length) -> bool { + std::vector tx(write_length + read_length, 0); + std::vector rx(write_length + read_length, 0); + std::memcpy(tx.data(), write_data, write_length); + std::error_code ec; + if (!radio_device->transfer(tx, rx, {}, ec)) { + return false; + } + std::memcpy(read_data, rx.data() + write_length, read_length); + return true; + }; + + // make the radio + espp::Sx126x radio({ + .variant = espp::Sx126x::Variant::SX1262, .write = write_fn, + .write_then_read = write_then_read_fn, + .is_busy = [&]() -> bool { return gpio_get_level(radio_busy); }, + .reset = [&](bool level) { gpio_set_level(radio_reset, level); }, +#if CONFIG_EXAMPLE_RADIO_HAS_TCXO + .tcxo_voltage = 1.8f, +#endif + .radio_config = + { + .frequency_hz = CONFIG_EXAMPLE_RADIO_FREQUENCY_HZ, + .tx_power_dbm = 22, + .spreading_factor = espp::Sx126x::SpreadingFactor::SF11, + .bandwidth = espp::Sx126x::Bandwidth::BW_250_KHZ, + .coding_rate = espp::Sx126x::CodingRate::CR_4_5, + .preamble_length = 16, + .sync_word = 0x2B, + }, + .on_receive = + [&](const espp::Sx126x::RxPacket &packet) { + std::string text(packet.data.begin(), packet.data.end()); + logger.info("Received {} bytes (RSSI {:.1f} dBm, SNR {:.2f} dB): '{}'", + packet.data.size(), packet.status.rssi, packet.status.snr, text); + }, + .on_transmit_done = [&]() { logger.info("Transmit complete"); }, .auto_init = false, + .log_level = espp::Logger::Verbosity::INFO + }); + if (!radio.initialize(ec)) { + logger.error("Failed to initialize radio: {}", ec.message()); + return; + } + + // service the radio's IRQs (from a task context) whenever DIO1 goes high + espp::Interrupt interrupts( + {.interrupts = {{.gpio_num = radio_dio1, + .callback = + [&](const espp::Interrupt::Event &event) { + std::error_code ec; + radio.handle_dio1_interrupt(ec); + }, + .active_level = espp::Interrupt::ActiveLevel::HIGH, + .interrupt_type = espp::Interrupt::Type::RISING_EDGE}}, + .task_config = {.name = "radio_interrupts", .stack_size_bytes = 6 * 1024}}); + + // listen for packets, and send a ping every transmit interval + if (!radio.start_receive(ec)) { + logger.error("Failed to start receiving: {}", ec.message()); + return; + } + logger.info("Radio listening on {:.3f} MHz", CONFIG_EXAMPLE_RADIO_FREQUENCY_HZ / 1e6f); + + int packet_count = 0; + while (true) { + std::this_thread::sleep_for(std::chrono::milliseconds(CONFIG_EXAMPLE_TRANSMIT_INTERVAL_MS)); + std::string message = fmt::format("ping {} from sx126x example", packet_count++); + logger.info("Transmitting: '{}'", message); + std::span payload{reinterpret_cast(message.data()), + message.size()}; + if (!radio.transmit(payload, 5s, ec)) { + logger.error("Failed to transmit: {}", ec.message()); + } + } + //! [sx126x example] +} diff --git a/components/sx126x/example/sdkconfig.defaults b/components/sx126x/example/sdkconfig.defaults new file mode 100755 index 000000000..603c2cc6c --- /dev/null +++ b/components/sx126x/example/sdkconfig.defaults @@ -0,0 +1,11 @@ +CONFIG_IDF_TARGET="esp32s3" + +CONFIG_FREERTOS_HZ=1000 + +# set compiler optimization level to -O2 (compile for performance) +CONFIG_COMPILER_OPTIMIZATION_PERF=y + +# Common ESP-related +# +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 diff --git a/components/sx126x/idf_component.yml b/components/sx126x/idf_component.yml new file mode 100755 index 000000000..c831cb0c0 --- /dev/null +++ b/components/sx126x/idf_component.yml @@ -0,0 +1,22 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "SX126x (SX1261/SX1262/LLCC68) sub-GHz LoRa radio transceiver component for ESP-IDF" +url: "https://github.com/esp-cpp/espp/tree/main/components/sx126x" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/wireless/sx126x.html" +examples: + - path: example +tags: + - cpp + - Component + - LoRa + - Radio + - SX1262 + - SPI + - Peripheral +dependencies: + idf: + version: '>=5.0' + espp/base_peripheral: '>=1.0' diff --git a/components/sx126x/include/sx126x.hpp b/components/sx126x/include/sx126x.hpp new file mode 100644 index 000000000..c2892d2f4 --- /dev/null +++ b/components/sx126x/include/sx126x.hpp @@ -0,0 +1,432 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "base_peripheral.hpp" + +namespace espp { +/// Driver for the Semtech SX126x series of sub-GHz LoRa radio transceivers +/// (SX1261, SX1262) and compatible chips (LLCC68). +/// +/// The SX126x is a command-based SPI peripheral: each operation is an opcode +/// followed by parameters, and read operations return data in the same SPI +/// transaction (with the chip select held asserted for the entire command). +/// Because of this, the driver requires the `write_then_read` function provided +/// in the configuration to perform the write phase and the read phase within a +/// single chip-select assertion - e.g. by performing one full-duplex transfer +/// of the concatenated write + read lengths. See the example for how to do +/// this with the espp::Spi component. +/// +/// The chip signals command processing via its BUSY pin, which must be +/// provided via the `is_busy` function in the configuration. The `reset` +/// function (driving the active-low NRESET pin) is optional but recommended. +/// +/// Interrupt-driven operation is supported by connecting the radio's DIO1 pin +/// to a GPIO interrupt (e.g. with the espp::Interrupt component) and calling +/// handle_dio1_interrupt() from the interrupt handler / task. Fully-polled +/// operation (no DIO1 wiring) is also supported via the blocking transmit() +/// and by periodically calling service_interrupts(). +/// +/// This driver only implements the LoRa modem (not FSK), and always uses +/// explicit (variable-length) headers. +/// +/// \section sx126x_example Example +/// \snippet sx126x_example.cpp sx126x example +class Sx126x : public BasePeripheral { +public: + /// The chip variant controlled by this driver. The variants differ in their + /// power amplifier configuration (SX1261: low power, up to +15 dBm; SX1262 / + /// LLCC68: high power, up to +22 dBm) and supported spreading factors + /// (LLCC68 supports only SF5-SF11). + enum class Variant : uint8_t { + SX1261, ///< Semtech SX1261 (up to +15 dBm) + SX1262, ///< Semtech SX1262 (up to +22 dBm) + LLCC68, ///< Semtech LLCC68 (up to +22 dBm, SF5-SF11 only) + }; + + /// LoRa spreading factor (SF). Higher spreading factors increase range and + /// time-on-air, and decrease data rate. + enum class SpreadingFactor : uint8_t { + SF5 = 5, ///< 5 chips / symbol + SF6 = 6, ///< 6 chips / symbol + SF7 = 7, ///< 7 chips / symbol + SF8 = 8, ///< 8 chips / symbol + SF9 = 9, ///< 9 chips / symbol + SF10 = 10, ///< 10 chips / symbol + SF11 = 11, ///< 11 chips / symbol + SF12 = 12, ///< 12 chips / symbol + }; + + /// LoRa signal bandwidth. + enum class Bandwidth : uint8_t { + BW_7_8_KHZ = 0x00, ///< 7.81 kHz + BW_10_4_KHZ = 0x08, ///< 10.42 kHz + BW_15_6_KHZ = 0x01, ///< 15.63 kHz + BW_20_8_KHZ = 0x09, ///< 20.83 kHz + BW_31_25_KHZ = 0x02, ///< 31.25 kHz + BW_41_7_KHZ = 0x0A, ///< 41.67 kHz + BW_62_5_KHZ = 0x03, ///< 62.50 kHz + BW_125_KHZ = 0x04, ///< 125 kHz + BW_250_KHZ = 0x05, ///< 250 kHz + BW_500_KHZ = 0x06, ///< 500 kHz + }; + + /// LoRa forward error correction coding rate. + enum class CodingRate : uint8_t { + CR_4_5 = 0x01, ///< 4/5 + CR_4_6 = 0x02, ///< 4/6 + CR_4_7 = 0x03, ///< 4/7 + CR_4_8 = 0x04, ///< 4/8 + }; + + /// Power amplifier ramp time. + enum class RampTime : uint8_t { + RAMP_10_US = 0x00, ///< 10 us + RAMP_20_US = 0x01, ///< 20 us + RAMP_40_US = 0x02, ///< 40 us + RAMP_80_US = 0x03, ///< 80 us + RAMP_200_US = 0x04, ///< 200 us + RAMP_800_US = 0x05, ///< 800 us + RAMP_1700_US = 0x06, ///< 1700 us + RAMP_3400_US = 0x07, ///< 3400 us + }; + + /// Standby oscillator configuration. + enum class StandbyMode : uint8_t { + RC = 0x00, ///< Standby with 13 MHz RC oscillator (lowest power) + XOSC = 0x01, ///< Standby with crystal oscillator running (faster TX/RX entry) + }; + + /// Interrupt flags reported by the radio (bitfield). These match the chip's + /// IRQ register bit positions. + enum Irq : uint16_t { + IRQ_NONE = 0x0000, ///< No interrupts + IRQ_TX_DONE = 0x0001, ///< Packet transmission completed + IRQ_RX_DONE = 0x0002, ///< Packet reception completed + IRQ_PREAMBLE_DETECTED = 0x0004, ///< Preamble detected + IRQ_SYNC_WORD_VALID = 0x0008, ///< Sync word valid (FSK only) + IRQ_HEADER_VALID = 0x0010, ///< LoRa header received & valid + IRQ_HEADER_ERR = 0x0020, ///< LoRa header CRC error + IRQ_CRC_ERR = 0x0040, ///< Payload CRC error + IRQ_CAD_DONE = 0x0080, ///< Channel activity detection finished + IRQ_CAD_DETECTED = 0x0100, ///< Channel activity detected + IRQ_TIMEOUT = 0x0200, ///< RX or TX timeout + IRQ_ALL = 0x03FF, ///< All interrupts + }; + + /// Status of a received packet. + struct PacketStatus { + float rssi{0}; ///< Average RSSI over the packet, in dBm + float snr{0}; ///< Estimated SNR of the packet, in dB + float signal_rssi{0}; ///< RSSI of the despread LoRa signal, in dBm + }; + + /// A received packet, as provided to the receive callback. + struct RxPacket { + std::vector data{}; ///< The packet payload + PacketStatus status{}; ///< RSSI / SNR of the packet + }; + + /// Callback invoked (from the caller of handle_dio1_interrupt / + /// service_interrupts) when a packet has been received. + typedef std::function receive_callback_fn; + + /// Callback invoked when a packet transmission has completed. + typedef std::function transmit_done_callback_fn; + + /// Callback invoked when channel activity detection completes. The + /// parameter is true if activity was detected. + typedef std::function cad_callback_fn; + + /// Function which returns the state of the radio's BUSY pin (true = busy). + typedef std::function is_busy_fn; + + /// Function which drives the radio's active-low NRESET pin. The parameter + /// is the logic level to drive (false = held in reset). + typedef std::function reset_fn; + + /// LoRa modem configuration. The defaults match the Meshtastic "LongFast" + /// preset modulation (US frequency shown; set the frequency for your + /// region / channel). + struct RadioConfig { + uint32_t frequency_hz = 906875000; ///< RF center frequency, in Hz + int8_t tx_power_dbm = 22; ///< TX power in dBm. Clamped to the + ///< variant's supported range. + RampTime ramp_time = RampTime::RAMP_200_US; ///< PA ramp time + SpreadingFactor spreading_factor = SpreadingFactor::SF11; ///< Spreading factor + Bandwidth bandwidth = Bandwidth::BW_250_KHZ; ///< Signal bandwidth + CodingRate coding_rate = CodingRate::CR_4_5; ///< Coding rate + uint16_t preamble_length = 16; ///< Preamble length in symbols + bool crc_enabled = true; ///< Whether to append/check payload CRC + bool invert_iq = false; ///< Whether to invert the IQ signals + uint8_t sync_word = 0x2B; ///< LoRa sync word. 0x12 = private networks, 0x34 + ///< = public (LoRaWAN), 0x2B = Meshtastic. + bool rx_boosted_gain = true; ///< Use the boosted-gain RX mode (~2 dB better + ///< sensitivity for ~0.7 mA more current) + }; + + /// Configuration for the Sx126x driver. + struct Config { + Variant variant = Variant::SX1262; ///< Which chip this is + BasePeripheral::write_fn write; ///< Function to write bytes to the radio (one full + ///< chip-select assertion per call) + BasePeripheral::write_then_read_fn write_then_read; ///< Function to write then read bytes + ///< from the radio, holding chip-select + ///< asserted across both phases + is_busy_fn is_busy; ///< Function returning the BUSY pin state + reset_fn reset = nullptr; ///< Optional function driving the NRESET pin + float tcxo_voltage = 0.0f; ///< If > 0, DIO3 is used to power a TCXO at this voltage + ///< (1.6-3.3V). 0 disables TCXO control (crystal used). + std::chrono::microseconds tcxo_delay = + std::chrono::microseconds(5000); ///< TCXO stabilization delay + bool use_dio2_as_rf_switch = true; ///< Whether DIO2 controls the RF switch (true on most + ///< modules, including the T-Deck and M5Stack LoRa + ///< modules) + bool use_dcdc_regulator = true; ///< Use the DC-DC regulator (true on most modules) + ///< instead of only the LDO + RadioConfig radio_config{}; ///< Initial radio (modem) configuration + receive_callback_fn on_receive = nullptr; ///< Called when a packet is received + transmit_done_callback_fn on_transmit_done = nullptr; ///< Called when transmission completes + cad_callback_fn on_cad_done = nullptr; ///< Called when CAD completes + bool auto_init = true; ///< Whether to initialize the radio on construction + Logger::Verbosity log_level{Logger::Verbosity::WARN}; ///< Log verbosity for the driver + }; + + /// Constructor + /// \param config The configuration for the driver + explicit Sx126x(const Config &config); + + /// Initialize the radio: reset (if a reset function was provided), verify + /// SPI communications, configure the regulator / TCXO / RF switch, + /// calibrate, and apply the radio configuration. + /// \param ec The error code to set if there is an error + /// \return True if initialization succeeded + bool initialize(std::error_code &ec); + + /// Whether the radio has been successfully initialized + /// \return True if the radio has been initialized + bool is_initialized() const { return initialized_; } + + /// Apply a new radio (modem) configuration. The radio is placed in standby. + /// \param config The radio configuration to apply + /// \param ec The error code to set if there is an error + /// \return True if the configuration was applied + bool set_radio_config(const RadioConfig &config, std::error_code &ec); + + /// Get the current radio (modem) configuration + /// \return The current radio configuration + const RadioConfig &radio_config() const { return config_.radio_config; } + + /// Set the RF center frequency. Also performs image calibration for the + /// new frequency band. + /// \param frequency_hz The frequency in Hz + /// \param ec The error code to set if there is an error + void set_frequency(uint32_t frequency_hz, std::error_code &ec); + + /// Set the TX output power. + /// \param power_dbm The power in dBm; clamped to the variant's range + /// (SX1261: -17 to +15, SX1262/LLCC68: -9 to +22) + /// \param ec The error code to set if there is an error + void set_tx_power(int8_t power_dbm, std::error_code &ec); + + /// Set the LoRa sync word. + /// \param sync_word The sync word (0x12 private, 0x34 public, 0x2B Meshtastic) + /// \param ec The error code to set if there is an error + void set_sync_word(uint8_t sync_word, std::error_code &ec); + + /// Set the callback invoked when a packet is received + /// \param callback The callback to invoke + void set_receive_callback(const receive_callback_fn &callback); + + /// Set the callback invoked when a transmission completes + /// \param callback The callback to invoke + void set_transmit_done_callback(const transmit_done_callback_fn &callback); + + /// Set the callback invoked when channel activity detection completes + /// \param callback The callback to invoke + void set_cad_callback(const cad_callback_fn &callback); + + /// Transmit a packet, blocking until transmission completes or the timeout + /// elapses. Polls the radio's IRQ status, so this works without DIO1 wired. + /// The radio is returned to standby (or receive, if it was receiving) + /// afterwards. + /// \param data The payload to transmit (1-255 bytes) + /// \param timeout The maximum time to wait for transmission to complete + /// \param ec The error code to set if there is an error + /// \return True if the packet was transmitted + bool transmit(std::span data, std::chrono::milliseconds timeout, + std::error_code &ec); + + /// Start transmitting a packet without blocking. Completion is signaled via + /// the transmit-done callback when handle_dio1_interrupt() / + /// service_interrupts() is called. + /// \param data The payload to transmit (1-255 bytes) + /// \param ec The error code to set if there is an error + /// \return True if the transmission was started + bool start_transmit(std::span data, std::error_code &ec); + + /// Enter continuous receive mode. Received packets are delivered via the + /// receive callback when handle_dio1_interrupt() / service_interrupts() is + /// called. + /// \param ec The error code to set if there is an error + /// \return True if receive mode was entered + bool start_receive(std::error_code &ec); + + /// Start a channel activity detection. The result is delivered via the CAD + /// callback when handle_dio1_interrupt() / service_interrupts() is called. + /// \param ec The error code to set if there is an error + /// \return True if CAD was started + bool start_cad(std::error_code &ec); + + /// Put the radio into standby mode. + /// \param mode The standby oscillator mode + /// \param ec The error code to set if there is an error + void standby(std::error_code &ec, StandbyMode mode = StandbyMode::RC); + + /// Put the radio into its lowest-power sleep mode (cold start, configuration + /// retained). Waking requires any SPI transaction (e.g. standby()). + /// \param ec The error code to set if there is an error + void sleep(std::error_code &ec); + + /// Whether the radio is currently in receive mode + /// \return True if the radio was last commanded into receive mode + bool is_receiving() const { return receiving_; } + + /// Whether a preamble/header has been detected and reception is likely in + /// progress. Useful for channel-activity checks before transmitting. + /// \param ec The error code to set if there is an error + /// \return True if the radio has detected preamble/valid header since the + /// last packet completed + bool is_reception_in_progress(std::error_code &ec); + + /// Get the instantaneous RSSI measured by the radio (only meaningful in + /// receive mode). + /// \param ec The error code to set if there is an error + /// \return The instantaneous RSSI in dBm + float get_rssi_inst(std::error_code &ec); + + /// Handle a DIO1 interrupt: read & clear the radio's IRQ flags and invoke + /// the appropriate callbacks (receive / transmit-done / CAD). Call this + /// from a task context (e.g. an espp::Interrupt callback) - it performs SPI + /// transactions and must not be called from an ISR. + /// \param ec The error code to set if there is an error + /// \return The IRQ flags which were set (bitfield of Irq values) + uint16_t handle_dio1_interrupt(std::error_code &ec); + + /// Poll the radio's IRQ status and dispatch callbacks - identical to + /// handle_dio1_interrupt(), provided for readability in polled operation. + /// \param ec The error code to set if there is an error + /// \return The IRQ flags which were set (bitfield of Irq values) + uint16_t service_interrupts(std::error_code &ec) { return handle_dio1_interrupt(ec); } + + /// Get the time-on-air for a payload with the current radio configuration. + /// \param payload_length The payload length in bytes + /// \return The approximate time-on-air + std::chrono::milliseconds time_on_air(size_t payload_length) const; + + /// Read a radio register (for advanced use). + /// \param address The register address + /// \param ec The error code to set if there is an error + /// \return The register value + uint8_t read_radio_register(uint16_t address, std::error_code &ec); + + /// Write a radio register (for advanced use). + /// \param address The register address + /// \param value The value to write + /// \param ec The error code to set if there is an error + void write_radio_register(uint16_t address, uint8_t value, std::error_code &ec); + +protected: + static constexpr uint32_t XTAL_FREQ = 32000000; + static constexpr uint32_t FREQ_DIV = 33554432; // 2^25 + static constexpr uint8_t MAX_PAYLOAD_LENGTH = 255; + + // Command opcodes + static constexpr uint8_t CMD_SET_SLEEP = 0x84; + static constexpr uint8_t CMD_SET_STANDBY = 0x80; + static constexpr uint8_t CMD_SET_FS = 0xC1; + static constexpr uint8_t CMD_SET_TX = 0x83; + static constexpr uint8_t CMD_SET_RX = 0x82; + static constexpr uint8_t CMD_STOP_TIMER_ON_PREAMBLE = 0x9F; + static constexpr uint8_t CMD_SET_CAD = 0xC5; + static constexpr uint8_t CMD_SET_REGULATOR_MODE = 0x96; + static constexpr uint8_t CMD_CALIBRATE = 0x89; + static constexpr uint8_t CMD_CALIBRATE_IMAGE = 0x98; + static constexpr uint8_t CMD_SET_PA_CONFIG = 0x95; + static constexpr uint8_t CMD_SET_RX_TX_FALLBACK_MODE = 0x93; + static constexpr uint8_t CMD_WRITE_REGISTER = 0x0D; + static constexpr uint8_t CMD_READ_REGISTER = 0x1D; + static constexpr uint8_t CMD_WRITE_BUFFER = 0x0E; + static constexpr uint8_t CMD_READ_BUFFER = 0x1E; + static constexpr uint8_t CMD_SET_DIO_IRQ_PARAMS = 0x08; + static constexpr uint8_t CMD_GET_IRQ_STATUS = 0x12; + static constexpr uint8_t CMD_CLEAR_IRQ_STATUS = 0x02; + static constexpr uint8_t CMD_SET_DIO2_AS_RF_SWITCH = 0x9D; + static constexpr uint8_t CMD_SET_DIO3_AS_TCXO_CTRL = 0x97; + static constexpr uint8_t CMD_SET_RF_FREQUENCY = 0x86; + static constexpr uint8_t CMD_SET_PACKET_TYPE = 0x8A; + static constexpr uint8_t CMD_GET_PACKET_TYPE = 0x11; + static constexpr uint8_t CMD_SET_TX_PARAMS = 0x8E; + static constexpr uint8_t CMD_SET_MODULATION_PARAMS = 0x8B; + static constexpr uint8_t CMD_SET_PACKET_PARAMS = 0x8C; + static constexpr uint8_t CMD_SET_CAD_PARAMS = 0x88; + static constexpr uint8_t CMD_SET_BUFFER_BASE_ADDRESS = 0x8F; + static constexpr uint8_t CMD_SET_LORA_SYMB_NUM_TIMEOUT = 0xA0; + static constexpr uint8_t CMD_GET_STATUS = 0xC0; + static constexpr uint8_t CMD_GET_RSSI_INST = 0x15; + static constexpr uint8_t CMD_GET_RX_BUFFER_STATUS = 0x13; + static constexpr uint8_t CMD_GET_PACKET_STATUS = 0x14; + static constexpr uint8_t CMD_GET_DEVICE_ERRORS = 0x17; + static constexpr uint8_t CMD_CLEAR_DEVICE_ERRORS = 0x07; + + // Register addresses + static constexpr uint16_t REG_SYNC_WORD_MSB = 0x0740; + static constexpr uint16_t REG_SYNC_WORD_LSB = 0x0741; + static constexpr uint16_t REG_RX_GAIN = 0x08AC; + static constexpr uint16_t REG_TX_CLAMP_CONFIG = 0x08D8; + static constexpr uint16_t REG_TX_MODULATION = 0x0889; + static constexpr uint16_t REG_IQ_POLARITY = 0x0736; + static constexpr uint16_t REG_RTC_CONTROL = 0x0902; + static constexpr uint16_t REG_EVENT_MASK = 0x0944; + + bool wait_on_busy(std::error_code &ec, + std::chrono::milliseconds timeout = std::chrono::milliseconds(10)); + void cmd_write(uint8_t opcode, const uint8_t *params, size_t length, std::error_code &ec); + void cmd_read(uint8_t opcode, const uint8_t *params, size_t params_length, uint8_t *response, + size_t response_length, std::error_code &ec); + void write_registers(uint16_t address, const uint8_t *data, size_t length, std::error_code &ec); + void read_registers(uint16_t address, uint8_t *data, size_t length, std::error_code &ec); + void write_tx_buffer(std::span data, std::error_code &ec); + + void set_packet_params(uint8_t payload_length, std::error_code &ec); + void set_modulation_params(std::error_code &ec); + void set_pa_config(std::error_code &ec); + void set_dio_irq_params(uint16_t irq_mask, uint16_t dio1_mask, std::error_code &ec); + uint16_t get_irq_status(std::error_code &ec); + void clear_irq_status(uint16_t mask, std::error_code &ec); + uint16_t get_device_errors(std::error_code &ec); + void clear_device_errors(std::error_code &ec); + void calibrate_all(std::error_code &ec); + void calibrate_image(uint32_t frequency_hz, std::error_code &ec); + void apply_workarounds(std::error_code &ec); + bool read_packet(RxPacket &packet, std::error_code &ec); + PacketStatus get_packet_status(std::error_code &ec); + bool low_data_rate_optimize() const; + float bandwidth_hz() const; + + Config config_; + std::atomic initialized_{false}; + std::atomic receiving_{false}; + std::atomic transmitting_{false}; + // TX completion, recorded by handle_dio1_interrupt() (the single reader of + // the IRQ status) so that blocking transmit() does not race the DIO1 + // interrupt to read / clear the TX_DONE flag + std::atomic tx_done_{false}; + std::atomic tx_timeout_{false}; +}; +} // namespace espp diff --git a/components/sx126x/src/sx126x.cpp b/components/sx126x/src/sx126x.cpp new file mode 100644 index 000000000..afe59fb02 --- /dev/null +++ b/components/sx126x/src/sx126x.cpp @@ -0,0 +1,858 @@ +#include "sx126x.hpp" + +#include +#include +#include +#include + +using namespace espp; + +Sx126x::Sx126x(const Config &config) + : BasePeripheral( + {.write = config.write, .write_then_read = config.write_then_read}, "Sx126x", + config.log_level) + , config_(config) { + if (config_.auto_init) { + std::error_code ec; + initialize(ec); + if (ec) { + logger_.error("Failed to initialize radio: {}", ec.message()); + } + } +} + +bool Sx126x::initialize(std::error_code &ec) { + logger_.info("Initializing SX126x"); + + // hardware reset if we can, otherwise wake the chip with a standby command + if (config_.reset) { + config_.reset(false); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + config_.reset(true); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + if (!wait_on_busy(ec, std::chrono::milliseconds(100))) { + logger_.error("Radio BUSY stuck high after reset"); + return false; + } + standby(ec); + if (ec) { + return false; + } + + // verify communications: the LoRa sync word register has a known reset + // value of 0x1424 + uint8_t sync[2] = {0, 0}; + read_registers(REG_SYNC_WORD_MSB, sync, 2, ec); + if (ec) { + return false; + } + if (sync[0] != 0x14 || sync[1] != 0x24) { + logger_.error("Unexpected sync word reset value 0x{:02x}{:02x} - SPI comms failure?", sync[0], + sync[1]); + ec = std::make_error_code(std::errc::no_such_device); + return false; + } + logger_.debug("SPI communications verified"); + + // configure the regulator + uint8_t reg_mode = config_.use_dcdc_regulator ? 0x01 : 0x00; + cmd_write(CMD_SET_REGULATOR_MODE, ®_mode, 1, ec); + if (ec) { + return false; + } + + // configure DIO3 as TCXO control if requested. This must be done before + // calibration, and requires re-calibration afterwards since the XOSC + // failure flag will be set. + if (config_.tcxo_voltage > 0.0f) { + static constexpr float voltages[] = {1.6f, 1.7f, 1.8f, 2.2f, 2.4f, 2.7f, 3.0f, 3.3f}; + uint8_t code = 0; + for (uint8_t i = 0; i < 8; i++) { + if (config_.tcxo_voltage <= voltages[i] + 0.05f) { + code = i; + break; + } + code = 7; + } + uint32_t delay_steps = (config_.tcxo_delay.count() * 1000) / 15625; // 15.625 us steps + uint8_t params[4] = {code, (uint8_t)((delay_steps >> 16) & 0xff), + (uint8_t)((delay_steps >> 8) & 0xff), (uint8_t)(delay_steps & 0xff)}; + cmd_write(CMD_SET_DIO3_AS_TCXO_CTRL, params, 4, ec); + if (ec) { + return false; + } + } + + calibrate_all(ec); + if (ec) { + return false; + } + + // the XOSC-start failure error is expected after enabling TCXO control; + // clear any errors from startup / calibration + uint16_t errors = get_device_errors(ec); + if (ec) { + return false; + } + if (errors) { + logger_.debug("Clearing device errors after calibration: 0x{:04x}", errors); + clear_device_errors(ec); + if (ec) { + return false; + } + } + + if (config_.use_dio2_as_rf_switch) { + uint8_t enable = 0x01; + cmd_write(CMD_SET_DIO2_AS_RF_SWITCH, &enable, 1, ec); + if (ec) { + return false; + } + } + + // LoRa packet type + uint8_t packet_type = 0x01; + cmd_write(CMD_SET_PACKET_TYPE, &packet_type, 1, ec); + if (ec) { + return false; + } + + // use the full 256-byte buffer for both TX and RX + uint8_t base_addrs[2] = {0x00, 0x00}; + cmd_write(CMD_SET_BUFFER_BASE_ADDRESS, base_addrs, 2, ec); + if (ec) { + return false; + } + + // fall back to standby (RC) after TX/RX completes + uint8_t fallback = 0x20; + cmd_write(CMD_SET_RX_TX_FALLBACK_MODE, &fallback, 1, ec); + if (ec) { + return false; + } + + // route all interesting IRQs to DIO1 + set_dio_irq_params( + IRQ_ALL, + IRQ_TX_DONE | IRQ_RX_DONE | IRQ_CRC_ERR | IRQ_CAD_DONE | IRQ_CAD_DETECTED | IRQ_TIMEOUT, ec); + if (ec) { + return false; + } + + initialized_ = true; + + if (!set_radio_config(config_.radio_config, ec)) { + initialized_ = false; + return false; + } + + logger_.info("SX126x initialized"); + return true; +} + +bool Sx126x::set_radio_config(const RadioConfig &config, std::error_code &ec) { + std::lock_guard lock(base_mutex_); + if (config_.variant == Variant::LLCC68 && config.spreading_factor == SpreadingFactor::SF12) { + logger_.error("LLCC68 does not support SF12"); + ec = std::make_error_code(std::errc::invalid_argument); + return false; + } + receiving_ = false; + standby(ec); + if (ec) { + return false; + } + config_.radio_config = config; + set_frequency(config.frequency_hz, ec); + if (ec) { + return false; + } + set_pa_config(ec); + if (ec) { + return false; + } + set_tx_power(config.tx_power_dbm, ec); + if (ec) { + return false; + } + set_modulation_params(ec); + if (ec) { + return false; + } + set_packet_params(MAX_PAYLOAD_LENGTH, ec); + if (ec) { + return false; + } + set_sync_word(config.sync_word, ec); + if (ec) { + return false; + } + // boosted RX gain (register write is lost on sleep; we don't use warm-start + // sleep so it only needs to be set here) + write_radio_register(REG_RX_GAIN, config.rx_boosted_gain ? 0x96 : 0x94, ec); + if (ec) { + return false; + } + apply_workarounds(ec); + if (ec) { + return false; + } + return true; +} + +void Sx126x::set_frequency(uint32_t frequency_hz, std::error_code &ec) { + std::lock_guard lock(base_mutex_); + calibrate_image(frequency_hz, ec); + if (ec) { + return; + } + // freq_reg = freq_hz * 2^25 / 32e6 + uint32_t freq_reg = (uint32_t)(((uint64_t)frequency_hz * FREQ_DIV) / XTAL_FREQ); + uint8_t params[4] = {(uint8_t)((freq_reg >> 24) & 0xff), (uint8_t)((freq_reg >> 16) & 0xff), + (uint8_t)((freq_reg >> 8) & 0xff), (uint8_t)(freq_reg & 0xff)}; + cmd_write(CMD_SET_RF_FREQUENCY, params, 4, ec); + if (!ec) { + config_.radio_config.frequency_hz = frequency_hz; + } +} + +void Sx126x::set_tx_power(int8_t power_dbm, std::error_code &ec) { + std::lock_guard lock(base_mutex_); + if (config_.variant == Variant::SX1261) { + power_dbm = std::clamp(power_dbm, -17, 14); + } else { + power_dbm = std::clamp(power_dbm, -9, 22); + } + uint8_t params[2] = {(uint8_t)power_dbm, (uint8_t)config_.radio_config.ramp_time}; + cmd_write(CMD_SET_TX_PARAMS, params, 2, ec); + if (!ec) { + config_.radio_config.tx_power_dbm = power_dbm; + } +} + +void Sx126x::set_sync_word(uint8_t sync_word, std::error_code &ec) { + std::lock_guard lock(base_mutex_); + // the 8-bit "public" sync word is spread across the two sync word + // registers, with the low nibble of each fixed at 0x4 + uint8_t data[2] = {(uint8_t)((sync_word & 0xF0) | 0x04), + (uint8_t)(((sync_word & 0x0F) << 4) | 0x04)}; + write_registers(REG_SYNC_WORD_MSB, data, 2, ec); + if (!ec) { + config_.radio_config.sync_word = sync_word; + } +} + +void Sx126x::set_receive_callback(const receive_callback_fn &callback) { + std::lock_guard lock(base_mutex_); + config_.on_receive = callback; +} + +void Sx126x::set_transmit_done_callback(const transmit_done_callback_fn &callback) { + std::lock_guard lock(base_mutex_); + config_.on_transmit_done = callback; +} + +void Sx126x::set_cad_callback(const cad_callback_fn &callback) { + std::lock_guard lock(base_mutex_); + config_.on_cad_done = callback; +} + +bool Sx126x::transmit(std::span data, std::chrono::milliseconds timeout, + std::error_code &ec) { + bool was_receiving = receiving_; + if (!start_transmit(data, ec)) { + return false; + } + // Wait for completion. TX completion is signaled through tx_done_ / + // tx_timeout_, which handle_dio1_interrupt() sets. That way this works both + // when DIO1 is wired to an interrupt (the interrupt handler processes the + // IRQ and sets the flag) and when it is not (we call handle_dio1_interrupt() + // ourselves below) - without the two racing to read and clear the same + // IRQ-status register. + auto start = std::chrono::steady_clock::now(); + while (!tx_done_ && !tx_timeout_) { + handle_dio1_interrupt(ec); + if (ec) { + return false; + } + if (tx_done_ || tx_timeout_) { + break; + } + if (std::chrono::steady_clock::now() - start > timeout) { + transmitting_ = false; + // A transmission that never completes usually means the radio's + // oscillator or PLL is not running - most often a wrong TCXO + // configuration. Surface the device errors to make that diagnosable. + std::error_code err_ec; + uint16_t dev_errors = get_device_errors(err_ec); + if (!err_ec && dev_errors) { + logger_.error("TX timed out; radio device errors 0x{:04x} (0x20 = XOSC/TCXO start error, " + "0x40 = PLL lock error) - check the TCXO voltage", + dev_errors); + } else { + logger_.error("TX timed out (no radio device errors reported)"); + } + standby(ec); + ec = std::make_error_code(std::errc::timed_out); + return false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + if (tx_timeout_) { + logger_.error("TX timeout reported by radio"); + ec = std::make_error_code(std::errc::timed_out); + return false; + } + if (was_receiving) { + return start_receive(ec); + } + return true; +} + +bool Sx126x::start_transmit(std::span data, std::error_code &ec) { + if (data.empty() || data.size() > MAX_PAYLOAD_LENGTH) { + ec = std::make_error_code(std::errc::invalid_argument); + return false; + } + std::lock_guard lock(base_mutex_); + receiving_ = false; + standby(ec); + if (ec) { + return false; + } + set_packet_params((uint8_t)data.size(), ec); + if (ec) { + return false; + } + write_tx_buffer(data, ec); + if (ec) { + return false; + } + clear_irq_status(IRQ_ALL, ec); + if (ec) { + return false; + } + tx_done_ = false; + tx_timeout_ = false; + // TX with no radio-side timeout (0x000000): the transmission runs to + // completion and raises TX_DONE. The host-side timeout in transmit() is the + // watchdog against a stuck radio. (A radio-side timeout derived from an + // estimated time-on-air risks aborting a valid transmission if the estimate + // is short, which surfaces as a spurious TX timeout.) + uint8_t params[3] = {0x00, 0x00, 0x00}; + cmd_write(CMD_SET_TX, params, 3, ec); + if (ec) { + return false; + } + transmitting_ = true; + return true; +} + +bool Sx126x::start_receive(std::error_code &ec) { + std::lock_guard lock(base_mutex_); + standby(ec); + if (ec) { + return false; + } + // restore max payload length for reception + set_packet_params(MAX_PAYLOAD_LENGTH, ec); + if (ec) { + return false; + } + clear_irq_status(IRQ_ALL, ec); + if (ec) { + return false; + } + // continuous receive + uint8_t params[3] = {0xFF, 0xFF, 0xFF}; + cmd_write(CMD_SET_RX, params, 3, ec); + if (ec) { + return false; + } + receiving_ = true; + transmitting_ = false; + return true; +} + +bool Sx126x::start_cad(std::error_code &ec) { + std::lock_guard lock(base_mutex_); + standby(ec); + if (ec) { + return false; + } + // CAD parameters recommended by AN1200.48 for the configured SF + uint8_t sf = (uint8_t)config_.radio_config.spreading_factor; + uint8_t det_peak = 0x15 + (sf >= 10 ? (sf - 9) : 0); // 21-24 depending on SF + uint8_t params[7] = { + 0x03, // CAD on 8 symbols + det_peak, // detection peak + 10, // detection minimum + 0x00, // exit mode: back to standby-RC + 0x00, 0x00, 0x00 // CAD timeout (unused in exit mode 0) + }; + cmd_write(CMD_SET_CAD_PARAMS, params, 7, ec); + if (ec) { + return false; + } + clear_irq_status(IRQ_ALL, ec); + if (ec) { + return false; + } + cmd_write(CMD_SET_CAD, nullptr, 0, ec); + if (ec) { + return false; + } + receiving_ = false; + return true; +} + +void Sx126x::standby(std::error_code &ec, StandbyMode mode) { + std::lock_guard lock(base_mutex_); + uint8_t param = (uint8_t)mode; + cmd_write(CMD_SET_STANDBY, ¶m, 1, ec); + if (!ec) { + receiving_ = false; + transmitting_ = false; + } +} + +void Sx126x::sleep(std::error_code &ec) { + std::lock_guard lock(base_mutex_); + // warm start (configuration retained), no RTC wakeup + uint8_t param = 0x04; + cmd_write(CMD_SET_SLEEP, ¶m, 1, ec); + if (!ec) { + receiving_ = false; + transmitting_ = false; + } +} + +bool Sx126x::is_reception_in_progress(std::error_code &ec) { + std::lock_guard lock(base_mutex_); + uint16_t irq = get_irq_status(ec); + if (ec) { + return false; + } + return (irq & (IRQ_PREAMBLE_DETECTED | IRQ_HEADER_VALID)) != 0; +} + +float Sx126x::get_rssi_inst(std::error_code &ec) { + std::lock_guard lock(base_mutex_); + uint8_t rssi_raw = 0; + cmd_read(CMD_GET_RSSI_INST, nullptr, 0, &rssi_raw, 1, ec); + if (ec) { + return 0.0f; + } + return -(float)rssi_raw / 2.0f; +} + +uint16_t Sx126x::handle_dio1_interrupt(std::error_code &ec) { + RxPacket packet; + bool got_packet = false; + uint16_t irq = 0; + { + std::lock_guard lock(base_mutex_); + irq = get_irq_status(ec); + if (ec) { + return 0; + } + if (irq == IRQ_NONE) { + return 0; + } + clear_irq_status(irq, ec); + if (ec) { + return irq; + } + logger_.debug("IRQ status: 0x{:04x}", irq); + if ((irq & IRQ_RX_DONE) && !(irq & IRQ_CRC_ERR)) { + got_packet = read_packet(packet, ec); + // in continuous RX the radio stays in RX; nothing to restart + } else if (irq & IRQ_CRC_ERR) { + logger_.warn("Dropping received packet with CRC error"); + } + bool was_transmitting = transmitting_; + if (irq & IRQ_TX_DONE) { + transmitting_ = false; + tx_done_ = true; + // fallback mode has put us in standby; resume receiving if we were + if (receiving_) { + start_receive(ec); + } + } + if (irq & IRQ_TIMEOUT) { + transmitting_ = false; + // only a TX timeout concerns a waiting transmit(); RX timeouts (not used + // in continuous-RX mode) are ignored + if (was_transmitting) { + tx_timeout_ = true; + } + } + } + // invoke callbacks outside the lock + if (got_packet && config_.on_receive) { + config_.on_receive(packet); + } + if ((irq & IRQ_TX_DONE) && config_.on_transmit_done) { + config_.on_transmit_done(); + } + if ((irq & IRQ_CAD_DONE) && config_.on_cad_done) { + config_.on_cad_done((irq & IRQ_CAD_DETECTED) != 0); + } + return irq; +} + +std::chrono::milliseconds Sx126x::time_on_air(size_t payload_length) const { + // Semtech AN1200.13 LoRa time-on-air calculation + const auto &rc = config_.radio_config; + int sf = (int)rc.spreading_factor; + float bw = bandwidth_hz(); + float t_sym_ms = (float)(1 << sf) / bw * 1000.0f; + int de = low_data_rate_optimize() ? 1 : 0; + int cr = (int)rc.coding_rate; // 1..4 + int crc = rc.crc_enabled ? 1 : 0; + int ih = 0; // explicit header + float num = 8.0f * payload_length - 4.0f * sf + 28.0f + 16.0f * crc - 20.0f * ih; + float den = 4.0f * (sf - 2 * de); + float n_payload = 8.0f + std::max(std::ceil(num / den) * (cr + 4), 0.0f); + float t_preamble_ms = (rc.preamble_length + 4.25f) * t_sym_ms; + float t_payload_ms = n_payload * t_sym_ms; + return std::chrono::milliseconds((int64_t)std::ceil(t_preamble_ms + t_payload_ms)); +} + +uint8_t Sx126x::read_radio_register(uint16_t address, std::error_code &ec) { + std::lock_guard lock(base_mutex_); + uint8_t value = 0; + read_registers(address, &value, 1, ec); + return value; +} + +void Sx126x::write_radio_register(uint16_t address, uint8_t value, std::error_code &ec) { + std::lock_guard lock(base_mutex_); + write_registers(address, &value, 1, ec); +} + +///////////////////////////////////////////////////////////////////////////// +// Protected / low-level +///////////////////////////////////////////////////////////////////////////// + +bool Sx126x::wait_on_busy(std::error_code &ec, std::chrono::milliseconds timeout) { + if (!config_.is_busy) { + ec = std::make_error_code(std::errc::operation_not_supported); + return false; + } + auto start = std::chrono::steady_clock::now(); + while (config_.is_busy()) { + if (std::chrono::steady_clock::now() - start > timeout) { + logger_.error("Timed out waiting for BUSY to deassert"); + ec = std::make_error_code(std::errc::timed_out); + return false; + } + std::this_thread::sleep_for(std::chrono::microseconds(100)); + } + return true; +} + +void Sx126x::cmd_write(uint8_t opcode, const uint8_t *params, size_t length, std::error_code &ec) { + std::lock_guard lock(base_mutex_); + if (!wait_on_busy(ec, std::chrono::milliseconds(100))) { + return; + } + uint8_t buffer[1 + 16]; + if (length + 1 > sizeof(buffer)) { + ec = std::make_error_code(std::errc::invalid_argument); + return; + } + buffer[0] = opcode; + if (params && length) { + std::memcpy(&buffer[1], params, length); + } + write_many(buffer, length + 1, ec); +} + +void Sx126x::cmd_read(uint8_t opcode, const uint8_t *params, size_t params_length, + uint8_t *response, size_t response_length, std::error_code &ec) { + std::lock_guard lock(base_mutex_); + if (!wait_on_busy(ec, std::chrono::milliseconds(100))) { + return; + } + // read commands return an RFU/status byte after the opcode (+ params) which + // must be clocked out before the data + uint8_t buffer[1 + 4]; + if (params_length + 2 > sizeof(buffer)) { + ec = std::make_error_code(std::errc::invalid_argument); + return; + } + buffer[0] = opcode; + if (params && params_length) { + std::memcpy(&buffer[1], params, params_length); + } + buffer[1 + params_length] = 0x00; // NOP to clock out the status byte + write_then_read(buffer, params_length + 2, response, response_length, ec); +} + +void Sx126x::write_registers(uint16_t address, const uint8_t *data, size_t length, + std::error_code &ec) { + std::lock_guard lock(base_mutex_); + if (!wait_on_busy(ec, std::chrono::milliseconds(100))) { + return; + } + std::vector buffer(3 + length); + buffer[0] = CMD_WRITE_REGISTER; + buffer[1] = (address >> 8) & 0xff; + buffer[2] = address & 0xff; + std::memcpy(&buffer[3], data, length); + write_many(buffer, ec); +} + +void Sx126x::read_registers(uint16_t address, uint8_t *data, size_t length, std::error_code &ec) { + std::lock_guard lock(base_mutex_); + if (!wait_on_busy(ec, std::chrono::milliseconds(100))) { + return; + } + uint8_t cmd[4] = {CMD_READ_REGISTER, (uint8_t)((address >> 8) & 0xff), (uint8_t)(address & 0xff), + 0x00}; + write_then_read(cmd, 4, data, length, ec); +} + +void Sx126x::write_tx_buffer(std::span data, std::error_code &ec) { + std::lock_guard lock(base_mutex_); + if (!wait_on_busy(ec, std::chrono::milliseconds(100))) { + return; + } + std::vector buffer(2 + data.size()); + buffer[0] = CMD_WRITE_BUFFER; + buffer[1] = 0x00; // offset + std::memcpy(&buffer[2], data.data(), data.size()); + write_many(buffer, ec); +} + +void Sx126x::set_packet_params(uint8_t payload_length, std::error_code &ec) { + const auto &rc = config_.radio_config; + uint8_t params[6] = { + (uint8_t)((rc.preamble_length >> 8) & 0xff), + (uint8_t)(rc.preamble_length & 0xff), + 0x00, // explicit (variable length) header + payload_length, + (uint8_t)(rc.crc_enabled ? 0x01 : 0x00), + (uint8_t)(rc.invert_iq ? 0x01 : 0x00), + }; + cmd_write(CMD_SET_PACKET_PARAMS, params, 6, ec); + if (ec) { + return; + } + // datasheet workaround: the IQ polarity register must be adjusted to match + // the configured IQ inversion for optimal RX sensitivity + uint8_t iq_polarity = read_radio_register(REG_IQ_POLARITY, ec); + if (ec) { + return; + } + if (rc.invert_iq) { + iq_polarity &= ~0x04; + } else { + iq_polarity |= 0x04; + } + write_radio_register(REG_IQ_POLARITY, iq_polarity, ec); +} + +void Sx126x::set_modulation_params(std::error_code &ec) { + const auto &rc = config_.radio_config; + uint8_t params[4] = { + (uint8_t)rc.spreading_factor, + (uint8_t)rc.bandwidth, + (uint8_t)rc.coding_rate, + (uint8_t)(low_data_rate_optimize() ? 0x01 : 0x00), + }; + cmd_write(CMD_SET_MODULATION_PARAMS, params, 4, ec); +} + +void Sx126x::set_pa_config(std::error_code &ec) { + uint8_t params[4]; + if (config_.variant == Variant::SX1261) { + // optimal +14/+15 dBm configuration + params[0] = 0x04; + params[1] = 0x00; + params[2] = 0x01; + params[3] = 0x01; + } else { + // SX1262 / LLCC68 optimal +22 dBm configuration + params[0] = 0x04; + params[1] = 0x07; + params[2] = 0x00; + params[3] = 0x01; + } + cmd_write(CMD_SET_PA_CONFIG, params, 4, ec); +} + +void Sx126x::set_dio_irq_params(uint16_t irq_mask, uint16_t dio1_mask, std::error_code &ec) { + uint8_t params[8] = { + (uint8_t)((irq_mask >> 8) & 0xff), + (uint8_t)(irq_mask & 0xff), + (uint8_t)((dio1_mask >> 8) & 0xff), + (uint8_t)(dio1_mask & 0xff), + 0x00, + 0x00, // DIO2 (may be RF switch) + 0x00, + 0x00, // DIO3 (may be TCXO) + }; + cmd_write(CMD_SET_DIO_IRQ_PARAMS, params, 8, ec); +} + +uint16_t Sx126x::get_irq_status(std::error_code &ec) { + uint8_t status[2] = {0, 0}; + cmd_read(CMD_GET_IRQ_STATUS, nullptr, 0, status, 2, ec); + if (ec) { + return 0; + } + return ((uint16_t)status[0] << 8) | status[1]; +} + +void Sx126x::clear_irq_status(uint16_t mask, std::error_code &ec) { + uint8_t params[2] = {(uint8_t)((mask >> 8) & 0xff), (uint8_t)(mask & 0xff)}; + cmd_write(CMD_CLEAR_IRQ_STATUS, params, 2, ec); +} + +uint16_t Sx126x::get_device_errors(std::error_code &ec) { + uint8_t errors[2] = {0, 0}; + cmd_read(CMD_GET_DEVICE_ERRORS, nullptr, 0, errors, 2, ec); + if (ec) { + return 0; + } + return ((uint16_t)errors[0] << 8) | errors[1]; +} + +void Sx126x::clear_device_errors(std::error_code &ec) { + uint8_t params[2] = {0x00, 0x00}; + cmd_write(CMD_CLEAR_DEVICE_ERRORS, params, 2, ec); +} + +void Sx126x::calibrate_all(std::error_code &ec) { + uint8_t param = 0x7F; // all blocks + cmd_write(CMD_CALIBRATE, ¶m, 1, ec); + if (ec) { + return; + } + // full calibration takes up to ~3.5 ms; BUSY is high for the duration + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + wait_on_busy(ec, std::chrono::milliseconds(100)); +} + +void Sx126x::calibrate_image(uint32_t frequency_hz, std::error_code &ec) { + uint8_t params[2]; + uint32_t mhz = frequency_hz / 1000000; + if (mhz >= 902) { + params[0] = 0xE1; + params[1] = 0xE9; + } else if (mhz >= 863) { + params[0] = 0xD7; + params[1] = 0xDB; + } else if (mhz >= 779) { + params[0] = 0xC1; + params[1] = 0xC5; + } else if (mhz >= 470) { + params[0] = 0x75; + params[1] = 0x81; + } else { + params[0] = 0x6B; + params[1] = 0x6F; + } + cmd_write(CMD_CALIBRATE_IMAGE, params, 2, ec); + if (ec) { + return; + } + wait_on_busy(ec, std::chrono::milliseconds(100)); +} + +void Sx126x::apply_workarounds(std::error_code &ec) { + // datasheet 15.1: modulation quality with 500 kHz bandwidth + uint8_t tx_mod = read_radio_register(REG_TX_MODULATION, ec); + if (ec) { + return; + } + if (config_.radio_config.bandwidth == Bandwidth::BW_500_KHZ) { + tx_mod &= ~0x04; + } else { + tx_mod |= 0x04; + } + write_radio_register(REG_TX_MODULATION, tx_mod, ec); + if (ec) { + return; + } + // datasheet 15.2: better resistance to antenna mismatch (SX1262 PA) + if (config_.variant != Variant::SX1261) { + uint8_t clamp = read_radio_register(REG_TX_CLAMP_CONFIG, ec); + if (ec) { + return; + } + clamp |= 0x1E; + write_radio_register(REG_TX_CLAMP_CONFIG, clamp, ec); + } +} + +bool Sx126x::read_packet(RxPacket &packet, std::error_code &ec) { + uint8_t buffer_status[2] = {0, 0}; + cmd_read(CMD_GET_RX_BUFFER_STATUS, nullptr, 0, buffer_status, 2, ec); + if (ec) { + return false; + } + uint8_t length = buffer_status[0]; + uint8_t offset = buffer_status[1]; + if (length == 0) { + return false; + } + packet.data.resize(length); + uint8_t params[1] = {offset}; + cmd_read(CMD_READ_BUFFER, params, 1, packet.data.data(), length, ec); + if (ec) { + return false; + } + packet.status = get_packet_status(ec); + return !ec; +} + +Sx126x::PacketStatus Sx126x::get_packet_status(std::error_code &ec) { + PacketStatus status; + uint8_t raw[3] = {0, 0, 0}; + cmd_read(CMD_GET_PACKET_STATUS, nullptr, 0, raw, 3, ec); + if (ec) { + return status; + } + status.rssi = -(float)raw[0] / 2.0f; + status.snr = (float)(int8_t)raw[1] / 4.0f; + status.signal_rssi = -(float)raw[2] / 2.0f; + return status; +} + +bool Sx126x::low_data_rate_optimize() const { + // enable LDRO when the symbol time exceeds 16.38 ms (per datasheet + // recommendation): SF11/SF12 @ 125 kHz, SF12 @ 250 kHz + int sf = (int)config_.radio_config.spreading_factor; + float t_sym_ms = (float)(1 << sf) / bandwidth_hz() * 1000.0f; + return t_sym_ms > 16.38f; +} + +float Sx126x::bandwidth_hz() const { + switch (config_.radio_config.bandwidth) { + case Bandwidth::BW_7_8_KHZ: + return 7810.0f; + case Bandwidth::BW_10_4_KHZ: + return 10420.0f; + case Bandwidth::BW_15_6_KHZ: + return 15630.0f; + case Bandwidth::BW_20_8_KHZ: + return 20830.0f; + case Bandwidth::BW_31_25_KHZ: + return 31250.0f; + case Bandwidth::BW_41_7_KHZ: + return 41670.0f; + case Bandwidth::BW_62_5_KHZ: + return 62500.0f; + case Bandwidth::BW_125_KHZ: + return 125000.0f; + case Bandwidth::BW_250_KHZ: + return 250000.0f; + case Bandwidth::BW_500_KHZ: + return 500000.0f; + } + return 125000.0f; +} diff --git a/components/t-deck/CMakeLists.txt b/components/t-deck/CMakeLists.txt old mode 100644 new mode 100755 index 4265b1785..8899f3c01 --- a/components/t-deck/CMakeLists.txt +++ b/components/t-deck/CMakeLists.txt @@ -2,6 +2,6 @@ idf_component_register( INCLUDE_DIRS "include" SRC_DIRS "src" - REQUIRES driver esp_driver_i2s esp_driver_spi base_component codec display display_drivers fatfs i2c input_drivers interrupt gt911 spi task t_keyboard + REQUIRES driver esp_driver_i2s esp_driver_spi base_component codec display display_drivers fatfs gps i2c input_drivers interrupt gt911 spi sx126x task t_keyboard REQUIRED_IDF_TARGETS "esp32s3" ) diff --git a/components/t-deck/example/README.md b/components/t-deck/example/README.md old mode 100644 new mode 100755 index ddd768edb..8fe577598 --- a/components/t-deck/example/README.md +++ b/components/t-deck/example/README.md @@ -3,10 +3,14 @@ This example shows how to use the `espp::TDeck` hardware abstraction component to initialize the components on the LilyGo T-Deck. -It initializes the touch panel, display, keyboard, trackball, sound output, and -optional microSD card support. Touching the screen draws a cyan trail overlay, -the delete key clears the trail, the space key or on-screen refresh button -rotates the display, and the keyboard also exposes simple mute/volume controls. +It initializes the touch panel, display, keyboard, trackball, sound output, the +SX1262 LoRa radio, and optional microSD card support. Touching the screen draws +a cyan trail overlay, the delete key clears the trail, the space key or +on-screen refresh button rotates the display, and the keyboard also exposes +simple mute/volume controls. + +The UI is a tabview with three tabs: **Draw** (the touch trail), **Audio** +(record / play and volume), and **LoRa** (send / receive text over the radio). https://github.com/user-attachments/assets/5d7e7086-fc2c-4477-8948-07b5bab3e51f @@ -61,6 +65,19 @@ See the Getting Started Guide for full steps to configure and use ESP-IDF to bui - Trackball initialization and callback wiring - Optional uSD card mounting over SDSPI on the same SPI host - WAV playback through the onboard audio path +- LoRa radio (SX1262) send / receive on the **LoRa** tab: type a message in the + text box and press **Send** (or **Enter**) to transmit it, and received + packets are shown in a scrolling log with their RSSI / SNR. (While the LoRa + tab is active, the keyboard types into the text box rather than driving the + Draw / Audio shortcuts.) Two boards running this example - e.g. a T-Deck and a + Cardputer with the LoRa+GPS Cap - will talk to each other. This is a raw-LoRa + link on a private sync word (0x12), *not* Meshtastic - see the `meshtastic` + component for Meshtastic interoperability. + The radio shares the SPI bus with the display and uSD card, and its DIO1 + interrupt is serviced by the BSP. (GPIO 17 is shared between the radio reset + and the T-Deck's unused PDM-microphone clock line; the ES7210 microphone this + example records from uses different pins, so the radio and the recording demo + coexist.) - Microphone recording and playback: the on-screen audio row (or the 'r' / 'p' keys) records from the ES7210 into a PSRAM-preferred buffer and streams it back through the mono speaker, with speaker / microphone volume buttons; diff --git a/components/t-deck/example/main/gui.cpp b/components/t-deck/example/main/gui.cpp index fc2602200..622ba8e7f 100644 --- a/components/t-deck/example/main/gui.cpp +++ b/components/t-deck/example/main/gui.cpp @@ -8,6 +8,7 @@ void Gui::init_ui() { init_tabview(); init_draw_tab(); init_audio_tab(); + init_lora_tab(); init_circle_layer(); } @@ -26,6 +27,7 @@ void Gui::init_tabview() { lv_display_get_vertical_resolution(lv_display_get_default())); draw_tab_ = lv_tabview_add_tab(tabview_, "Draw"); audio_tab_ = lv_tabview_add_tab(tabview_, "Audio"); + lora_tab_ = lv_tabview_add_tab(tabview_, "LoRa"); // switching tabs is done with the tab buttons only: disable swipe // scrolling of the content so drawing on the Draw tab cannot accidentally // change pages @@ -133,6 +135,141 @@ void Gui::refresh_audio_label() { update_audio_label(); } +void Gui::init_lora_tab() { + // a column: status line, an input row (text box + Send button), then a + // scrolling message log + lv_obj_set_flex_flow(lora_tab_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(lora_tab_, 8, 0); + + lora_status_label_ = lv_label_create(lora_tab_); + lv_label_set_long_mode(lora_status_label_, LV_LABEL_LONG_WRAP); + lv_obj_set_width(lora_status_label_, lv_pct(100)); + lv_label_set_text(lora_status_label_, "LoRa: initializing..."); + + // input row: a one-line text box (grows to fill) + a Send button + lv_obj_t *input_row = lv_obj_create(lora_tab_); + lv_obj_remove_style_all(input_row); + lv_obj_set_width(input_row, lv_pct(100)); + lv_obj_set_height(input_row, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(input_row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(input_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(input_row, 6, 0); + + lora_input_ = lv_textarea_create(input_row); + lv_obj_set_flex_grow(lora_input_, 1); + lv_textarea_set_one_line(lora_input_, true); + lv_textarea_set_placeholder_text(lora_input_, "Type a message..."); + // the physical keyboard drives this text box (routed by the example); there + // is no on-screen keyboard, so it does not need to be touch-clickable + lv_obj_remove_flag(lora_input_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_state(lora_input_, LV_STATE_FOCUSED); + + lora_send_button_ = lv_btn_create(input_row); + lv_obj_set_height(lora_send_button_, LV_SIZE_CONTENT); + lv_obj_t *send_label = lv_label_create(lora_send_button_); + lv_label_set_text(send_label, LV_SYMBOL_UPLOAD " Send"); + lv_obj_center(send_label); + lv_obj_add_event_cb(lora_send_button_, event_callback, LV_EVENT_PRESSED, this); + + // the message log grows to fill the remaining space and scrolls + lv_obj_t *log_container = lv_obj_create(lora_tab_); + lv_obj_set_width(log_container, lv_pct(100)); + lv_obj_set_flex_grow(log_container, 1); + lv_obj_set_style_pad_all(log_container, 4, 0); + lora_log_label_ = lv_label_create(log_container); + lv_label_set_long_mode(lora_log_label_, LV_LABEL_LONG_WRAP); + lv_obj_set_width(lora_log_label_, lv_pct(100)); + lv_label_set_text(lora_log_label_, "No messages yet."); +} + +void Gui::update_lora_log() { + if (!lora_log_label_) { + return; + } + if (lora_messages_.empty()) { + lv_label_set_text(lora_log_label_, "No messages yet."); + return; + } + std::string text; + for (const auto &message : lora_messages_) { + if (!text.empty()) { + text += "\n"; + } + text += message; + } + lv_label_set_text(lora_log_label_, text.c_str()); +} + +void Gui::set_lora_status(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(lora_status_label_, std::string(text).c_str()); +} + +void Gui::set_lora_send_enabled(bool enabled) { + std::lock_guard lock(mutex_); + auto apply = [enabled](lv_obj_t *obj) { + if (!obj) { + return; + } + if (enabled) { + lv_obj_clear_state(obj, LV_STATE_DISABLED); + } else { + lv_obj_add_state(obj, LV_STATE_DISABLED); + } + }; + apply(lora_send_button_); + apply(lora_input_); +} + +void Gui::add_lora_message(std::string_view text) { + std::lock_guard lock(mutex_); + lora_messages_.emplace_front(text); + while (lora_messages_.size() > MAX_LORA_MESSAGES) { + lora_messages_.pop_back(); + } + update_lora_log(); +} + +bool Gui::lora_page_active() { + std::lock_guard lock(mutex_); + // tab order: Draw (0), Audio (1), LoRa (2) + return lv_tabview_get_tab_active(tabview_) == 2; +} + +void Gui::lora_input_add_char(char c) { + std::lock_guard lock(mutex_); + if (!lora_input_) { + return; + } + if (c == '\b') { + lv_textarea_delete_char(lora_input_); + } else { + lv_textarea_add_char(lora_input_, c); + } +} + +void Gui::send_lora_message() { + std::string text; + lora_send_callback_t callback; + { + std::lock_guard lock(mutex_); + if (!lora_input_) { + return; + } + const char *current = lv_textarea_get_text(lora_input_); + text = current ? current : ""; + if (text.empty()) { + return; + } + callback = lora_send_callback_; + lv_textarea_set_text(lora_input_, ""); + } + // invoke the callback outside the GUI lock + if (callback) { + callback(text); + } +} + void Gui::init_circle_layer() { // a transparent, click-through overlay above the tabview which shows the // touch trail (only populated while the Draw tab is active) @@ -210,6 +347,11 @@ void Gui::on_pressed(lv_event_t *e) { } return; } + if (target == lora_send_button_) { + logger_.info("LoRa send button pressed"); + send_lora_message(); + return; + } if (target == volume_down_button_ || target == volume_up_button_) { float delta = target == volume_down_button_ ? -10.0f : 10.0f; tdeck.volume(tdeck.volume() + delta); diff --git a/components/t-deck/example/main/gui.hpp b/components/t-deck/example/main/gui.hpp old mode 100644 new mode 100755 index e9ee325ec..313698069 --- a/components/t-deck/example/main/gui.hpp +++ b/components/t-deck/example/main/gui.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -32,11 +33,19 @@ /// * "Audio" tab: record / play buttons (wired to the example via /// callbacks) and speaker / microphone volume buttons (acting directly on /// the BSP), with labels showing the audio state and current volumes. +/// * "LoRa" tab: a status line (radio state / frequency), a text box to +/// compose a message with a Send button (Enter also sends; both wired to +/// the example via a callback), and a scrolling log of sent / received +/// messages with their RSSI / SNR. class Gui { public: /// Callback invoked when the record / play buttons are pressed using audio_button_callback_t = std::function; + /// Callback invoked when a LoRa message is sent (via the Send button or the + /// Enter key). The parameter is the message text to transmit. + using lora_send_callback_t = std::function; + /// Configuration for the Gui struct Config { espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity @@ -107,6 +116,44 @@ class Gui { /// after the keyboard shortcuts change the volume). Thread-safe. void refresh_audio_label(); + /// Set the callback invoked when a LoRa message is sent (Send button or + /// Enter). The callback receives the message text. + /// @param callback The callback to invoke + void set_lora_send_callback(lora_send_callback_t callback) { + lora_send_callback_ = std::move(callback); + } + + /// Set the LoRa status line (e.g. the radio state and frequency, or an + /// error if the radio is unavailable). Thread-safe. + /// @param text The text to display + void set_lora_status(std::string_view text); + + /// Enable or disable LoRa sending (the Send button and text input; e.g. + /// disable it if the radio failed to initialize). Thread-safe. + /// @param enabled Whether sending should be enabled + void set_lora_send_enabled(bool enabled); + + /// Append a message to the LoRa log (newest shown at the top; the oldest + /// messages are dropped once the log is full). Thread-safe. + /// @param text The message line to add (e.g. a received or sent packet) + void add_lora_message(std::string_view text); + + /// Whether the LoRa tab is currently active (used by the example to route + /// keyboard input into the LoRa text box). Thread-safe. + /// @return True if the LoRa tab is the active tab + bool lora_page_active(); + + /// Add / edit a character in the LoRa text input (used to feed keyboard + /// input). '\b' deletes the character before the cursor; other characters + /// are appended. Thread-safe. + /// @param c The character to add + void lora_input_add_char(char c); + + /// Send the current LoRa text input: if non-empty, invoke the send callback + /// with its contents and clear the input. Called by the Send button and by + /// the example when Enter is pressed. Thread-safe. + void send_lora_message(); + protected: static constexpr size_t MAX_CIRCLES = 100; static constexpr int TAB_BAR_HEIGHT = 40; @@ -125,8 +172,12 @@ class Gui { void init_tabview(); void init_draw_tab(); void init_audio_tab(); + void init_lora_tab(); void init_circle_layer(); + // rebuild the LoRa log label from lora_messages_; called with the mutex held + void update_lora_log(); + // update the audio volume label from the BSP's current volumes; called // with the mutex held void update_audio_label(); @@ -152,6 +203,7 @@ class Gui { lv_obj_t *tabview_{nullptr}; lv_obj_t *draw_tab_{nullptr}; lv_obj_t *audio_tab_{nullptr}; + lv_obj_t *lora_tab_{nullptr}; lv_obj_t *label_{nullptr}; lv_obj_t *rotate_button_{nullptr}; lv_obj_t *clear_button_{nullptr}; @@ -171,8 +223,18 @@ class Gui { lv_obj_t *audio_status_label_{nullptr}; lv_obj_t *circle_layer_{nullptr}; + // LoRa tab + lv_obj_t *lora_status_label_{nullptr}; + lv_obj_t *lora_input_{nullptr}; + lv_obj_t *lora_send_button_{nullptr}; + lv_obj_t *lora_log_label_{nullptr}; + // recent LoRa log lines (newest at the front) + std::deque lora_messages_; + static constexpr size_t MAX_LORA_MESSAGES = 10; + audio_button_callback_t record_callback_{nullptr}; audio_button_callback_t play_callback_{nullptr}; + lora_send_callback_t lora_send_callback_{nullptr}; std::array circles_; size_t next_circle_index_{0}; diff --git a/components/t-deck/example/main/t_deck_example.cpp b/components/t-deck/example/main/t_deck_example.cpp index d2aa6aa12..2f36312c0 100644 --- a/components/t-deck/example/main/t_deck_example.cpp +++ b/components/t-deck/example/main/t_deck_example.cpp @@ -4,7 +4,10 @@ #include #include #include +#include +#include #include +#include #include #include @@ -21,6 +24,18 @@ static std::vector audio_bytes; static bool load_audio(size_t &out_size, size_t &out_sample_rate); static void play_click(espp::TDeck &tdeck); static void resample_click(uint32_t from_rate, uint32_t to_rate); +// render a received LoRa payload as a printable string (non-printable bytes, +// e.g. from an unrelated LoRa transmission on the same frequency, are shown as +// '.') so a stray frame cannot corrupt the on-screen log +static std::string printable(const std::vector &data); + +// A LoRa transmit requested from the GUI (the Send button / Enter run in the +// LVGL / keyboard tasks; the actual transmit is done from the main loop so a +// multi-hundred-ms time-on-air does not block the UI). The message text is +// handed over under lora_tx_mutex. +static std::atomic lora_send_requested{false}; +static std::mutex lora_tx_mutex; +static std::string lora_tx_message; // Run all audio at 16 kHz - the rate LilyGO's own T-Deck firmware uses for // the ES7210 (the codec sounds clean there, and higher rates on this board @@ -96,13 +111,28 @@ extern "C" void app_main(void) { fmt::format("Touch the screen to draw!\nPress the delete key or the {} button to clear " "circles.\nPress the space key or the {} button to rotate the display.\n" "The Audio tab (or the 'r' / 'p' keys) records and plays back audio; " - "'n' / '$' / 'm' adjust / mute the speaker volume.", + "'n' / '$' / 'm' adjust / mute the speaker volume.\n" + "On the LoRa tab, type a message and press Send (or Enter) to transmit " + "it over the SX1262 radio.", LV_SYMBOL_TRASH, LV_SYMBOL_REFRESH)); // initialize the Keyboard after the Gui exists so key presses can act on it // immediately auto keypress_callback = [&](uint8_t key) { logger.info("Key pressed: {}", key); + // When the LoRa tab is active, the keyboard composes a message in its text + // box: Enter sends, backspace deletes, and printable keys are appended. + // (This takes over the keys from the Draw / Audio shortcuts while typing.) + if (gui.lora_page_active()) { + if (key == '\r' || key == '\n') { + gui.send_lora_message(); + } else if (key == 8) { + gui.lora_input_add_char('\b'); + } else if (key >= 0x20 && key < 0x7f) { + gui.lora_input_add_char(static_cast(key)); + } + return; + } if (key == 8) { // delete key will clear the circles logger.info("Clearing circles"); @@ -328,11 +358,75 @@ extern "C" void app_main(void) { gui.set_record_callback(toggle_record); gui.set_play_callback(toggle_play); + // Initialize the LoRa radio (SX1262) and wire it to the LoRa tab. This is a + // simple raw-LoRa demo: type a message in the LoRa tab's text box and press + // Send (or Enter) to transmit it; received messages appear in the log. A + // private sync word (0x12) is used so this link does not pick up Meshtastic + // traffic (which uses 0x2B - see the meshtastic component/example for actual + // Meshtastic interop). The radio shares the SPI bus with the display and uSD + // card, and its DIO1 interrupt is serviced by the BSP, so received packets + // arrive on the callback below. + espp::Sx126x::RadioConfig lora_config{}; + lora_config.sync_word = 0x12; // private link, not Meshtastic + std::shared_ptr radio; + bool have_lora = tdeck.initialize_lora(lora_config); + if (have_lora) { + radio = tdeck.lora(); + // deliver received packets to the LoRa tab (runs in the BSP interrupt task) + radio->set_receive_callback([&](const espp::Sx126x::RxPacket &packet) { + gui.add_lora_message(fmt::format("RX {:.0f}dBm/{:.1f}dB: {}", packet.status.rssi, + packet.status.snr, printable(packet.data))); + }); + std::error_code ec; + if (radio->start_receive(ec)) { + gui.set_lora_status(fmt::format("Listening @ {:.3f} MHz, SF11/BW250 (private)", + radio->radio_config().frequency_hz / 1e6f)); + // the Send button / Enter hand the message text here; the main loop + // performs the (blocking) transmit + gui.set_lora_send_callback([](const std::string &text) { + { + std::lock_guard lock(lora_tx_mutex); + lora_tx_message = text; + } + lora_send_requested = true; + }); + } else { + logger.error("Failed to start LoRa receive: {}", ec.message()); + gui.set_lora_status(fmt::format("LoRa RX failed: {}", ec.message())); + gui.set_lora_send_enabled(false); + have_lora = false; + } + } else { + logger.warn("Could not initialize the LoRa radio!"); + gui.set_lora_status("LoRa unavailable (see log)"); + gui.set_lora_send_enabled(false); + } + // Main loop: stream any active playback to the speaker and notice when a // recording stops (either button press or the buffer filling up) size_t play_offset = 0; bool was_recording = false; while (true) { + // service a LoRa send requested from the GUI. transmit() blocks for the + // packet's time-on-air (a few hundred ms at SF11) then returns the radio + // to receive, so doing it here keeps the LVGL task responsive. + if (have_lora && lora_send_requested.exchange(false)) { + std::string message; + { + std::lock_guard lock(lora_tx_mutex); + message = lora_tx_message; + } + std::span payload{reinterpret_cast(message.data()), + message.size()}; + std::error_code ec; + if (radio->transmit(payload, 3s, ec)) { + gui.add_lora_message("TX: " + message); + logger.info("LoRa sent: {}", message); + } else { + gui.add_lora_message(fmt::format("TX failed: {}", ec.message())); + logger.error("LoRa transmit failed: {}", ec.message()); + } + } // feed the active playback in chunks, advancing by however much the // speaker's stream buffer accepted if (playing) { @@ -505,6 +599,13 @@ static bool load_audio(size_t &out_size, size_t &out_sample_rate) { return true; } +static std::string printable(const std::vector &data) { + std::string out(data.size(), '.'); + std::transform(data.begin(), data.end(), out.begin(), + [](uint8_t b) { return (b >= 0x20 && b < 0x7f) ? static_cast(b) : '.'; }); + return out; +} + static void play_click(espp::TDeck &tdeck) { // Enqueue the click without blocking the caller (this runs in the touch // callback). play_audio() enqueues only whole frames and returns how many diff --git a/components/t-deck/idf_component.yml b/components/t-deck/idf_component.yml old mode 100644 new mode 100755 index a661d6172..f6df0d19d --- a/components/t-deck/idf_component.yml +++ b/components/t-deck/idf_component.yml @@ -20,11 +20,13 @@ dependencies: espp/base_component: '>=1.0' espp/display: '>=1.0' espp/display_drivers: '>=1.0' + espp/gps: '>=1.0' espp/gt911: '>=1.0' espp/i2c: '>=1.0' espp/input_drivers: '>=1.0' espp/interrupt: '>=1.0' espp/spi: '>=1.0' + espp/sx126x: '>=1.0' espp/task: '>=1.0' espp/t_keyboard: '>=1.0' targets: diff --git a/components/t-deck/include/t-deck.hpp b/components/t-deck/include/t-deck.hpp old mode 100644 new mode 100755 index 25999741b..17abb2e38 --- a/components/t-deck/include/t-deck.hpp +++ b/components/t-deck/include/t-deck.hpp @@ -22,6 +22,7 @@ #include "base_component.hpp" #include "es7210.hpp" +#include "gps.hpp" #include "gt911.hpp" #include "i2c.hpp" #include "interrupt.hpp" @@ -29,6 +30,7 @@ #include "pointer_input.hpp" #include "spi.hpp" #include "st7789.hpp" +#include "sx126x.hpp" #include "t_keyboard.hpp" #include "touchpad_input.hpp" @@ -44,6 +46,8 @@ namespace espp { /// - Interrupts /// - I2C /// - microSD (uSD) card +/// - LoRa radio (SX1262) +/// - GPS (T-Deck Plus only) /// /// For more information, see /// https://github.com/Xinyuan-LilyGO/T-Deck/tree/master and @@ -146,6 +150,72 @@ class TDeck : public BaseComponent { /// and the mount point is valid sdmmc_card_t *sdcard() const { return sdcard_; } + ///////////////////////////////////////////////////////////////////////////// + // LoRa Radio (SX1262, HPD16A module) + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the LoRa radio (SX1262) + /// \param radio_config The radio (modem) configuration to apply + /// \return True if the radio was initialized properly + /// \note The radio shares the SPI bus with the display and uSD card. + /// \note The radio's DIO1 interrupt is automatically serviced via the + /// board's interrupt handler, so packets are delivered through the + /// callbacks registered on the returned driver (see + /// espp::Sx126x::set_receive_callback and friends). + /// \note The radio's reset line (GPIO 17) is also routed to the T-Deck's + /// PDM digital-microphone clock / ES7210 interrupt line. The espp + /// microphone path (the ES7210 array) uses a different set of pins, so + /// the radio and microphone can be used together; only a PDM + /// microphone (which would drive GPIO 17) would conflict. + bool initialize_lora(const Sx126x::RadioConfig &radio_config = {}); + + /// Get the LoRa radio + /// \return A shared pointer to the LoRa radio driver + /// \note The radio is only available if initialize_lora() succeeded + std::shared_ptr lora() const { return lora_; } + + /// Get the GPIO pin for the LoRa radio chip select + /// \return The GPIO pin for the LoRa radio chip select + static constexpr auto lora_cs_gpio() { return lora_cs_io; } + + /// Get the GPIO pin for the LoRa radio DIO1 (interrupt) line + /// \return The GPIO pin for the LoRa radio DIO1 line + static constexpr auto lora_dio1_gpio() { return lora_dio1_io; } + + /// Get the GPIO pin for the LoRa radio BUSY line + /// \return The GPIO pin for the LoRa radio BUSY line + static constexpr auto lora_busy_gpio() { return lora_busy_io; } + + /// Get the GPIO pin for the LoRa radio reset line + /// \return The GPIO pin for the LoRa radio reset line + static constexpr auto lora_reset_gpio() { return lora_reset_io; } + + ///////////////////////////////////////////////////////////////////////////// + // GPS (T-Deck Plus only, u-blox MIA-M10Q) + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the GPS (T-Deck Plus only) + /// \param fix_cb Optional callback invoked on each fix update + /// \param baud_rate The baud rate of the GPS UART (9600 by default) + /// \return True if the GPS was initialized properly + /// \note The GPS UART pins are routed to the Grove connector on the base + /// T-Deck; on the T-Deck Plus they connect to the internal u-blox + /// MIA-M10Q receiver. + bool initialize_gps(const Gps::fix_callback_fn &fix_cb = nullptr, uint32_t baud_rate = 9600); + + /// Get the GPS + /// \return A shared pointer to the GPS driver + /// \note The GPS is only available if initialize_gps() succeeded + std::shared_ptr gps() const { return gps_; } + + /// Get the GPIO pin for the GPS UART TX (ESP32 -> GPS) + /// \return The GPIO pin for the GPS UART TX + static constexpr auto gps_tx_gpio() { return gps_tx_io; } + + /// Get the GPIO pin for the GPS UART RX (GPS -> ESP32) + /// \return The GPIO pin for the GPS UART RX + static constexpr auto gps_rx_gpio() { return gps_rx_io; } + ///////////////////////////////////////////////////////////////////////////// // Keyboard ///////////////////////////////////////////////////////////////////////////// @@ -619,10 +689,22 @@ class TDeck : public BaseComponent { static constexpr gpio_num_t sdcard_cs = GPIO_NUM_39; // LoRa (HPD16A) - static constexpr gpio_num_t lora_enable_io = GPIO_NUM_17; + // NOTE: the reset line (GPIO 17) is shared with the PDM digital-microphone + // clock / ES7210 interrupt line. The espp microphone path (the ES7210 + // array) uses a different set of pins, so the radio and microphone can + // be used together; only a PDM microphone (which would drive GPIO 17) + // would conflict. + static constexpr gpio_num_t lora_reset_io = GPIO_NUM_17; static constexpr gpio_num_t lora_cs_io = GPIO_NUM_9; static constexpr gpio_num_t lora_dio1_io = GPIO_NUM_45; static constexpr gpio_num_t lora_busy_io = GPIO_NUM_13; + static constexpr int lora_spi_clock_speed = 8 * 1000 * 1000; + + // GPS (T-Deck Plus, u-blox MIA-M10Q); these pins go to the Grove + // connector on the base T-Deck + static constexpr gpio_num_t gps_tx_io = GPIO_NUM_43; + static constexpr gpio_num_t gps_rx_io = GPIO_NUM_44; + static constexpr auto gps_uart_port = UART_NUM_1; // TODO: allow core id configuration I2c internal_i2c_{{.port = internal_i2c_port, @@ -735,6 +817,24 @@ class TDeck : public BaseComponent { StreamBufferHandle_t audio_tx_stream; i2s_std_config_t audio_std_cfg; + // LoRa radio + std::shared_ptr lora_spi_device_; + std::shared_ptr lora_; + espp::Interrupt::PinConfig lora_dio1_interrupt_pin_{ + .gpio_num = lora_dio1_io, + .callback = + [this](const auto &event) { + if (lora_) { + std::error_code ec; + lora_->handle_dio1_interrupt(ec); + } + }, + .active_level = espp::Interrupt::ActiveLevel::HIGH, + .interrupt_type = espp::Interrupt::Type::RISING_EDGE}; + + // GPS + std::shared_ptr gps_; + // microphone (ES7210 on its own I2S bus) std::shared_ptr> es7210_i2c_device_; std::atomic microphone_initialized_{false}; diff --git a/components/t-deck/src/lora.cpp b/components/t-deck/src/lora.cpp new file mode 100755 index 000000000..6b12844a7 --- /dev/null +++ b/components/t-deck/src/lora.cpp @@ -0,0 +1,129 @@ +#include "t-deck.hpp" + +#include + +using namespace espp; + +///////////////////////////////////////////////////////////////////////////// +// LoRa Radio (SX1262, HPD16A module) +///////////////////////////////////////////////////////////////////////////// + +bool TDeck::initialize_lora(const Sx126x::RadioConfig &radio_config) { + if (lora_) { + logger_.warn("LoRa radio already initialized, not initializing again!"); + return false; + } + + if (microphone_initialized_) { + // GPIO 17 is the LoRa radio reset line and is also routed to the T-Deck's + // (unused-by-espp) PDM digital-microphone clock / ES7210 interrupt line. + // The espp microphone path uses the ES7210 array on a separate set of + // pins (it does not drive GPIO 17), so using the radio and the microphone + // together is generally fine - warn rather than fail. + logger_.warn("The LoRa radio reset line (GPIO 17) is shared with the microphone interrupt / " + "PDM dmic clock line; this is usually safe with the ES7210 microphone path, but " + "avoid enabling a PDM microphone at the same time."); + } + + // ensure that the (shared) SPI bus is initialized + if (!init_spi_bus()) { + logger_.error("Failed to initialize SPI bus."); + return false; + } + + logger_.info("Initializing LoRa radio"); + + std::error_code ec; + lora_spi_device_ = lcd_spi_->add_device( + { + .mode = 0, + .clock_speed_hz = lora_spi_clock_speed, + .cs_io_num = lora_cs_io, + .queue_size = spi_queue_size, + }, + ec); + if (!lora_spi_device_) { + logger_.error("Failed to add LoRa radio to SPI bus: {}", ec.message()); + return false; + } + + // configure the radio's control pins + gpio_set_direction(lora_busy_io, GPIO_MODE_INPUT); + gpio_set_direction(lora_reset_io, GPIO_MODE_OUTPUT); + gpio_set_level(lora_reset_io, 1); + + // The SX126x requires chip select to be held asserted across the write and + // read phases of a command, so write_then_read is implemented as a single + // full-duplex transfer of the concatenated length. + auto device = lora_spi_device_; + auto write_fn = [device](const uint8_t *data, size_t length) -> bool { + std::error_code ec; + return device->write(std::span{data, length}, {}, ec); + }; + auto write_then_read_fn = [device](const uint8_t *write_data, size_t write_length, + uint8_t *read_data, size_t read_length) -> bool { + std::vector tx(write_length + read_length, 0); + std::vector rx(write_length + read_length, 0); + std::memcpy(tx.data(), write_data, write_length); + std::error_code ec; + if (!device->transfer(tx, rx, {}, ec)) { + return false; + } + std::memcpy(read_data, rx.data() + write_length, read_length); + return true; + }; + + lora_ = std::make_shared( + Sx126x::Config{.variant = Sx126x::Variant::SX1262, + .write = write_fn, + .write_then_read = write_then_read_fn, + .is_busy = []() -> bool { return gpio_get_level(lora_busy_io); }, + .reset = [](bool level) { gpio_set_level(lora_reset_io, level); }, + // the HPD16A module has a TCXO powered from DIO3 at 1.8V, + // and uses DIO2 to control its RF switch + .tcxo_voltage = 1.8f, + .use_dio2_as_rf_switch = true, + .use_dcdc_regulator = true, + .radio_config = radio_config, + .auto_init = false, + .log_level = get_log_level()}); + if (!lora_->initialize(ec)) { + logger_.error("Failed to initialize LoRa radio: {}", ec.message()); + lora_.reset(); + lora_spi_device_.reset(); + return false; + } + + // service the radio's IRQs whenever DIO1 goes high + interrupts_.add_interrupt(lora_dio1_interrupt_pin_); + + return true; +} + +///////////////////////////////////////////////////////////////////////////// +// GPS (T-Deck Plus only, u-blox MIA-M10Q) +///////////////////////////////////////////////////////////////////////////// + +bool TDeck::initialize_gps(const Gps::fix_callback_fn &fix_cb, uint32_t baud_rate) { + if (gps_) { + logger_.warn("GPS already initialized, not initializing again!"); + return false; + } + + logger_.info("Initializing GPS"); + + gps_ = std::make_shared(Gps::Config{.uart_port = gps_uart_port, + .tx_io_num = gps_tx_io, + .rx_io_num = gps_rx_io, + .baud_rate = baud_rate, + .on_fix = fix_cb, + .auto_start = false, + .log_level = get_log_level()}); + std::error_code ec; + if (!gps_->start(ec)) { + logger_.error("Failed to start GPS: {}", ec.message()); + gps_.reset(); + return false; + } + return true; +} diff --git a/doc/Doxyfile b/doc/Doxyfile index e5b3f886e..5b2a7adf0 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -113,6 +113,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/ftp/example/main/ftp_example.cpp \ $(PROJECT_PATH)/components/ft5x06/example/main/ft5x06_example.cpp \ $(PROJECT_PATH)/components/gfps_service/example/main/gfps_service_example.cpp \ + $(PROJECT_PATH)/components/gps/example/main/gps_example.cpp \ $(PROJECT_PATH)/components/gt911/example/main/gt911_example.cpp \ $(PROJECT_PATH)/components/hid-rp/example/main/hid_rp_example.cpp \ $(PROJECT_PATH)/components/hid_service/example/main/hid_service_example.cpp \ @@ -139,6 +140,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/motorgo-mini/example/main/motorgo_mini_example.cpp \ $(PROJECT_PATH)/components/motorgo-plink/example/main/motorgo_plink_example.cpp \ $(PROJECT_PATH)/components/mcp23x17/example/main/mcp23x17_example.cpp \ + $(PROJECT_PATH)/components/meshtastic/example/main/meshtastic_example.cpp \ $(PROJECT_PATH)/components/mt6701/example/main/mt6701_example.cpp \ $(PROJECT_PATH)/components/neopixel/example/main/neopixel_example.cpp \ $(PROJECT_PATH)/components/nvs/example/main/nvs_example.cpp \ @@ -164,6 +166,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/st25dv/example/main/st25dv_example.cpp \ $(PROJECT_PATH)/components/st7123touch/example/main/st7123touch_example.cpp \ $(PROJECT_PATH)/components/state_machine/example/main/hfsm_example.cpp \ + $(PROJECT_PATH)/components/sx126x/example/main/sx126x_example.cpp \ $(PROJECT_PATH)/components/tabulate/example/main/tabulate_example.cpp \ $(PROJECT_PATH)/components/t-deck/example/main/t_deck_example.cpp \ $(PROJECT_PATH)/components/t-dongle-s3/example/main/t_dongle_s3_example.cpp \ @@ -275,6 +278,9 @@ INPUT = \ $(PROJECT_PATH)/components/gfps_service/include/gfps.hpp \ $(PROJECT_PATH)/components/gfps_service/include/gfps_characteristic_callbacks.hpp \ $(PROJECT_PATH)/components/gfps_service/include/gfps_service.hpp \ + $(PROJECT_PATH)/components/gps/include/gps.hpp \ + $(PROJECT_PATH)/components/gps/include/gps_fix.hpp \ + $(PROJECT_PATH)/components/gps/include/nmea_parser.hpp \ $(PROJECT_PATH)/components/gt911/include/gt911.hpp \ $(PROJECT_PATH)/components/hid-rp/include/gamepad_hat.hpp \ $(PROJECT_PATH)/components/hid-rp/include/gamepad_imu.hpp \ @@ -331,6 +337,11 @@ INPUT = \ $(PROJECT_PATH)/components/nvs/include/nvs.hpp \ $(PROJECT_PATH)/components/nvs/include/nvs_handle_espp.hpp \ $(PROJECT_PATH)/components/mcp23x17/include/mcp23x17.hpp \ + $(PROJECT_PATH)/components/meshtastic/include/meshtastic.hpp \ + $(PROJECT_PATH)/components/meshtastic/include/meshtastic_crypto.hpp \ + $(PROJECT_PATH)/components/meshtastic/include/meshtastic_protobuf.hpp \ + $(PROJECT_PATH)/components/meshtastic/include/meshtastic_protocol.hpp \ + $(PROJECT_PATH)/components/meshtastic/include/meshtastic_types.hpp \ $(PROJECT_PATH)/components/mt6701/include/mt6701.hpp \ $(PROJECT_PATH)/components/odrive_ascii/include/odrive_ascii.hpp \ $(PROJECT_PATH)/components/pcf85063/include/pcf85063.hpp \ @@ -377,6 +388,7 @@ INPUT = \ $(PROJECT_PATH)/components/state_machine/include/shallow_history_state.hpp \ $(PROJECT_PATH)/components/state_machine/include/state_base.hpp \ $(PROJECT_PATH)/components/state_machine/include/state_machine.hpp \ + $(PROJECT_PATH)/components/sx126x/include/sx126x.hpp \ $(PROJECT_PATH)/components/t-deck/include/t-deck.hpp \ $(PROJECT_PATH)/components/t-dongle-s3/include/t-dongle-s3.hpp \ $(PROJECT_PATH)/components/t_keyboard/include/t_keyboard.hpp \ diff --git a/doc/en/index.rst b/doc/en/index.rst index 6f235c739..e27655ce0 100755 --- a/doc/en/index.rst +++ b/doc/en/index.rst @@ -80,6 +80,7 @@ capability; all supported development boards are collected under ble/index ftp/index nfc/index + wireless/index protocols/index .. toctree:: diff --git a/doc/en/wireless/gps.rst b/doc/en/wireless/gps.rst new file mode 100755 index 000000000..cdb9e966c --- /dev/null +++ b/doc/en/wireless/gps.rst @@ -0,0 +1,23 @@ +GPS / GNSS +********** + +The `Gps` component provides a driver for UART-attached GNSS receivers which +output standard NMEA-0183 sentences (e.g. the ATGM336H on the M5Stack +Cardputer LoRa+GPS Cap, or the u-blox MIA-M10Q on the LilyGo T-Deck Plus). It +reads and parses the NMEA stream in a background task and provides the latest +fix (position, altitude, speed, course, satellites, UTC time) thread-safely or +via callback. + +The `NmeaParser` class provides the underlying platform-independent NMEA +parsing and can be used on its own. + +.. toctree:: + + gps_example + +API Reference +------------- + +.. include-build-file:: inc/gps.inc +.. include-build-file:: inc/nmea_parser.inc +.. include-build-file:: inc/gps_fix.inc diff --git a/doc/en/wireless/gps_example.md b/doc/en/wireless/gps_example.md new file mode 100755 index 000000000..1ab3514a5 --- /dev/null +++ b/doc/en/wireless/gps_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/gps/example/README.md +``` diff --git a/doc/en/wireless/index.rst b/doc/en/wireless/index.rst new file mode 100755 index 000000000..5ee3d10ef --- /dev/null +++ b/doc/en/wireless/index.rst @@ -0,0 +1,13 @@ +Wireless & Radio APIs +********************* + +.. toctree:: + :maxdepth: 1 + + sx126x + gps + meshtastic + +These components provide sub-GHz LoRa radio, GNSS/GPS, and Meshtastic-compatible +mesh networking support. They are used together on LoRa-capable boards such as +the LilyGo T-Deck and the M5Stack Cardputer-Adv (with the LoRa+GPS Cap). diff --git a/doc/en/wireless/meshtastic.rst b/doc/en/wireless/meshtastic.rst new file mode 100755 index 000000000..8f8916a96 --- /dev/null +++ b/doc/en/wireless/meshtastic.rst @@ -0,0 +1,33 @@ +Meshtastic +********** + +The `MeshtasticNode` component is a minimal, radio-agnostic implementation of +the Meshtastic® over-the-air mesh protocol, sufficient to interoperate with +stock Meshtastic devices on a shared channel (the public "LongFast" channel by +default). It handles framing, AES-CTR encryption with the well-known default +channel key, sending and receiving text messages / node info / positions, +receive-side deduplication, and optional managed-flood rebroadcasting. + +It is decoupled from any particular radio - you provide a transmit function +and feed it received frames - and pairs naturally with the :doc:`sx126x` +component. + +.. note:: + + This is an independent, clean-room implementation of the published + Meshtastic protocol; it is not affiliated with or endorsed by Meshtastic + LLC. "Meshtastic" is a registered trademark of Meshtastic LLC. See the + component README for licensing and trademark details. + +.. toctree:: + + meshtastic_example + +API Reference +------------- + +.. include-build-file:: inc/meshtastic.inc +.. include-build-file:: inc/meshtastic_types.inc +.. include-build-file:: inc/meshtastic_protocol.inc +.. include-build-file:: inc/meshtastic_crypto.inc +.. include-build-file:: inc/meshtastic_protobuf.inc diff --git a/doc/en/wireless/meshtastic_example.md b/doc/en/wireless/meshtastic_example.md new file mode 100755 index 000000000..f24f943e5 --- /dev/null +++ b/doc/en/wireless/meshtastic_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/meshtastic/example/README.md +``` diff --git a/doc/en/wireless/sx126x.rst b/doc/en/wireless/sx126x.rst new file mode 100755 index 000000000..153b368a0 --- /dev/null +++ b/doc/en/wireless/sx126x.rst @@ -0,0 +1,21 @@ +SX126x LoRa Radio +***************** + +The `Sx126x` component provides a driver for the Semtech SX126x series of +sub-GHz LoRa radio transceivers (SX1261, SX1262) and compatible chips such as +the LLCC68. It supports LoRa modem configuration, interrupt-driven or polled +operation, blocking and non-blocking transmit, continuous receive with RSSI / +SNR packet status, channel activity detection, and TCXO / RF-switch control. + +The radio is a command-based SPI peripheral and can share an SPI bus with +other devices (as on the LilyGo T-Deck, where it shares the bus with the +display and uSD card). + +.. toctree:: + + sx126x_example + +API Reference +------------- + +.. include-build-file:: inc/sx126x.inc diff --git a/doc/en/wireless/sx126x_example.md b/doc/en/wireless/sx126x_example.md new file mode 100755 index 000000000..32972906e --- /dev/null +++ b/doc/en/wireless/sx126x_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/sx126x/example/README.md +```