From 176340600e28a79c65850c05d5a45c2a7f316a37 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 29 Jul 2026 17:00:46 -0500 Subject: [PATCH] feat(lilygo-t5-47): Add BSP for the LilyGo T5 4.7" ESP32-S3 e-paper board Board support package (espp::LilyGoT547 singleton) for the LilyGo T5 4.7" (ESP32-S3) e-paper board, wrapping the epdiy library for the ED047TC1 960x540 16-level-grayscale panel. Features: - E-paper display via epdiy (LCD_CAM parallel bus): power on/off, clear, a grayscale framebuffer, and partial/full updates. - LVGL 9 integration: an L8 grayscale display whose flush batches each refresh cycle's dirty areas into a single panel update; selectable update mode (GC16 grayscale vs fast mono DU) and touch-aware display rotation. - GT911 capacitive touch (polled - the panel's controller config is query-mode) as an LVGL input device, plus the capacitive home button. - PCF8563 RTC (via the register-compatible espp::Bm8563 driver). - BQ27220 battery fuel gauge. - PCA9535 I/O expander (the same chip epdiy drives) including the IO48 button. - Frontlight, BOOT button, qwiic bus accessor, microSD (SPI), and power-off via BQ25896 ship mode. - All on-board I2C peripherals share epdiy's bus (100 kHz), handed to epdiy via epd_init_with_config so there is a single shared I2C master. Adds two reusable espp components used by this BSP: - bq27220: TI BQ27220 I2C battery fuel-gauge driver. - pca9535: PCA9535 / PCA9555 16-bit I2C GPIO-expander driver. The example is an interactive LVGL GUI (Gui class): a live RTC clock, a toggle-able stats panel (battery / touch / IO48- and home-button state), and buttons for frontlight, rotation, stats, full-refresh, and power-off. The panel update mode follows the stats panel (GC16 while shown for legibility, DU while hidden for a snappy clock). Docs pages and CI example-build entries added for all three components. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 6 + components/bq27220/CMakeLists.txt | 4 + components/bq27220/README.md | 22 + components/bq27220/example/CMakeLists.txt | 22 + components/bq27220/example/README.md | 34 ++ .../bq27220/example/main/CMakeLists.txt | 2 + .../bq27220/example/main/Kconfig.projbuild | 39 ++ .../bq27220/example/main/bq27220_example.cpp | 83 ++++ components/bq27220/example/sdkconfig.defaults | 11 + components/bq27220/idf_component.yml | 22 + components/bq27220/include/bq27220.hpp | 274 ++++++++++ components/lilygo-t5-47/CMakeLists.txt | 6 + components/lilygo-t5-47/README.md | 87 ++++ .../lilygo-t5-47/example/CMakeLists.txt | 39 ++ components/lilygo-t5-47/example/README.md | 60 +++ .../lilygo-t5-47/example/main/CMakeLists.txt | 2 + components/lilygo-t5-47/example/main/gui.cpp | 257 ++++++++++ components/lilygo-t5-47/example/main/gui.hpp | 133 +++++ .../example/main/lilygo-t5-47_example.cpp | 158 ++++++ .../lilygo-t5-47/example/sdkconfig.defaults | 26 + components/lilygo-t5-47/idf_component.yml | 44 ++ .../lilygo-t5-47/include/lilygo-t5-47.hpp | 467 ++++++++++++++++++ components/lilygo-t5-47/src/battery.cpp | 39 ++ components/lilygo-t5-47/src/buttons.cpp | 55 +++ components/lilygo-t5-47/src/frontlight.cpp | 22 + components/lilygo-t5-47/src/io_expander.cpp | 73 +++ components/lilygo-t5-47/src/lilygo-t5-47.cpp | 103 ++++ components/lilygo-t5-47/src/lvgl.cpp | 136 +++++ components/lilygo-t5-47/src/power.cpp | 46 ++ components/lilygo-t5-47/src/rtc.cpp | 41 ++ components/lilygo-t5-47/src/sdcard.cpp | 86 ++++ components/lilygo-t5-47/src/touch.cpp | 188 +++++++ components/pca9535/CMakeLists.txt | 4 + components/pca9535/README.md | 14 + components/pca9535/example/CMakeLists.txt | 22 + components/pca9535/example/README.md | 33 ++ .../pca9535/example/main/CMakeLists.txt | 2 + .../pca9535/example/main/Kconfig.projbuild | 39 ++ .../pca9535/example/main/pca9535_example.cpp | 85 ++++ components/pca9535/example/sdkconfig.defaults | 13 + components/pca9535/idf_component.yml | 21 + components/pca9535/include/pca9535.hpp | 219 ++++++++ doc/Doxyfile | 6 + doc/en/battery/bq27220.rst | 24 + doc/en/battery/bq27220_example.md | 2 + doc/en/battery/index.rst | 1 + doc/en/dev_boards/lilygo/index.rst | 1 + doc/en/dev_boards/lilygo/lilygo_t5_47.rst | 34 ++ .../dev_boards/lilygo/lilygo_t5_47_example.md | 2 + doc/en/io_expander/index.rst | 1 + doc/en/io_expander/pca9535.rst | 23 + doc/en/io_expander/pca9535_example.md | 2 + 52 files changed, 3135 insertions(+) create mode 100644 components/bq27220/CMakeLists.txt create mode 100644 components/bq27220/README.md create mode 100644 components/bq27220/example/CMakeLists.txt create mode 100644 components/bq27220/example/README.md create mode 100644 components/bq27220/example/main/CMakeLists.txt create mode 100644 components/bq27220/example/main/Kconfig.projbuild create mode 100644 components/bq27220/example/main/bq27220_example.cpp create mode 100644 components/bq27220/example/sdkconfig.defaults create mode 100644 components/bq27220/idf_component.yml create mode 100644 components/bq27220/include/bq27220.hpp create mode 100644 components/lilygo-t5-47/CMakeLists.txt create mode 100644 components/lilygo-t5-47/README.md create mode 100644 components/lilygo-t5-47/example/CMakeLists.txt create mode 100644 components/lilygo-t5-47/example/README.md create mode 100644 components/lilygo-t5-47/example/main/CMakeLists.txt create mode 100644 components/lilygo-t5-47/example/main/gui.cpp create mode 100644 components/lilygo-t5-47/example/main/gui.hpp create mode 100644 components/lilygo-t5-47/example/main/lilygo-t5-47_example.cpp create mode 100644 components/lilygo-t5-47/example/sdkconfig.defaults create mode 100644 components/lilygo-t5-47/idf_component.yml create mode 100644 components/lilygo-t5-47/include/lilygo-t5-47.hpp create mode 100644 components/lilygo-t5-47/src/battery.cpp create mode 100644 components/lilygo-t5-47/src/buttons.cpp create mode 100644 components/lilygo-t5-47/src/frontlight.cpp create mode 100644 components/lilygo-t5-47/src/io_expander.cpp create mode 100644 components/lilygo-t5-47/src/lilygo-t5-47.cpp create mode 100644 components/lilygo-t5-47/src/lvgl.cpp create mode 100644 components/lilygo-t5-47/src/power.cpp create mode 100644 components/lilygo-t5-47/src/rtc.cpp create mode 100644 components/lilygo-t5-47/src/sdcard.cpp create mode 100644 components/lilygo-t5-47/src/touch.cpp create mode 100644 components/pca9535/CMakeLists.txt create mode 100644 components/pca9535/README.md create mode 100644 components/pca9535/example/CMakeLists.txt create mode 100644 components/pca9535/example/README.md create mode 100644 components/pca9535/example/main/CMakeLists.txt create mode 100644 components/pca9535/example/main/Kconfig.projbuild create mode 100644 components/pca9535/example/main/pca9535_example.cpp create mode 100644 components/pca9535/example/sdkconfig.defaults create mode 100644 components/pca9535/idf_component.yml create mode 100644 components/pca9535/include/pca9535.hpp create mode 100644 doc/en/battery/bq27220.rst create mode 100644 doc/en/battery/bq27220_example.md create mode 100644 doc/en/dev_boards/lilygo/lilygo_t5_47.rst create mode 100644 doc/en/dev_boards/lilygo/lilygo_t5_47_example.md create mode 100644 doc/en/io_expander/pca9535.rst create mode 100644 doc/en/io_expander/pca9535_example.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9cf4d2829..5a49abd66 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,6 +57,8 @@ jobs: target: esp32 - path: 'components/bmi270/example' target: esp32p4 + - path: 'components/bq27220/example' + target: esp32 - path: 'components/button/example' target: esp32 - path: 'components/byte90/example' @@ -141,6 +143,8 @@ jobs: target: esp32 - path: 'components/led_strip/example' target: esp32 + - path: 'components/lilygo-t5-47/example' + target: esp32s3 - path: 'components/logger/example' target: esp32 - path: 'components/lp5817/example' @@ -177,6 +181,8 @@ jobs: target: esp32s3 - path: 'components/odrive_ascii/example' target: esp32 + - path: 'components/pca9535/example' + target: esp32s3 - path: 'components/pcf85063/example' target: esp32s3 - path: 'components/pi4ioe5v/example' diff --git a/components/bq27220/CMakeLists.txt b/components/bq27220/CMakeLists.txt new file mode 100644 index 000000000..3d35d0937 --- /dev/null +++ b/components/bq27220/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + INCLUDE_DIRS "include" + REQUIRES "base_peripheral" "math" + ) diff --git a/components/bq27220/README.md b/components/bq27220/README.md new file mode 100644 index 000000000..fc04fdd0b --- /dev/null +++ b/components/bq27220/README.md @@ -0,0 +1,22 @@ +# BQ27220 I2C Battery Fuel Gauge Component + +[![Badge](https://components.espressif.com/components/espp/bq27220/badge.svg)](https://components.espressif.com/components/espp/bq27220) + +The Texas Instruments BQ27220 is a single-cell Li-Ion battery fuel gauge (gas +gauge) that uses the compensated end-of-discharge voltage (CEDV) algorithm to +provide accurate state-of-charge and remaining-capacity information without +requiring the host to maintain a battery-learn cycle. + +The BQ27220 reports battery voltage, instantaneous and average current, average +power, temperature, state of charge, state of health, remaining and full-charge +capacity, cycle count, and time-to-empty / time-to-full estimates over the I2C +interface. All data values are 16-bit little-endian. + +## Example + +The [example](./example) shows how to use the BQ27220 driver to talk to the +BQ27220 and retrieve the current battery: +* Voltage (mV) +* Current (mA) +* State of Charge (%) +* Temperature (°C) diff --git a/components/bq27220/example/CMakeLists.txt b/components/bq27220/example/CMakeLists.txt new file mode 100644 index 000000000..90daffe68 --- /dev/null +++ b/components/bq27220/example/CMakeLists.txt @@ -0,0 +1,22 @@ +# 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) + +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 i2c task bq27220" + CACHE STRING + "List of components to include" + ) + +project(bq27220_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/bq27220/example/README.md b/components/bq27220/example/README.md new file mode 100644 index 000000000..c987b3808 --- /dev/null +++ b/components/bq27220/example/README.md @@ -0,0 +1,34 @@ +# BQ27220 Example + +This example shows how to use the `espp::Bq27220` driver to talk to a TI BQ27220 +battery fuel gauge over I2C and read the battery: +* Voltage (mV) +* Current (mA) +* State of charge (%) +* Temperature (°C) + +## How to use example + +### Hardware Required + +This example requires a connection (via I2C) to a board with a BQ27220 battery +fuel gauge and an attached battery. Configure the I2C pins (SDA/SCL) via +`menuconfig` for your board. + +### Build and Flash + +Build the project and flash it to the board, then run the monitor tool to view +serial output: + +``` +idf.py -p PORT flash monitor +``` + +(Replace PORT with the name of the serial port to use.) + +(To exit the serial monitor, type ``Ctrl-]``.) + +## Example Output + +The example logs the battery voltage, current, state of charge, and temperature +in a loop. diff --git a/components/bq27220/example/main/CMakeLists.txt b/components/bq27220/example/main/CMakeLists.txt new file mode 100644 index 000000000..a941e22ba --- /dev/null +++ b/components/bq27220/example/main/CMakeLists.txt @@ -0,0 +1,2 @@ +idf_component_register(SRC_DIRS "." + INCLUDE_DIRS ".") diff --git a/components/bq27220/example/main/Kconfig.projbuild b/components/bq27220/example/main/Kconfig.projbuild new file mode 100644 index 000000000..dfc846b7d --- /dev/null +++ b/components/bq27220/example/main/Kconfig.projbuild @@ -0,0 +1,39 @@ +menu "Example Configuration" + + choice EXAMPLE_HARDWARE + prompt "Hardware" + default EXAMPLE_HARDWARE_QTPYPICO + help + Select the hardware to run this example on. + + config EXAMPLE_HARDWARE_QTPYPICO + depends on IDF_TARGET_ESP32 + bool "Qt Py PICO" + + config EXAMPLE_HARDWARE_QTPYS3 + depends on IDF_TARGET_ESP32S3 + bool "Qt Py S3" + + config EXAMPLE_HARDWARE_CUSTOM + bool "Custom" + endchoice + + config EXAMPLE_I2C_SCL_GPIO + int "SCL GPIO Num" + range 0 50 + default 19 if EXAMPLE_HARDWARE_QTPYPICO + default 40 if EXAMPLE_HARDWARE_QTPYS3 + default 19 if EXAMPLE_HARDWARE_CUSTOM + help + GPIO number for I2C Master clock line. + + config EXAMPLE_I2C_SDA_GPIO + int "SDA GPIO Num" + range 0 50 + default 22 if EXAMPLE_HARDWARE_QTPYPICO + default 41 if EXAMPLE_HARDWARE_QTPYS3 + default 22 if EXAMPLE_HARDWARE_CUSTOM + help + GPIO number for I2C Master data line. + +endmenu diff --git a/components/bq27220/example/main/bq27220_example.cpp b/components/bq27220/example/main/bq27220_example.cpp new file mode 100644 index 000000000..d010cac52 --- /dev/null +++ b/components/bq27220/example/main/bq27220_example.cpp @@ -0,0 +1,83 @@ +#include +#include + +#include "bq27220.hpp" +#include "i2c.hpp" +#include "logger.hpp" +#include "task.hpp" + +using namespace std::chrono_literals; + +extern "C" void app_main(void) { + + //! [bq27220 example] + espp::Logger logger({.tag = "Bq27220 example", .level = espp::Logger::Verbosity::INFO}); + // make the I2C that we'll use to communicate + logger.info("initializing i2c driver..."); + espp::I2c i2c({ + .port = I2C_NUM_0, + .sda_io_num = (gpio_num_t)CONFIG_EXAMPLE_I2C_SDA_GPIO, + .scl_io_num = (gpio_num_t)CONFIG_EXAMPLE_I2C_SCL_GPIO, + }); + std::error_code ec; + auto bq27220_device = + i2c.add_device({.device_address = espp::Bq27220::DEFAULT_ADDRESS, + .timeout_ms = static_cast(i2c.config().timeout_ms), + .scl_speed_hz = i2c.config().clk_speed, + .log_level = espp::Logger::Verbosity::WARN}, + ec); + if (!bq27220_device) { + logger.error("BQ27220 I2C device initialization failed: {}", ec.message()); + return; + } + // now make the bq27220 which handles the fuel gauge + espp::Bq27220 bq27220({.write = espp::make_i2c_addressed_write(bq27220_device), + .read = espp::make_i2c_addressed_read(bq27220_device), + .log_level = espp::Logger::Verbosity::WARN}); + + // and finally, make the task to periodically poll the bq27220 and print + // the state. + auto task_fn = [&](std::mutex &m, std::condition_variable &cv) { + // NOTE: sleeping in this way allows the sleep to exit early when the + // task is being stopped / destroyed + { + std::unique_lock lk(m); + cv.wait_for(lk, 1s); + } + static auto start = std::chrono::high_resolution_clock::now(); + auto now = std::chrono::high_resolution_clock::now(); + auto seconds = std::chrono::duration(now - start).count(); + auto voltage = bq27220.get_voltage_mv(ec); + if (ec) { + return false; + } + auto current = bq27220.get_current_ma(ec); + if (ec) { + return false; + } + auto soc = bq27220.get_state_of_charge(ec); + if (ec) { + return false; + } + auto temperature = bq27220.get_temperature_celsius(ec); + if (ec) { + return false; + } + fmt::print("{:0.2f}, {}, {}, {}, {:0.2f}\n", seconds, voltage, current, soc, temperature); + // don't want to stop the task + return false; + }; + auto task = espp::Task({.callback = task_fn, + .task_config = + { + .name = "Bq27220 Task", + .stack_size_bytes = 5 * 1024, + }, + .log_level = espp::Logger::Verbosity::WARN}); + fmt::print("%time(s), voltage (mV), current (mA), SoC (%), Temperature (C)\n"); + task.start(); + //! [bq27220 example] + while (true) { + std::this_thread::sleep_for(100ms); + } +} diff --git a/components/bq27220/example/sdkconfig.defaults b/components/bq27220/example/sdkconfig.defaults new file mode 100644 index 000000000..7d75f8d01 --- /dev/null +++ b/components/bq27220/example/sdkconfig.defaults @@ -0,0 +1,11 @@ +CONFIG_FREERTOS_HZ=1000 + +# ESP32-specific +# +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240 + +# Common ESP-related +# +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 diff --git a/components/bq27220/idf_component.yml b/components/bq27220/idf_component.yml new file mode 100644 index 000000000..7cf1502d2 --- /dev/null +++ b/components/bq27220/idf_component.yml @@ -0,0 +1,22 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "BQ27220 Battery Fuel Gauge component for ESP-IDF" +url: "https://github.com/esp-cpp/espp/tree/main/components/bq27220" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/battery/bq27220.html" +examples: + - path: example +tags: + - cpp + - Component + - Battery + - Fuel + - Gauge + - BQ27220 +dependencies: + idf: + version: '>=5.0' + espp/base_peripheral: '>=1.0' + espp/math: '>=1.0' diff --git a/components/bq27220/include/bq27220.hpp b/components/bq27220/include/bq27220.hpp new file mode 100644 index 000000000..c41ca93e2 --- /dev/null +++ b/components/bq27220/include/bq27220.hpp @@ -0,0 +1,274 @@ +#pragma once + +#include +#include + +#include "base_peripheral.hpp" + +namespace espp { +/** + * @brief Class to interface with the BQ27220 battery fuel gauge. + * @details This class is used to interface with the Texas Instruments BQ27220 + * I2C battery fuel gauge (gas gauge). It is used to get the battery + * voltage, current, state of charge, state of health, temperature, + * capacity, and time-to-empty / time-to-full estimates. + * @note All data values reported by the BQ27220 are 16-bit little-endian. + * @see https://www.ti.com/lit/gpn/bq27220 + * + * @section bq27220_ex1 BQ27220 Example + * @snippet bq27220_example.cpp bq27220 example + */ +class Bq27220 : public BasePeripheral<> { +public: + static constexpr uint8_t DEFAULT_ADDRESS = 0x55; ///< Default address of the BQ27220. + + /** + * @brief Configuration for the BQ27220. + */ + struct Config { + uint8_t device_address{DEFAULT_ADDRESS}; ///< Address of the BQ27220. + BasePeripheral::write_fn write; //< Function to write bytes to the device. + BasePeripheral::read_fn read; //< Function to read bytes from the device. + bool auto_init{true}; ///< Whether to automatically initialize the BQ27220. + Logger::Verbosity log_level{Logger::Verbosity::WARN}; ///< Log level for the BQ27220. + }; + + /** + * @brief Construct a new Bq27220 object. + * @param config Configuration for the BQ27220. + */ + explicit Bq27220(const Config &config) + : BasePeripheral( + {.address = config.device_address, .write = config.write, .read = config.read}, + "Bq27220", config.log_level) { + if (config.auto_init) { + std::error_code ec; + initialize(ec); + if (ec) { + logger_.error("Failed to initialize BQ27220: {}", ec.message()); + } + } + } + + /** + * @brief Initialize the BQ27220. + * @details Performs a communications sanity check by reading the battery + * status register. This does not perform any data-memory or + * subcommand access. + * @param ec Error code set if an error occurs during initialization. + */ + void initialize(std::error_code &ec) { + std::lock_guard lock(base_mutex_); + // Perform a simple comms sanity read of the BatteryStatus register. + uint16_t status = read_u16_le((uint8_t)Register::BatteryStatus, ec); + if (ec) { + return; + } + logger_.info("BQ27220 initialized, battery status: 0x{:04X}", status); + ec.clear(); + } + + /** + * @brief Get the battery voltage. + * @param ec Error code set if an error occurs. + * @return The battery voltage in mV. + */ + uint16_t get_voltage_mv(std::error_code &ec) { + return read_u16_le((uint8_t)Register::Voltage, ec); + } + + /** + * @brief Get the instantaneous battery current. + * @details A positive value indicates charging. A negative value indicates + * discharging. + * @param ec Error code set if an error occurs. + * @return The battery current in mA (signed). + */ + int16_t get_current_ma(std::error_code &ec) { + return read_s16_le((uint8_t)Register::Current, ec); + } + + /** + * @brief Get the average battery current. + * @details A positive value indicates charging. A negative value indicates + * discharging. + * @param ec Error code set if an error occurs. + * @return The average battery current in mA (signed). + */ + int16_t get_average_current_ma(std::error_code &ec) { + return read_s16_le((uint8_t)Register::AverageCurrent, ec); + } + + /** + * @brief Get the average power. + * @details A positive value indicates charging. A negative value indicates + * discharging. + * @param ec Error code set if an error occurs. + * @return The average power in mW (signed). + */ + int16_t get_average_power_mw(std::error_code &ec) { + return read_s16_le((uint8_t)Register::AveragePower, ec); + } + + /** + * @brief Get the battery temperature. + * @details The BQ27220 reports temperature in units of 0.1 Kelvin. This is + * converted to degrees Celsius. + * @param ec Error code set if an error occurs. + * @return The battery temperature in degrees Celsius. + */ + float get_temperature_celsius(std::error_code &ec) { + uint16_t raw = read_u16_le((uint8_t)Register::Temperature, ec); + if (ec) { + return 0.0f; + } + ec.clear(); + return (float)raw * 0.1f - 273.15f; + } + + /** + * @brief Get the battery state of charge. + * @details This is the percentage of battery charge remaining. + * @param ec Error code set if an error occurs. + * @return The battery state of charge in % (0-100). + */ + uint8_t get_state_of_charge(std::error_code &ec) { + uint16_t data = read_u16_le((uint8_t)Register::StateOfCharge, ec); + if (ec) { + return 0; + } + ec.clear(); + return (uint8_t)(data & 0xFF); + } + + /** + * @brief Get the battery state of health. + * @param ec Error code set if an error occurs. + * @return The battery state of health in % (0-100). + */ + uint8_t get_state_of_health(std::error_code &ec) { + uint16_t data = read_u16_le((uint8_t)Register::StateOfHealth, ec); + if (ec) { + return 0; + } + ec.clear(); + return (uint8_t)(data & 0xFF); + } + + /** + * @brief Get the remaining battery capacity. + * @param ec Error code set if an error occurs. + * @return The remaining battery capacity in mAh. + */ + uint16_t get_remaining_capacity_mah(std::error_code &ec) { + return read_u16_le((uint8_t)Register::RemainingCapacity, ec); + } + + /** + * @brief Get the full charge capacity. + * @param ec Error code set if an error occurs. + * @return The full charge capacity in mAh. + */ + uint16_t get_full_charge_capacity_mah(std::error_code &ec) { + return read_u16_le((uint8_t)Register::FullChargeCapacity, ec); + } + + /** + * @brief Get the design capacity. + * @param ec Error code set if an error occurs. + * @return The design capacity in mAh. + */ + uint16_t get_design_capacity_mah(std::error_code &ec) { + return read_u16_le((uint8_t)Register::DesignCapacity, ec); + } + + /** + * @brief Get the estimated time until the battery is empty. + * @param ec Error code set if an error occurs. + * @return The time to empty in minutes. + */ + uint16_t get_time_to_empty_minutes(std::error_code &ec) { + return read_u16_le((uint8_t)Register::TimeToEmpty, ec); + } + + /** + * @brief Get the estimated time until the battery is fully charged. + * @param ec Error code set if an error occurs. + * @return The time to full in minutes. + */ + uint16_t get_time_to_full_minutes(std::error_code &ec) { + return read_u16_le((uint8_t)Register::TimeToFull, ec); + } + + /** + * @brief Get the battery cycle count. + * @param ec Error code set if an error occurs. + * @return The number of charge cycles. + */ + uint16_t get_cycle_count(std::error_code &ec) { + return read_u16_le((uint8_t)Register::CycleCount, ec); + } + + /** + * @brief Get the battery status flags. + * @param ec Error code set if an error occurs. + * @return The battery status flags. + */ + uint16_t get_battery_status(std::error_code &ec) { + return read_u16_le((uint8_t)Register::BatteryStatus, ec); + } + +protected: + /// @brief BQ27220 register (standard command) map. + enum class Register : uint8_t { + Control = 0x00, ///< Control() command / status + AtRate = 0x02, ///< AtRate() (signed mA) + AtRateTimeToEmpty = 0x04, ///< AtRateTimeToEmpty() (minutes) + Temperature = 0x06, ///< Temperature() (unsigned, units 0.1 Kelvin) + Voltage = 0x08, ///< Voltage() (mV) + BatteryStatus = 0x0A, ///< BatteryStatus() (flags) + Current = 0x0C, ///< Current() (signed mA) + RemainingCapacity = 0x10, ///< RemainingCapacity() (mAh) + FullChargeCapacity = 0x12, ///< FullChargeCapacity() (mAh) + AverageCurrent = 0x14, ///< AverageCurrent() (signed mA) + TimeToEmpty = 0x16, ///< TimeToEmpty() (minutes) + TimeToFull = 0x18, ///< TimeToFull() (minutes) + StandbyCurrent = 0x1A, ///< StandbyCurrent() (signed mA) + AveragePower = 0x24, ///< AveragePower() (signed mW) + InternalTemperature = 0x28, ///< InternalTemperature() (unsigned, 0.1 Kelvin) + CycleCount = 0x2A, ///< CycleCount() + StateOfCharge = 0x2C, ///< StateOfCharge() (percent) + StateOfHealth = 0x2E, ///< StateOfHealth() (percent) + DesignCapacity = 0x3C, ///< DesignCapacity() (mAh) + }; + + /** + * @brief Read a 16-bit little-endian unsigned value from a register. + * @details The BQ27220 reports all data values as 16-bit little-endian, so + * we cannot use BasePeripheral::read_u16_from_register (which is + * big-endian). + * @param reg The register (command) to read from. + * @param ec Error code set if an error occurs. + * @return The 16-bit unsigned value. + */ + uint16_t read_u16_le(uint8_t reg, std::error_code &ec) { + uint8_t buf[2] = {0, 0}; + read_many_from_register(reg, buf, 2, ec); + if (ec) { + return 0; + } + ec.clear(); + return static_cast(buf[0] | (buf[1] << 8)); + } + + /** + * @brief Read a 16-bit little-endian signed value from a register. + * @param reg The register (command) to read from. + * @param ec Error code set if an error occurs. + * @return The 16-bit signed value. + */ + int16_t read_s16_le(uint8_t reg, std::error_code &ec) { + return static_cast(read_u16_le(reg, ec)); + } +}; +} // namespace espp diff --git a/components/lilygo-t5-47/CMakeLists.txt b/components/lilygo-t5-47/CMakeLists.txt new file mode 100644 index 000000000..b4a7cf8e7 --- /dev/null +++ b/components/lilygo-t5-47/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES driver esp_driver_gpio esp_driver_spi esp_driver_i2c fatfs base_component task interrupt i2c spi gt911 bm8563 bq27220 pca9535 input_drivers lvgl epdiy + REQUIRED_IDF_TARGETS "esp32s3" + ) diff --git a/components/lilygo-t5-47/README.md b/components/lilygo-t5-47/README.md new file mode 100644 index 000000000..e219aef3a --- /dev/null +++ b/components/lilygo-t5-47/README.md @@ -0,0 +1,87 @@ +# LilyGo T5 4.7" (ESP32-S3) E-Paper BSP + +Board Support Package for the [LilyGo T5 4.7 inch ESP32-S3 +e-paper](https://www.lilygo.cc/products/t5-4-7-inch-e-paper-v2-3) board. + +The board drives an **ED047TC1** 960x540, 16-level-grayscale e-paper panel over +a parallel bus (the ESP32-S3 `LCD_CAM` peripheral). This BSP wraps the +[epdiy](https://github.com/vroland/epdiy) library, which owns the timing, +waveforms, grayscale rendering and partial-update handling for this exact board. + +## Features + +- 4.7" 960x540 e-paper display (ED047TC1) via epdiy: init, power on/off, clear, + a grayscale framebuffer to draw into, and screen updates. +- LVGL 9 integration: an 8-bit grayscale (`L8`) display whose flush writes into + the epdiy framebuffer, batching each refresh cycle's dirty areas into a single + panel update. Choose the update mode (`MODE_GC16` for crisp 16-level grayscale + or a faster mono mode like `MODE_DU`) and force a full refresh to clear + ghosting. +- Display rotation (`set_rotation()` / `rotate()`): rotates the e-paper and + resizes the LVGL display to match, and transforms touch coordinates so they + stay aligned in every orientation. +- BOOT button (GPIO0) via `espp::Interrupt`. +- Capacitive touch (GT911) on the internal I2C bus, registered as an LVGL input + device so on-screen widgets are tappable. The capacitive home button below the + display is reported via `home_button_pressed()` / `touchpad_data().btn_state`. +- PCF8563 real-time clock (via the register-compatible `espp::Bm8563` driver). +- BQ27220 battery fuel gauge (`espp::Bq27220`): voltage, current, + state-of-charge, temperature, capacity, etc. +- PCA9535 I/O expander (`espp::Pca9535`) — the same chip epdiy uses for the + e-paper power ICs — with an `io48_button_pressed()` convenience for the + "IO48"-labelled button (on expander pin PC12). +- Frontlight on/off (`set_frontlight()`, BL_EN / GPIO11). +- Power off via `shutdown()`, which puts the BQ25896 PMIC into ship mode + (disconnects the battery); the PWR button turns the board back on. Only powers + off when running on battery (not while USB is connected). +- qwiic / STEMMA-QT connector, wired to the internal I2C bus (`qwiic_i2c()`). +- microSD card (SPI) mounted as a FAT filesystem at `/sdcard`. + +The internal I2C bus (SDA=39/SCL=40) is created by this BSP and shared with +epdiy's e-paper power ICs, so the board's other I2C peripherals (touch, RTC, +battery gauge, the PCA9535 I/O expander and the qwiic connector) all live on the +same bus. Access it via `internal_i2c()`. + +> **The PCA9535 I/O expander is shared with epdiy.** epdiy drives this same chip +> (address 0x20) to sequence the e-paper's high-voltage rails and owns port 1's +> high bits. The BSP's `io_expander()` is created without re-initializing the +> chip; only read inputs or drive pins epdiy does not use, always with +> read-modify-write. The IO48 button lives on expander pin PC12 (port 1, bit 2), +> which epdiy leaves alone. + +## Pins + +The non-display pins below are taken from the LilyGo *T5 4.7" ePaper S3 PRO* +factory firmware. The display itself is driven entirely by epdiy's +`lilygo_board_s3` board definition. + +| Function | Pin | +|----------|-----| +| BOOT button | GPIO0 | +| Internal I2C SDA / SCL | GPIO39 / GPIO40 | +| GT911 touch INT / RST | GPIO3 / GPIO9 | +| PCF8563 RTC IRQ | GPIO2 | +| PCA9535 expander INT | GPIO38 | +| Frontlight (BL_EN) | GPIO11 | +| microSD SCLK / MOSI / MISO / CS | GPIO14 / GPIO13 / GPIO21 / GPIO12 | + +I2C addresses on the internal bus: GT911 touch `0x5D`, PCF8563 RTC `0x51`, +BQ27220 gauge `0x55`, PCA9535 expander `0x20`. + +## Notes on e-paper + +E-paper is not a color TFT: updates are slow (hundreds of ms), the panel needs +its high-voltage rails powered only during an update, and the waveform is +temperature dependent. Prefer full refreshes (`clear()` / `MODE_GC16`) to clear +ghosting and partial/mono modes for fast incremental updates. + +## Example + +See `example/` for a minimal bring-up (clear + draw a test pattern). Build with +the ESP-IDF component manager enabled (needed to fetch epdiy): + +``` +cd example +idf.py set-target esp32s3 +idf.py build flash monitor +``` diff --git a/components/lilygo-t5-47/example/CMakeLists.txt b/components/lilygo-t5-47/example/CMakeLists.txt new file mode 100644 index 000000000..6f8fe49b3 --- /dev/null +++ b/components/lilygo-t5-47/example/CMakeLists.txt @@ -0,0 +1,39 @@ +# 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) + +# The component manager must be enabled to fetch the epdiy dependency declared by +# the lilygo-t5-47 BSP. The espp components are provided locally via +# EXTRA_COMPONENT_DIRS below. +set(ENV{IDF_COMPONENT_MANAGER} "1") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add the component directories that we want to use. List the specific espp +# component dirs (not the whole components/ tree) so the component manager does +# not try to validate esp32p4-only BSPs against this esp32s3 target. +set(EXTRA_COMPONENT_DIRS + "../" + "../../../components/base_component" + "../../../components/bm8563" + "../../../components/format" + "../../../components/gt911" + "../../../components/i2c" + "../../../components/input_drivers" + "../../../components/interrupt" + "../../../components/logger" + "../../../components/lvgl" + "../../../components/spi" + "../../../components/task" + "../../../components/touch" +) + +set( + COMPONENTS + "main esptool_py lilygo-t5-47 lvgl" + CACHE STRING + "List of components to include" + ) + +project(lilygo_t5_47_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/lilygo-t5-47/example/README.md b/components/lilygo-t5-47/example/README.md new file mode 100644 index 000000000..710a8dfe0 --- /dev/null +++ b/components/lilygo-t5-47/example/README.md @@ -0,0 +1,60 @@ +# LilyGo T5 4.7" e-paper Example + +This example shows how to use the `espp::LilyGoT547` hardware abstraction +component to bring up the LilyGo T5 4.7" (ESP32-S3) e-paper board. + +It initializes the e-paper display and an LVGL grayscale UI, then the on-board +peripherals: the BOOT button, GT911 capacitive touch (registered as an LVGL +input device), the PCF8563 RTC, the BQ27220 battery fuel gauge, the PCA9535 I/O +expander (and the IO48 button), the frontlight, and the microSD card. + +The UI (in the `Gui` class, `gui.hpp`/`gui.cpp`) shows a live RTC clock and date, +and a toggle-able stats panel with the battery voltage / state-of-charge / +current, the last touch coordinates, and the IO48 and home button states. It has +five touch buttons: + +* **Light** - toggle the frontlight on/off. +* **Rotate** - cycle the display rotation. The layout (a flex column of a + header, the stats rows, a spacer, and a wrapping button row) re-flows to the + new landscape/portrait dimensions, and touch stays aligned. +* **Stats** - show/hide the stats panel. The panel update mode follows it: crisp + grayscale (`GC16`) while shown so the small text is legible, and fast mono + (`DU`) while hidden for a snappy clock-only view. (`DU` is too coarse to render + the small stats text.) +* **Refresh** - do a full-screen refresh to clear e-paper ghosting. +* **Off** - power the board down (BQ25896 ship mode); press PWR to turn it back + on. Only works on battery (not while USB is connected). + +The BOOT button also triggers a full refresh, and the app does one automatically +every 30 s. Because e-paper refreshes are slow and flash, the UI only redraws +labels whose value actually changed. + +## How to use example + +### Hardware Required + +This example requires a LilyGo T5 4.7" (ESP32-S3) e-paper board. + +### Build and Flash + +Build the project and flash it to the board, then run the monitor tool to view +serial output. The ESP-IDF component manager must be enabled (it is, in the +example's CMakeLists) so the epdiy dependency is fetched: + +``` +cd example +idf.py set-target esp32s3 +idf.py -p PORT flash monitor +``` + +(Replace PORT with the name of the serial port to use.) + +(To exit the serial monitor, type ``Ctrl-]``.) + +## Notes + +E-paper updates are slow (hundreds of ms) and the panel needs its high-voltage +rails powered only during an update. The default update mode is `MODE_GC16` +(smooth 16-level grayscale); switch to a mono mode such as `MODE_DU` via +`set_lvgl_update_mode()` for faster but rougher black-and-white updates. Do a +periodic `full_refresh()` to clear ghosting. diff --git a/components/lilygo-t5-47/example/main/CMakeLists.txt b/components/lilygo-t5-47/example/main/CMakeLists.txt new file mode 100644 index 000000000..a941e22ba --- /dev/null +++ b/components/lilygo-t5-47/example/main/CMakeLists.txt @@ -0,0 +1,2 @@ +idf_component_register(SRC_DIRS "." + INCLUDE_DIRS ".") diff --git a/components/lilygo-t5-47/example/main/gui.cpp b/components/lilygo-t5-47/example/main/gui.cpp new file mode 100644 index 000000000..8f8261861 --- /dev/null +++ b/components/lilygo-t5-47/example/main/gui.cpp @@ -0,0 +1,257 @@ +#include "gui.hpp" + +#include + +using namespace std::chrono_literals; + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_header(); + init_info_panel(); + + // A flexible spacer that fills the space between the stats panel and the + // buttons, so the buttons stay at the bottom whether or not stats are shown. + lv_obj_t *spacer = lv_obj_create(lv_screen_active()); + lv_obj_set_width(spacer, 1); + lv_obj_set_flex_grow(spacer, 1); + lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(spacer, 0, 0); + lv_obj_clear_flag(spacer, LV_OBJ_FLAG_SCROLLABLE); + + init_buttons(); +} + +void Gui::init_background() { + lv_obj_t *screen = lv_screen_active(); + lv_obj_set_style_bg_color(screen, lv_color_white(), 0); + lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, 0); + lv_obj_set_style_text_color(screen, lv_color_black(), 0); + // a border around the whole screen + lv_obj_set_style_border_color(screen, lv_color_black(), 0); + lv_obj_set_style_border_width(screen, 3, 0); + lv_obj_set_style_pad_all(screen, 12, 0); + lv_obj_set_style_pad_row(screen, 6, 0); + // Lay the screen out as a vertical stack, centered horizontally, so it adapts + // to the current (rotated) resolution instead of using fixed positions. + lv_obj_set_flex_flow(screen, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(screen, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(screen, LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::init_header() { + lv_obj_t *screen = lv_screen_active(); + + // Full-width, center-aligned text so updating the clock doesn't re-flow the + // layout (only the label's own area is redrawn). + auto centered = [&](lv_obj_t *label, const lv_font_t *font) { + lv_obj_set_width(label, lv_pct(100)); + lv_obj_set_style_text_font(label, font, 0); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); + }; + + lv_obj_t *title = lv_label_create(screen); + lv_label_set_text(title, "LilyGo T5 4.7\" e-paper"); + centered(title, &lv_font_montserrat_20); + + // the live RTC clock, large + time_label_ = lv_label_create(screen); + lv_label_set_text(time_label_, "--:--:--"); + centered(time_label_, &lv_font_montserrat_48); + + date_label_ = lv_label_create(screen); + lv_label_set_text(date_label_, "----/--/--"); + centered(date_label_, &lv_font_montserrat_20); +} + +void Gui::init_info_panel() { + lv_obj_t *screen = lv_screen_active(); + + // Status rows created as direct children of the screen flex column (same as + // the header labels, which render reliably). They are grouped/toggled by + // hiding them together. Full width with left-aligned text gives a clean left + // column; the Stats button shows/hides them. + auto make_row = [&](lv_obj_t **label, const char *initial) { + *label = lv_label_create(screen); + lv_label_set_text(*label, initial); + lv_obj_set_width(*label, lv_pct(96)); + lv_obj_set_style_text_font(*label, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_align(*label, LV_TEXT_ALIGN_LEFT, 0); + }; + + make_row(&battery_label_, "Battery: --"); + make_row(&touch_label_, "Touch: --"); + make_row(&io48_label_, "IO48 button: --"); + make_row(&home_label_, "Home button: --"); +} + +lv_obj_t *Gui::make_button(lv_obj_t *parent, const char *text, lv_obj_t **out_label) { + lv_obj_t *btn = lv_button_create(parent); + lv_obj_set_size(btn, 172, 70); + // white with a black border reads cleanly on grayscale e-paper + lv_obj_set_style_bg_color(btn, lv_color_white(), 0); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(btn, lv_color_black(), 0); + lv_obj_set_style_border_width(btn, 2, 0); + lv_obj_set_style_radius(btn, 6, 0); + // No pressed-state styling: on e-paper the press/release redraws would each be + // a full ~refresh, so a single tap would flash 2-3 times. Keeping the button + // static means only the button's action redraws. + + lv_obj_t *label = lv_label_create(btn); + lv_label_set_text(label, text); + lv_obj_set_style_text_font(label, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(label, lv_color_black(), 0); + lv_obj_center(label); + + lv_obj_add_event_cb(btn, event_callback, LV_EVENT_CLICKED, this); + if (out_label) { + *out_label = label; + } + return btn; +} + +void Gui::init_buttons() { + lv_obj_t *screen = lv_screen_active(); + + // A transparent wrapping flex row of buttons. Wrapping + content height lets + // it re-flow to as many rows as needed when the display is rotated (5 across + // in landscape, wrapping to 2-3 rows in portrait). + lv_obj_t *row = lv_obj_create(screen); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_pad_column(row, 8, 0); + lv_obj_set_style_pad_row(row, 8, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + frontlight_button_ = make_button(row, "Light: OFF", &frontlight_button_label_); + rotate_button_ = make_button(row, "Rotate", nullptr); + stats_button_ = make_button(row, "Stats", nullptr); + refresh_button_ = make_button(row, "Refresh", nullptr); + off_button_ = make_button(row, "Off", nullptr); + + // The update mode follows the stats panel: GC16 (grayscale) while stats are + // shown so the small text is legible; DU (fast mono) while hidden. + board_.set_lvgl_update_mode(stats_shown_ ? MODE_GC16 : MODE_DU); +} + +void Gui::set_label_if_changed(lv_obj_t *label, std::string &cache, std::string_view text) { + if (!label) { + return; + } + if (cache == text) { + return; // no change - avoid a needless e-paper refresh + } + cache = std::string(text); + std::lock_guard lock(mutex_); + lv_label_set_text(label, cache.c_str()); +} + +void Gui::set_time(std::string_view text) { set_label_if_changed(time_label_, time_cache_, text); } +void Gui::set_date(std::string_view text) { set_label_if_changed(date_label_, date_cache_, text); } +void Gui::set_battery(std::string_view text) { + set_label_if_changed(battery_label_, battery_cache_, text); +} + +void Gui::set_touch(int num_points, int x, int y) { + std::string s; + if (num_points > 0) { + s = "Touch: (" + std::to_string(x) + ", " + std::to_string(y) + ")"; + } else { + s = "Touch: --"; + } + set_label_if_changed(touch_label_, touch_cache_, s); +} + +void Gui::set_io48_button(bool pressed) { + set_label_if_changed(io48_label_, io48_cache_, + pressed ? "IO48 button: pressed" : "IO48 button: released"); +} + +void Gui::set_home_button(bool pressed) { + set_label_if_changed(home_label_, home_cache_, + pressed ? "Home button: pressed" : "Home button: released"); +} + +void Gui::request_full_refresh() { + std::lock_guard lock(mutex_); + board_.full_refresh(); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + bool relayout = false; + if (rotate_requested_.exchange(false)) { + board_.rotate(); + relayout = true; + } + if (stats_toggle_requested_.exchange(false)) { + stats_shown_ = !stats_shown_; + for (lv_obj_t *label : {battery_label_, touch_label_, io48_label_, home_label_}) { + if (stats_shown_) { + lv_obj_clear_flag(label, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(label, LV_OBJ_FLAG_HIDDEN); + } + } + // Follow the stats panel with the update mode: GC16 renders the small + // stats text legibly; DU is faster but too coarse for it. + board_.set_lvgl_update_mode(stats_shown_ ? MODE_GC16 : MODE_DU); + relayout = true; + } + if (relayout) { + // A layout change (rotation or showing/hiding the stats panel) needs a + // forced re-layout + render, then a clean full refresh to clear the old + // content. + lv_obj_invalidate(lv_screen_active()); + lv_refr_now(board_.lvgl_display()); + board_.full_refresh(); + } else { + lv_task_handler(); + } + } + std::unique_lock lock(m); + cv.wait_for(lock, 16ms); + return false; // don't stop the task +} + +void Gui::event_callback(lv_event_t *e) { + auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + if (lv_event_get_code(e) == LV_EVENT_CLICKED) { + gui->on_clicked(e); + } +} + +void Gui::on_clicked(lv_event_t *e) { + const auto *target = static_cast(lv_event_get_target(e)); + std::lock_guard lock(mutex_); + if (target == frontlight_button_) { + frontlight_on_ = !frontlight_on_; + board_.set_frontlight(frontlight_on_); + lv_label_set_text(frontlight_button_label_, frontlight_on_ ? "Light: ON" : "Light: OFF"); + logger_.info("Frontlight {}", frontlight_on_ ? "on" : "off"); + } else if (target == rotate_button_) { + // Defer the actual rotation to update() - doing it here (inside + // lv_task_handler's event processing) would re-enter LVGL layout/refresh. + logger_.info("Rotate requested"); + rotate_requested_ = true; + } else if (target == stats_button_) { + logger_.info("Stats toggle requested"); + stats_toggle_requested_ = true; + } else if (target == refresh_button_) { + logger_.info("Full refresh"); + board_.full_refresh(); + } else if (target == off_button_) { + logger_.info("Powering off (ship mode) - press PWR to turn back on"); + board_.shutdown(); + } +} diff --git a/components/lilygo-t5-47/example/main/gui.hpp b/components/lilygo-t5-47/example/main/gui.hpp new file mode 100644 index 000000000..934c8f88e --- /dev/null +++ b/components/lilygo-t5-47/example/main/gui.hpp @@ -0,0 +1,133 @@ +#pragma once + +#include +#include +#include +#include + +#include "lilygo-t5-47.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates the LVGL UI for the LilyGo T5 4.7" e-paper +/// example. It follows the espp + LVGL pattern used by the other BSP examples: +/// +/// * All LVGL objects are created in init_ui(), split into small member +/// functions - one per logical section of the UI. +/// * The class owns a task that calls lv_task_handler(), and a recursive mutex +/// that guards every LVGL call. Public setters lock that mutex so other tasks +/// (the touch callback, a sensor-polling loop, etc.) can call them safely. +/// * Button events are dispatched through a single static trampoline into +/// member handlers. +/// +/// Because e-paper updates are slow and cause a visible flash, the setters cache +/// the last value and only touch LVGL (and therefore the panel) when the value +/// actually changes. +/// +/// The UI shows a live RTC clock and a toggle-able stats panel (battery, last +/// touch, IO48- and home-button states), plus five buttons: toggle the +/// frontlight, rotate the display, show/hide the stats panel, full-refresh +/// (clear ghosting), and power off. The panel update mode follows the stats +/// panel - crisp grayscale (GC16) while it is shown so the small text is +/// legible, fast mono (DU) while hidden for a snappy clock. The layout is a flex +/// column so it re-flows when the display is rotated. +class Gui { +public: + /// Configuration for the Gui + struct Config { + espp::LilyGoT547 &board; ///< The board BSP + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: build the UI and start the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : board_(config.board) + , logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + std::lock_guard lock(mutex_); + if (auto screen = lv_screen_active()) { + lv_obj_clean(screen); + } + } + + /// Set the clock (HH:MM:SS) text. Thread-safe. + void set_time(std::string_view text); + /// Set the date (YYYY-MM-DD Weekday) text. Thread-safe. + void set_date(std::string_view text); + /// Set the battery status text. Thread-safe. + void set_battery(std::string_view text); + /// Set the last-touch text from a touch event. Thread-safe. + void set_touch(int num_points, int x, int y); + /// Set the IO48 button state. Thread-safe. + void set_io48_button(bool pressed); + /// Set the home button state. Thread-safe. + void set_home_button(bool pressed); + + /// Do a full-screen grayscale refresh (clears ghosting), serialized with the + /// LVGL flush so it never races the update task. Thread-safe. + void request_full_refresh(); + +protected: + void init_ui(); + void init_background(); + void init_header(); + void init_info_panel(); + void init_buttons(); + + // create a white e-paper-friendly button with a black border and a label + lv_obj_t *make_button(lv_obj_t *parent, const char *text, lv_obj_t **out_label); + + // update a label only if the text changed (avoids needless e-paper refreshes) + void set_label_if_changed(lv_obj_t *label, std::string &cache, std::string_view text); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // single trampoline for all button events + static void event_callback(lv_event_t *e); + void on_clicked(lv_event_t *e); + + espp::LilyGoT547 &board_; + + // LVGL objects + lv_obj_t *time_label_{nullptr}; + lv_obj_t *date_label_{nullptr}; + lv_obj_t *battery_label_{nullptr}; + lv_obj_t *touch_label_{nullptr}; + lv_obj_t *io48_label_{nullptr}; + lv_obj_t *home_label_{nullptr}; + lv_obj_t *frontlight_button_{nullptr}; + lv_obj_t *frontlight_button_label_{nullptr}; + lv_obj_t *rotate_button_{nullptr}; + lv_obj_t *stats_button_{nullptr}; + lv_obj_t *refresh_button_{nullptr}; + lv_obj_t *off_button_{nullptr}; + + // cached label strings, to skip unchanged updates + std::string time_cache_; + std::string date_cache_; + std::string battery_cache_; + std::string touch_cache_; + std::string io48_cache_; + std::string home_cache_; + + bool frontlight_on_{false}; + // Stats panel visible by default. The update mode follows this: crisp GC16 + // while stats are shown (the small text needs grayscale to be legible), fast + // mono DU while hidden (a snappy clock-only view). + bool stats_shown_{true}; + std::atomic rotate_requested_{false}; // set by the Rotate button, handled in update() + std::atomic stats_toggle_requested_{false}; // set by the Stats button, handled in update() + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 8 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/lilygo-t5-47/example/main/lilygo-t5-47_example.cpp b/components/lilygo-t5-47/example/main/lilygo-t5-47_example.cpp new file mode 100644 index 000000000..a134c524d --- /dev/null +++ b/components/lilygo-t5-47/example/main/lilygo-t5-47_example.cpp @@ -0,0 +1,158 @@ +#include +#include +#include +#include + +#include "gui.hpp" +#include "lilygo-t5-47.hpp" +#include "logger.hpp" + +using namespace std::chrono_literals; + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "LilyGo T5 4.7 Example", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting LilyGo T5 4.7\" e-paper LVGL example"); + + //! [lilygo t5 47 example] + auto &board = espp::LilyGoT547::get(); + if (!board.initialize_display()) { + logger.error("Failed to initialize the e-paper display!"); + return; + } + if (!board.initialize_lvgl()) { + logger.error("Failed to initialize LVGL!"); + return; + } + logger.info("Display is {}x{}", board.width(), board.height()); + + // Keep the panel's high-voltage rails powered on for the demo so each update + // doesn't pay the power-up latency, and start from a clean (ghost-free) panel. + board.power_on(); + board.clear(); + + // Build the interactive GUI. The Gui owns the LVGL update task (which flushes + // to the panel), so all panel access from here on goes through it. + Gui gui({.board = board, .log_level = espp::Logger::Verbosity::WARN}); + + // The BOOT button triggers a full grayscale refresh (handy to clear ghosting). + board.initialize_button([&logger, &board, &gui](const auto &) { + if (board.button_state()) { + logger.info("BOOT button pressed -> full refresh"); + gui.request_full_refresh(); + } + }); + + // Touch drives the on-screen widgets (via the registered LVGL input device); + // the callback also feeds the "Touch" and "Home button" rows. The GT911 + // reports the capacitive home button as t.btn_state. + if (board.initialize_touch([&gui, &logger](const espp::TouchpadData &t) { + logger.info("touch event: points={} ({}, {}) home={}", t.num_touch_points, t.x, t.y, + t.btn_state); + gui.set_touch(t.num_touch_points, t.x, t.y); + gui.set_home_button(t.btn_state); + })) { + logger.info("Touch initialized"); + } else { + logger.warn("Touch not initialized"); + } + + bool have_rtc = board.initialize_rtc(); + bool have_battery = board.initialize_battery(); + bool have_expander = board.initialize_io_expander(); + logger.info("RTC {}, battery {}, I/O expander {}", have_rtc ? "ok" : "n/a", + have_battery ? "ok" : "n/a", have_expander ? "ok" : "n/a"); + + // Mount the microSD card (SPI) and list its root directory (optional). + if (board.initialize_sdcard({})) { + if (DIR *dir = opendir(espp::LilyGoT547::mount_point)) { + logger.info("microSD card contents:"); + while (dirent *entry = readdir(dir)) { + logger.info(" {} {}", entry->d_type == DT_DIR ? "[dir] " : "[file]", entry->d_name); + } + closedir(dir); + } + } else { + logger.warn("No microSD card mounted (this is fine if none is inserted)"); + } + + // Scan the internal I2C bus and log every device that answers. Expected: + // 0x20 (PCA9535 expander), 0x51 (PCF8563 RTC), 0x55 (BQ27220 gauge), and + // 0x5D (GT911 touch). If 0x5D/0x14 is missing, the GT911 isn't coming out of + // reset (check the touch RST/INT pins). + logger.info("Scanning internal I2C bus..."); + for (uint8_t addr = 0x08; addr < 0x78; addr++) { + if (board.internal_i2c()->probe_device(addr)) { + logger.info(" I2C device found at 0x{:02X}", addr); + } + } + + // Render everything cleanly once. + gui.request_full_refresh(); + //! [lilygo t5 47 example] + + // Poll the peripherals and feed the GUI. The clock ticks every second, the + // battery updates every 5 s, and the IO48 button is polled every second. A + // periodic full refresh clears the ghosting that partial updates leave behind. + static const char *const weekdays[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; + auto now = std::chrono::steady_clock::now(); + auto last_clock = now - 1s; + auto last_status = now - 1s; + auto last_battery = now - 5s; + auto last_full = now; + bool logged_rtc = false, logged_pwr = false; + char buf[48]; + while (true) { + now = std::chrono::steady_clock::now(); + + if (have_rtc && now - last_clock >= 1s) { + last_clock = now; + std::error_code ec; + auto dt = board.rtc()->get_date_time(ec); + if (!ec) { + snprintf(buf, sizeof(buf), "%02d:%02d:%02d", dt.time.hour, dt.time.minute, dt.time.second); + gui.set_time(buf); + const char *wd = dt.date.weekday < 7 ? weekdays[dt.date.weekday] : ""; + snprintf(buf, sizeof(buf), "%04d-%02d-%02d %s", dt.date.year, dt.date.month, dt.date.day, + wd); + gui.set_date(buf); + } else if (!logged_rtc) { + logger.error("RTC read failed: {}", ec.message()); + } + logged_rtc = true; + } + + // Poll the IO48 button independently of the RTC path. + if (have_expander && now - last_status >= 1s) { + last_status = now; + bool pressed = board.io48_button_pressed(); + gui.set_io48_button(pressed); + if (!logged_pwr) { + logger.info("IO48 button first read: {}", pressed ? "pressed" : "released"); + logged_pwr = true; + } + } + + if (have_battery && now - last_battery >= 5s) { + last_battery = now; + std::error_code ec; + auto mv = board.battery()->get_voltage_mv(ec); + auto soc = board.battery()->get_state_of_charge(ec); + auto ma = board.battery()->get_current_ma(ec); + if (!ec) { + snprintf(buf, sizeof(buf), "Battery: %.2f V %d%% %d mA", mv / 1000.0f, soc, ma); + gui.set_battery(buf); + } else { + gui.set_battery("Battery: read error"); + } + } + + // Clear accumulated ghosting periodically. The default DU (mono) mode ghosts + // faster than GC16, so refresh a bit more often. + if (now - last_full >= 30s) { + last_full = now; + gui.request_full_refresh(); + } + + std::this_thread::sleep_for(200ms); + } +} diff --git a/components/lilygo-t5-47/example/sdkconfig.defaults b/components/lilygo-t5-47/example/sdkconfig.defaults new file mode 100644 index 000000000..add64659d --- /dev/null +++ b/components/lilygo-t5-47/example/sdkconfig.defaults @@ -0,0 +1,26 @@ +CONFIG_IDF_TARGET="esp32s3" + +# The T5 4.7" has PSRAM (octal); epdiy uses it for the framebuffer. +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="16MB" +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y + +# epdiy wants a 64-byte data cache line; the S3 default is 32B, which forces it +# to halve the pixel clock (20 -> 10 MHz) and slows every refresh. Use 64B. +CONFIG_ESP32S3_DATA_CACHE_LINE_64B=y + +# Larger main task stack for the C++ / drawing code. +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +CONFIG_COMPILER_OPTIMIZATION_PERF=y + +# Extra LVGL fonts for the demo UI (large clock + headings). +CONFIG_LV_FONT_MONTSERRAT_20=y +CONFIG_LV_FONT_MONTSERRAT_28=y +CONFIG_LV_FONT_MONTSERRAT_48=y + +# The extra fonts push the app past the default 1 MB partition; use the larger +# single-app layout (the board has 16 MB of flash). +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y diff --git a/components/lilygo-t5-47/idf_component.yml b/components/lilygo-t5-47/idf_component.yml new file mode 100644 index 000000000..ca3982f26 --- /dev/null +++ b/components/lilygo-t5-47/idf_component.yml @@ -0,0 +1,44 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "LilyGo T5 4.7 inch ESP32-S3 e-paper Board Support Package (BSP) component for ESPP" +url: "https://github.com/esp-cpp/espp/tree/main/components/lilygo-t5-47" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +examples: + - path: example +tags: + - cpp + - Component + - LilyGo + - T5-47 + - e-paper + - epaper + - BSP +dependencies: + idf: + version: ">=5.0" + espp/base_component: ">=1.0" + espp/task: ">=1.0" + espp/interrupt: ">=1.0" + espp/i2c: ">=1.0" + espp/spi: ">=1.0" + espp/gt911: ">=1.0" + espp/bm8563: ">=1.0" + # bq27220 and pca9535 are new espp components that are not published to the + # registry yet; point at the local sources until they are released. + espp/bq27220: + version: "*" + override_path: "../bq27220" + espp/pca9535: + version: "*" + override_path: "../pca9535" + espp/input_drivers: ">=1.0" + lvgl/lvgl: ">=9.2.2" + # epdiy drives the ED047TC1 parallel e-paper panel (waveforms, grayscale, + # partial updates) for this exact board. Fetched from git (the IDF component + # is registered at the repo root). + epdiy: + git: https://github.com/vroland/epdiy.git +targets: + - esp32s3 diff --git a/components/lilygo-t5-47/include/lilygo-t5-47.hpp b/components/lilygo-t5-47/include/lilygo-t5-47.hpp new file mode 100644 index 000000000..123f98727 --- /dev/null +++ b/components/lilygo-t5-47/include/lilygo-t5-47.hpp @@ -0,0 +1,467 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "base_component.hpp" +#include "bm8563.hpp" +#include "bq27220.hpp" +#include "gt911.hpp" +#include "i2c.hpp" +#include "interrupt.hpp" +#include "pca9535.hpp" +#include "spi.hpp" +#include "task.hpp" +#include "touchpad_input.hpp" + +#include "lvgl.h" + +extern "C" { +#include "epdiy.h" +} + +namespace espp { +/// The LilyGoT547 class provides an interface to the LilyGo T5 4.7" ESP32-S3 +/// e-paper development board. +/// +/// The board drives an ED047TC1 960x540 16-level-grayscale e-paper panel over a +/// parallel bus (the ESP32-S3 LCD_CAM peripheral). This BSP wraps Espressif's +/// esp-idf-friendly build of the epdiy library, which owns the parallel timing, +/// waveforms, grayscale rendering and partial-update handling for this exact +/// board. +/// +/// The class provides access to the following features: +/// - 4.7" 960x540 e-paper display (ED047TC1) via epdiy +/// +/// The class is a singleton and can be accessed using the get() method. +/// +/// \section lilygo_t5_47_example Example +/// \snippet lilygo_t5_47_example.cpp lilygo t5 47 example +class LilyGoT547 : public BaseComponent { +public: + /// Native panel width in pixels + static constexpr int panel_width = 960; + /// Native panel height in pixels + static constexpr int panel_height = 540; + + /// @brief Access the singleton instance of the LilyGoT547 class + /// @return Reference to the singleton instance + static LilyGoT547 &get() { + static LilyGoT547 instance; + return instance; + } + + LilyGoT547(const LilyGoT547 &) = delete; + LilyGoT547 &operator=(const LilyGoT547 &) = delete; + LilyGoT547(LilyGoT547 &&) = delete; + LilyGoT547 &operator=(LilyGoT547 &&) = delete; + + /// Initialize the e-paper display: bring up epdiy for this board (parallel + /// bus + waveforms) and allocate the high-level grayscale framebuffer. + /// \return true if the display was successfully initialized + bool initialize_display(); + + /// Power the display's high-voltage rails on. Required before an update; the + /// panel draws significant current, so keep it off when idle. + void power_on(); + + /// Power the display's high-voltage rails off. + void power_off(); + + /// Clear the whole panel to white (a full refresh; removes ghosting). + void clear(); + + /// Get the epdiy high-level framebuffer (4 bits per pixel, 2 pixels per byte, + /// panel_width x panel_height). Draw into it with epdiy's drawing functions, + /// then call update(). + /// \return Pointer to the framebuffer, or nullptr if not initialized + uint8_t *framebuffer(); + + /// Push the current framebuffer contents to the panel. + /// \param mode The epdiy draw mode (default MODE_GC16 for full 16-gray) + /// \note The high-voltage rails must be powered (power_on()) for the update to + /// take effect. + void update(EpdDrawMode mode = MODE_GC16); + + /// Get the current display width (respecting the configured rotation) + /// \return Width in pixels + int width() const { return epd_rotated_display_width(); } + + /// Get the current display height (respecting the configured rotation) + /// \return Height in pixels + int height() const { return epd_rotated_display_height(); } + + /// Get the ambient temperature (degrees C) used for the e-paper waveform. + /// \return Temperature in degrees Celsius + int temperature(); + + ///////////////////////////////////////////////////////////////////////////// + // I2C + ///////////////////////////////////////////////////////////////////////////// + + /// Get the internal I2C bus. This is the bus epdiy uses to drive the e-paper + /// power ICs (PCA9555 + TPS65185); this BSP creates it and hands the handle to + /// epdiy so the same bus is shared with the board's other I2C peripherals + /// (touch, RTC, battery gauge, the qwiic connector). It is created by + /// initialize_display(). + /// \return Pointer to the espp::I2c, or nullptr if initialize_display() has + /// not been called + espp::I2c *internal_i2c() { return internal_i2c_.get(); } + + /// Get the I2C bus exposed on the qwiic / STEMMA-QT connector. On this board + /// the qwiic connector is wired to the internal I2C bus (SDA=39/SCL=40), so + /// this returns the same bus as internal_i2c(). Add external devices to it + /// with internal_i2c()->add_device<...>(...). + /// \return Pointer to the espp::I2c, or nullptr if initialize_display() has + /// not been called + espp::I2c *qwiic_i2c() { return internal_i2c_.get(); } + + ///////////////////////////////////////////////////////////////////////////// + // LVGL + ///////////////////////////////////////////////////////////////////////////// + + /// Set up LVGL with an 8-bit grayscale (L8) display whose flush writes into + /// the epdiy framebuffer. Each LVGL refresh cycle's dirty areas are batched + /// into a single panel update (see set_lvgl_update_mode() / full_refresh()), + /// so many small UI changes become one e-paper refresh instead of one per + /// change. + /// \param buffer_lines Height, in lines, of the LVGL partial draw buffer + /// (double-buffered, allocated in PSRAM). Larger means fewer flushes + /// per refresh at the cost of more memory. + /// \return true on success. initialize_display() must have been called first. + bool initialize_lvgl(int buffer_lines = 60); + + /// Get the LVGL display created by initialize_lvgl(). + /// \return The LVGL display, or nullptr if LVGL has not been initialized + lv_display_t *lvgl_display() const { return lvgl_display_; } + + /// Set the epdiy draw mode used for LVGL-driven panel updates. Default is + /// MODE_GC16 (full 16-level grayscale, highest quality but slowest). Use a + /// faster mono mode (e.g. MODE_DU) for snappier black-and-white updates. + /// \param mode The epdiy draw mode + void set_lvgl_update_mode(EpdDrawMode mode) { lvgl_update_mode_ = mode; } + + /// Force a full grayscale (MODE_GC16) refresh of the whole panel. Use this to + /// clear ghosting that builds up after repeated partial updates. + void full_refresh(); + + /// Set the display rotation. Rotates the e-paper (via epdiy) and resizes the + /// LVGL display to match, so an LVGL UI re-lays-out for the new dimensions. + /// Touch coordinates are transformed to match the rotation automatically. + /// \param rotation The epdiy rotation (EPD_ROT_LANDSCAPE / EPD_ROT_PORTRAIT / + /// EPD_ROT_INVERTED_LANDSCAPE / EPD_ROT_INVERTED_PORTRAIT) + void set_rotation(EpdRotation rotation); + + /// Cycle to the next display rotation. + void rotate(); + + /// Get the current display rotation. + /// \return The current EpdRotation + EpdRotation rotation() const { return rotation_; } + + ///////////////////////////////////////////////////////////////////////////// + // Buttons + ///////////////////////////////////////////////////////////////////////////// + + /// Alias for the button callback (an interrupt event handler) + using button_callback_t = espp::Interrupt::event_callback_fn; + + /// Initialize the BOOT button (GPIO0). The callback fires on press and + /// release. + /// \param callback Called with each button interrupt event + /// \return true on success + /// \note This wires only the BOOT button (GPIO0). The board's other buttons + /// are elsewhere: the "IO48"-labelled button is on the PCA9535 expander + /// (see io48_button_pressed()), and the PWR button is handled by the + /// board's power-management IC (not exposed here). + bool initialize_button(const button_callback_t &callback = nullptr); + + /// Get the BOOT button state + /// \return true if pressed, false otherwise + bool button_state() const; + + /// Get the interrupts manager (created by initialize_button()). + /// \return Pointer to the espp::Interrupt, or nullptr if no button was + /// initialized + espp::Interrupt *interrupts() { return interrupts_.get(); } + + ///////////////////////////////////////////////////////////////////////////// + // Touch (GT911) + ///////////////////////////////////////////////////////////////////////////// + + /// Alias for the touch callback + using touch_callback_t = std::function; + + /// Initialize the GT911 capacitive touch controller on the internal I2C bus, + /// and register an LVGL input device for it. + /// \param callback Called (from the touch interrupt) with each new touch state + /// \return true on success. initialize_display() must have been called first + /// (it creates the shared I2C bus). + /// \note The GT911's reported orientation may not match the panel's; if touch + /// is mirrored/rotated on hardware, adjust touch_swap_xy / touch_invert_x + /// / touch_invert_y. + bool initialize_touch(const touch_callback_t &callback = nullptr); + + /// Get the LVGL touchpad input device created by initialize_touch(). + /// \return The touchpad input, or nullptr if touch was not initialized + std::shared_ptr touchpad_input() const { return touchpad_input_; } + + /// Get the latest touchpad data (thread-safe copy). + /// \return The most recent espp::TouchpadData + espp::TouchpadData touchpad_data() const; + + /// Read the latest touchpad data (signature used by espp::TouchpadInput). + void touchpad_read(uint8_t *num_touch_points, uint16_t *x, uint16_t *y, uint8_t *btn_state); + + /// Get the state of the capacitive home button (the touch key below the + /// display). The GT911 reports it as a key press, separate from finger touch + /// points, so it is available even when num_touch_points is 0. + /// \return true if the home button is currently pressed + /// \note Requires initialize_touch(). Whether the key is active depends on the + /// GT911 configuration flashed on the board; flagged for hardware + /// verification. + bool home_button_pressed() const; + + ///////////////////////////////////////////////////////////////////////////// + // RTC (PCF8563) + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the PCF8563 real-time clock on the internal I2C bus. + /// \return true on success. initialize_display() must have been called first + /// (it creates the shared I2C bus). + /// \note The PCF8563 is register-compatible with the BM8563, so this uses the + /// espp::Bm8563 driver. + bool initialize_rtc(); + + /// Get the RTC driver (created by initialize_rtc()). + /// \return Pointer to the espp::Bm8563, or nullptr if the RTC was not + /// initialized + espp::Bm8563 *rtc() { return rtc_.get(); } + + ///////////////////////////////////////////////////////////////////////////// + // Battery (BQ27220 fuel gauge) + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the BQ27220 battery fuel gauge on the internal I2C bus. + /// \return true on success. initialize_display() must have been called first + /// (it creates the shared I2C bus). + bool initialize_battery(); + + /// Get the battery fuel gauge (created by initialize_battery()). Query it for + /// voltage, current, state-of-charge, temperature, etc. + /// \return Pointer to the espp::Bq27220, or nullptr if not initialized + espp::Bq27220 *battery() { return battery_.get(); } + + ///////////////////////////////////////////////////////////////////////////// + // I/O expander (PCA9535) + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize a driver for the on-board PCA9535 I/O expander on the internal + /// I2C bus. + /// \return true on success. initialize_display() must have been called first. + /// \warning This is the SAME physical expander (address 0x20) that epdiy drives + /// for the e-paper power ICs. epdiy owns port 1's high bits (output + /// enable, mode, power-up, VCOM, wakeup, power-good, INT). The driver + /// is created with auto_init=false so it does not reconfigure the + /// chip; only read inputs / drive port-0 pins epdiy does not use, and + /// always read-modify-write. Reconfiguring epdiy's bits will break the + /// display's power sequencing. + bool initialize_io_expander(); + + /// Get the PCA9535 I/O expander driver (created by initialize_io_expander()). + /// \return Pointer to the espp::Pca9535, or nullptr if not initialized + /// \note See initialize_io_expander()'s warning about epdiy co-ownership. + espp::Pca9535 *io_expander() { return io_expander_.get(); } + + /// Read the "IO48"-labelled button, which is wired to the PCA9535 expander + /// (pin PC12 = port 1, bit 2 - a pin epdiy does not use). This is not the PWR + /// button (that is handled by the board's power-management IC). + /// \return true if the button is currently pressed + /// \note Requires initialize_io_expander(). + bool io48_button_pressed(); + + ///////////////////////////////////////////////////////////////////////////// + // Frontlight + ///////////////////////////////////////////////////////////////////////////// + + /// Turn the e-paper frontlight on or off (BL_EN / GPIO11). The pin is + /// configured as an output on first use. + /// \param on true to enable the frontlight + /// \note This is a simple on/off enable (active-high, flagged for hardware + /// verification). For dimming, drive GPIO11 with LEDC PWM instead. + void set_frontlight(bool on); + + /// Get the current frontlight state. + /// \return true if the frontlight is on + bool frontlight_on() const { return frontlight_on_; } + + ///////////////////////////////////////////////////////////////////////////// + // Power + ///////////////////////////////////////////////////////////////////////////// + + /// Power the board off by putting the BQ25896 PMIC into ship mode (disconnect + /// the battery / BATFET_DIS). The board then draws negligible current and is + /// turned back on by pressing the PWR button. + /// \return true if the ship-mode command was written successfully + /// \note This only powers the board off when running on battery - if USB power + /// is connected the board stays powered. initialize_display() must have + /// been called first (it creates the shared I2C bus the PMIC is on). + bool shutdown(); + + ///////////////////////////////////////////////////////////////////////////// + // microSD Card + ///////////////////////////////////////////////////////////////////////////// + + /// The filesystem mount point for the microSD card + static constexpr char mount_point[] = "/sdcard"; + + /// Configuration for the microSD card + struct SdCardConfig { + bool format_if_mount_failed = false; ///< Format the card if mounting fails + int max_files = 5; ///< Maximum number of open files at once + size_t allocation_unit_size = 2 * 1024; ///< Allocation unit size in bytes + }; + + /// Initialize the microSD card (SPI). Mounts a FAT filesystem at + /// mount_point ("/sdcard"). + /// \param config The microSD card configuration + /// \return true if the card was mounted successfully + /// \note The card is on a dedicated SPI bus (it does not conflict with the + /// e-paper's parallel bus), so this can be called independently of + /// initialize_display(). + bool initialize_sdcard(const SdCardConfig &config); + + /// Get the mounted microSD card. + /// \return Pointer to the sdmmc_card_t, or nullptr if not initialized + sdmmc_card_t *sdcard() const { return sdcard_; } + +private: + LilyGoT547(); + + /// Lazily initialize the shared SPI bus used by the microSD card (and the + /// board's other SPI peripherals). \return true if the bus is initialized. + bool init_spi_bus(); + + /// Lazily create the shared espp::Interrupt manager (used by both the buttons + /// and the touch controller). epdiy's epd_init() must have installed the GPIO + /// ISR service first. \return true if the manager exists. + bool ensure_interrupts(); + + /// Poll the GT911 for new touch data and cache it. \return true if new data. + bool update_gt911(); + + static void lvgl_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map); + void lvgl_flush(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map); + + std::atomic initialized_{false}; + std::atomic powered_on_{false}; + EpdiyHighlevelState hl_{}; + EpdRotation rotation_{EPD_ROT_LANDSCAPE}; + + // Internal I2C bus (shared with epdiy's e-paper power ICs). Pins from the T5 + // 4.7" ePaper S3 PRO factory firmware; this is the same bus/pins epdiy's + // lilygo_board_s3 board definition uses. + static constexpr gpio_num_t internal_i2c_sda = GPIO_NUM_39; + static constexpr gpio_num_t internal_i2c_scl = GPIO_NUM_40; + std::unique_ptr internal_i2c_{nullptr}; + + // LVGL + lv_display_t *lvgl_display_{nullptr}; + uint8_t *lvgl_buffer0_{nullptr}; + uint8_t *lvgl_buffer1_{nullptr}; + EpdDrawMode lvgl_update_mode_{MODE_GC16}; + lv_area_t lvgl_dirty_{}; // accumulated dirty box for the current refresh cycle + bool lvgl_dirty_valid_{false}; + + // Buttons + static constexpr gpio_num_t button_io = GPIO_NUM_0; // BOOT button + std::atomic button_initialized_{false}; + button_callback_t button_callback_{nullptr}; + espp::Interrupt::PinConfig button_interrupt_pin_{ + .gpio_num = button_io, + .callback = + [this](const auto &event) { + if (button_callback_) { + button_callback_(event); + } + }, + .active_level = espp::Interrupt::ActiveLevel::LOW, + .interrupt_type = espp::Interrupt::Type::ANY_EDGE, + .pullup_enabled = true}; + // Created lazily in initialize_button(): the espp::Interrupt constructor + // installs the GPIO ISR service, but epdiy's epd_init() (in + // initialize_display) must install it first, so this cannot be constructed + // eagerly with the singleton. + std::unique_ptr interrupts_{nullptr}; + + // Touch (GT911) on the internal I2C bus. Pins from the T5 4.7" ePaper S3 PRO + // factory firmware. RST is currently unused (the controller comes up at its + // default address); it is kept here for a future explicit-reset sequence. + static constexpr gpio_num_t touch_interrupt_io = GPIO_NUM_3; + static constexpr gpio_num_t touch_reset_io = GPIO_NUM_9; + // Orientation of the GT911 relative to the panel (landscape 960x540). The + // controller reports in the portrait frame, so swap X/Y; an invert may also be + // needed depending on which corner is the origin (hardware-verify). + static constexpr bool touch_swap_xy = true; + static constexpr bool touch_invert_x = false; + static constexpr bool touch_invert_y = true; + std::atomic touch_initialized_{false}; + std::shared_ptr> touch_i2c_device_; + std::unique_ptr gt911_{nullptr}; + std::shared_ptr touchpad_input_{nullptr}; + mutable std::recursive_mutex touchpad_data_mutex_; + espp::TouchpadData touchpad_data_{}; + touch_callback_t touch_callback_{nullptr}; + // The GT911's config on this board is query-mode: it does not reliably signal + // via an edge on INT, so we poll it from a task (matching the vendor firmware, + // which uses LOW_LEVEL_QUERY + isPressed()). + std::unique_ptr touch_task_{nullptr}; + + // RTC (PCF8563) on the internal I2C bus. IRQ (GPIO2) is not wired here yet. + static constexpr gpio_num_t rtc_interrupt_io = GPIO_NUM_2; + std::atomic rtc_initialized_{false}; + std::shared_ptr> rtc_i2c_device_; + std::unique_ptr rtc_{nullptr}; + + // Battery (BQ27220 fuel gauge) on the internal I2C bus. + std::atomic battery_initialized_{false}; + std::shared_ptr> battery_i2c_device_; + std::unique_ptr battery_{nullptr}; + + // I/O expander (PCA9535) on the internal I2C bus — the SAME chip epdiy drives + // (see initialize_io_expander()). The IO48 button is pin PC12 = port 1, bit 2. + static constexpr uint8_t io48_button_port1_mask = 0x04; // PC12 + std::atomic io_expander_initialized_{false}; + std::atomic io48_button_configured_{false}; + std::shared_ptr> io_expander_i2c_device_; + std::unique_ptr io_expander_{nullptr}; + + // Frontlight (BL_EN). Configured as an output on first use. + static constexpr gpio_num_t frontlight_io = GPIO_NUM_11; + std::atomic frontlight_initialized_{false}; + std::atomic frontlight_on_{false}; + + // BQ25896 PMIC (battery charger / power path) on the internal I2C bus. Used to + // enter ship mode (power off). Device handle created lazily by shutdown(). + static constexpr uint8_t pmic_address = 0x6B; + std::shared_ptr> pmic_i2c_device_; + + // microSD card (SPI). Pins from the T5 4.7" ePaper S3 PRO factory firmware. + static constexpr gpio_num_t spi_mosi_io = GPIO_NUM_13; + static constexpr gpio_num_t spi_miso_io = GPIO_NUM_21; + static constexpr gpio_num_t spi_sclk_io = GPIO_NUM_14; + static constexpr gpio_num_t sdcard_cs = GPIO_NUM_12; + static constexpr auto spi_num = SPI2_HOST; + static constexpr int SPI_MAX_TRANSFER_BYTES = 4092; + std::unique_ptr spi_{nullptr}; + sdmmc_card_t *sdcard_{nullptr}; +}; +} // namespace espp diff --git a/components/lilygo-t5-47/src/battery.cpp b/components/lilygo-t5-47/src/battery.cpp new file mode 100644 index 000000000..5ade3388b --- /dev/null +++ b/components/lilygo-t5-47/src/battery.cpp @@ -0,0 +1,39 @@ +#include "lilygo-t5-47.hpp" + +namespace espp { + +bool LilyGoT547::initialize_battery() { + if (battery_initialized_) { + logger_.warn("Battery gauge already initialized"); + return true; + } + if (!internal_i2c_) { + logger_.error("initialize_display() must be called before initialize_battery(): it creates the " + "shared I2C bus"); + return false; + } + logger_.info("Initializing BQ27220 battery fuel gauge on the internal I2C bus"); + + std::error_code ec; + battery_i2c_device_ = internal_i2c_->add_device( + { + .device_address = espp::Bq27220::DEFAULT_ADDRESS, + .scl_speed_hz = 100 * 1000, // match epdiy / the board's 100 kHz bus + .log_level = espp::Logger::Verbosity::WARN, + }, + ec); + if (!battery_i2c_device_) { + logger_.error("Could not add BQ27220 I2C device: {}", ec.message()); + return false; + } + + battery_ = std::make_unique( + espp::Bq27220::Config{.write = espp::make_i2c_addressed_write(battery_i2c_device_), + .read = espp::make_i2c_addressed_read(battery_i2c_device_), + .log_level = espp::Logger::Verbosity::WARN}); + + battery_initialized_ = true; + return true; +} + +} // namespace espp diff --git a/components/lilygo-t5-47/src/buttons.cpp b/components/lilygo-t5-47/src/buttons.cpp new file mode 100644 index 000000000..31fd55dbb --- /dev/null +++ b/components/lilygo-t5-47/src/buttons.cpp @@ -0,0 +1,55 @@ +#include "lilygo-t5-47.hpp" + +namespace espp { + +namespace { +// epdiy's epd_init() installs the GPIO ISR service (and aborts if it is already +// installed), so it must be the one to install it. espp::Interrupt would +// otherwise try to install it again on construction; this subclass lets us set +// its (protected) "already installed" flag so it skips the install and just +// adds handlers to epdiy's service. +struct IsrMarker : public espp::Interrupt { + static void mark_isr_installed() { ISR_SERVICE_INSTALLED = true; } +}; +} // namespace + +bool LilyGoT547::ensure_interrupts() { + if (!initialized_) { + logger_.error("initialize_display() must be called first: epdiy owns the GPIO ISR service"); + return false; + } + if (interrupts_) { + return true; + } + // epd_init() already installed the GPIO ISR service; tell espp::Interrupt so + // it does not try to install it again. + IsrMarker::mark_isr_installed(); + interrupts_ = std::make_unique(espp::Interrupt::Config{ + .interrupts = {}, + .event_queue_size = 20, + .task_config = { + .name = "lilygo-t5-47 interrupts", .stack_size_bytes = 6 * 1024, .priority = 10}}); + return interrupts_ != nullptr; +} + +bool LilyGoT547::initialize_button(const button_callback_t &callback) { + if (!ensure_interrupts()) { + return false; + } + logger_.info("Initializing BOOT button (GPIO{})", static_cast(button_io)); + button_callback_ = callback; + if (!button_initialized_) { + interrupts_->add_interrupt(button_interrupt_pin_); + button_initialized_ = true; + } + return true; +} + +bool LilyGoT547::button_state() const { + if (!button_initialized_ || !interrupts_) { + return false; + } + return interrupts_->is_active(button_interrupt_pin_); +} + +} // namespace espp diff --git a/components/lilygo-t5-47/src/frontlight.cpp b/components/lilygo-t5-47/src/frontlight.cpp new file mode 100644 index 000000000..d27e46d88 --- /dev/null +++ b/components/lilygo-t5-47/src/frontlight.cpp @@ -0,0 +1,22 @@ +#include "lilygo-t5-47.hpp" + +namespace espp { + +void LilyGoT547::set_frontlight(bool on) { + if (!frontlight_initialized_) { + gpio_config_t cfg = {}; + cfg.pin_bit_mask = 1ULL << static_cast(frontlight_io); + cfg.mode = GPIO_MODE_OUTPUT; + cfg.pull_up_en = GPIO_PULLUP_DISABLE; + cfg.pull_down_en = GPIO_PULLDOWN_DISABLE; + cfg.intr_type = GPIO_INTR_DISABLE; + gpio_config(&cfg); + frontlight_initialized_ = true; + } + // BL_EN is assumed active-high (flagged for hardware verification). + gpio_set_level(frontlight_io, on ? 1 : 0); + frontlight_on_ = on; + logger_.debug("Frontlight {}", on ? "on" : "off"); +} + +} // namespace espp diff --git a/components/lilygo-t5-47/src/io_expander.cpp b/components/lilygo-t5-47/src/io_expander.cpp new file mode 100644 index 000000000..76c2ffb29 --- /dev/null +++ b/components/lilygo-t5-47/src/io_expander.cpp @@ -0,0 +1,73 @@ +#include "lilygo-t5-47.hpp" + +namespace espp { + +bool LilyGoT547::initialize_io_expander() { + if (io_expander_initialized_) { + logger_.warn("I/O expander already initialized"); + return true; + } + if (!internal_i2c_) { + logger_.error("initialize_display() must be called before initialize_io_expander(): it creates " + "the shared I2C bus"); + return false; + } + logger_.info("Initializing PCA9535 I/O expander on the internal I2C bus (shared with epdiy)"); + + std::error_code ec; + io_expander_i2c_device_ = internal_i2c_->add_device( + { + .device_address = espp::Pca9535::DEFAULT_ADDRESS, + .scl_speed_hz = 100 * 1000, // match epdiy / the board's 100 kHz bus + .log_level = espp::Logger::Verbosity::WARN, + }, + ec); + if (!io_expander_i2c_device_) { + logger_.error("Could not add PCA9535 I2C device: {}", ec.message()); + return false; + } + + // auto_init=false: epdiy already configured this chip for e-paper power + // sequencing (it owns port 1's high bits). Do NOT reconfigure it on + // construction; only touch pins epdiy does not use, via read-modify-write. + io_expander_ = std::make_unique(espp::Pca9535::Config{ + .write = espp::make_i2c_addressed_write(io_expander_i2c_device_), + .read_register = espp::make_i2c_addressed_read_register(io_expander_i2c_device_), + .auto_init = false, + .log_level = espp::Logger::Verbosity::WARN}); + + io_expander_initialized_ = true; + return true; +} + +bool LilyGoT547::io48_button_pressed() { + if (!io_expander_) { + logger_.error("initialize_io_expander() must be called before io48_button_pressed()"); + return false; + } + std::error_code ec; + // Ensure PC12 (port 1, bit 2) is an input, without disturbing the port-1 bits + // epdiy drives. Read the current direction and only add our bit. + if (!io48_button_configured_) { + uint8_t dir = io_expander_->get_direction(espp::Pca9535::Port::PORT1, ec); + if (ec) { + logger_.error("Could not read PCA9535 port-1 direction: {}", ec.message()); + return false; + } + io_expander_->set_direction(espp::Pca9535::Port::PORT1, dir | io48_button_port1_mask, ec); + if (ec) { + logger_.error("Could not set PCA9535 IO48-button pin as input: {}", ec.message()); + return false; + } + io48_button_configured_ = true; + } + uint8_t value = io_expander_->get_pins(espp::Pca9535::Port::PORT1, ec); + if (ec) { + logger_.error("Could not read PCA9535 port 1: {}", ec.message()); + return false; + } + // The IO48 button is active-low. + return !(value & io48_button_port1_mask); +} + +} // namespace espp diff --git a/components/lilygo-t5-47/src/lilygo-t5-47.cpp b/components/lilygo-t5-47/src/lilygo-t5-47.cpp new file mode 100644 index 000000000..af43556ea --- /dev/null +++ b/components/lilygo-t5-47/src/lilygo-t5-47.cpp @@ -0,0 +1,103 @@ +#include "lilygo-t5-47.hpp" + +namespace espp { + +LilyGoT547::LilyGoT547() + : BaseComponent("LilyGoT547", espp::Logger::Verbosity::INFO) {} + +bool LilyGoT547::initialize_display() { + if (initialized_) { + logger_.warn("Display already initialized"); + return true; + } + logger_.info("Initializing ED047TC1 e-paper via epdiy"); + + // Create the internal I2C bus ourselves and hand epdiy the handle, so the same + // bus is shared with the board's other I2C peripherals (touch, RTC, battery + // gauge, qwiic). epdiy's lilygo_board_s3 uses this bus (SDA=39/SCL=40, port 0, + // 100 kHz) for the e-paper power ICs (PCA9555 + TPS65185); passing our handle + // makes epdiy add its device handles to our bus instead of creating its own + // (it sets owns_bus=false and leaves the bus alive for us). + internal_i2c_ = std::make_unique(espp::I2c::Config{ + .port = I2C_NUM_0, + .sda_io_num = internal_i2c_sda, + .scl_io_num = internal_i2c_scl, + .sda_pullup_en = GPIO_PULLUP_ENABLE, + .scl_pullup_en = GPIO_PULLUP_ENABLE, + .clk_speed = 100 * 1000, // the board (and epdiy) run this bus at 100 kHz + .log_level = espp::Logger::Verbosity::WARN, + }); + EpdI2cConfig epd_i2c_config = {.bus_handle = internal_i2c_->native_bus_handle()}; + EpdInitConfig epd_init_config = {.i2c = &epd_i2c_config}; + + // Bring up the parallel bus + waveform LUTs for this exact board/panel. Use + // the ESP32-S3 board definition (lilygo_board_s3), which drives the panel via + // the LCD_CAM peripheral. The original epd_board_lilygo_t5_47 is the ESP32 + // (I2S-parallel) board and pulls in the legacy I2S driver removed in IDF 6. + epd_init_with_config(&lilygo_board_s3, &ED047TC1, EPD_LUT_64K, &epd_init_config); + // Allocate the high-level framebuffer + use the built-in waveform. + hl_ = epd_hl_init(EPD_BUILTIN_WAVEFORM); + // Default to landscape (960 wide x 540 tall). + epd_set_rotation(EPD_ROT_LANDSCAPE); + + logger_.info("Display initialized: {}x{}", width(), height()); + initialized_ = true; + return true; +} + +void LilyGoT547::power_on() { + if (!powered_on_.exchange(true)) { + epd_poweron(); + } +} + +void LilyGoT547::power_off() { + if (powered_on_.exchange(false)) { + epd_poweroff(); + } +} + +void LilyGoT547::clear() { + if (!initialized_) { + return; + } + bool was_off = !powered_on_; + if (was_off) { + power_on(); + } + epd_fullclear(&hl_, temperature()); + if (was_off) { + power_off(); + } +} + +uint8_t *LilyGoT547::framebuffer() { + if (!initialized_) { + return nullptr; + } + return epd_hl_get_framebuffer(&hl_); +} + +void LilyGoT547::update(EpdDrawMode mode) { + if (!initialized_) { + return; + } + bool was_off = !powered_on_; + if (was_off) { + power_on(); + } + epd_hl_update_screen(&hl_, mode, temperature()); + if (was_off) { + power_off(); + } +} + +int LilyGoT547::temperature() { + // The T5 4.7" has no dedicated temperature sensor wired for epdiy on all + // revisions; use a reasonable room-temperature default. e-paper waveforms are + // temperature-dependent, so if updates look wrong at temperature extremes this + // is the value to feed from an external sensor. + return 25; +} + +} // namespace espp diff --git a/components/lilygo-t5-47/src/lvgl.cpp b/components/lilygo-t5-47/src/lvgl.cpp new file mode 100644 index 000000000..53dfe9c65 --- /dev/null +++ b/components/lilygo-t5-47/src/lvgl.cpp @@ -0,0 +1,136 @@ +#include + +#include +#include +#include + +#include "lilygo-t5-47.hpp" + +namespace espp { + +// LVGL tick source: elapsed milliseconds. +static uint32_t lvgl_tick_ms() { + return static_cast(xTaskGetTickCount()) * portTICK_PERIOD_MS; +} + +bool LilyGoT547::initialize_lvgl(int buffer_lines) { + if (!initialized_) { + logger_.error("initialize_display() must be called before initialize_lvgl()"); + return false; + } + if (lvgl_display_) { + logger_.warn("LVGL already initialized"); + return true; + } + + lv_init(); + lv_tick_set_cb(lvgl_tick_ms); + + const int w = width(); + const int h = height(); + if (buffer_lines <= 0 || buffer_lines > h) { + buffer_lines = h; + } + // L8 = 1 byte per pixel. Two partial draw buffers in PSRAM. + const size_t buffer_bytes = static_cast(w) * buffer_lines; + lvgl_buffer0_ = + static_cast(heap_caps_malloc(buffer_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + lvgl_buffer1_ = + static_cast(heap_caps_malloc(buffer_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (lvgl_buffer0_ == nullptr || lvgl_buffer1_ == nullptr) { + logger_.error("Could not allocate LVGL draw buffers ({} bytes each)", buffer_bytes); + return false; + } + + lvgl_display_ = lv_display_create(w, h); + lv_display_set_color_format(lvgl_display_, LV_COLOR_FORMAT_L8); + lv_display_set_buffers(lvgl_display_, lvgl_buffer0_, lvgl_buffer1_, buffer_bytes, + LV_DISPLAY_RENDER_MODE_PARTIAL); + lv_display_set_user_data(lvgl_display_, this); + lv_display_set_flush_cb(lvgl_display_, &LilyGoT547::lvgl_flush_cb); + lv_display_set_default(lvgl_display_); + + logger_.info("LVGL initialized ({}x{}, L8 grayscale, {}-line buffers)", w, h, buffer_lines); + return true; +} + +void LilyGoT547::lvgl_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) { + auto *self = static_cast(lv_display_get_user_data(disp)); + self->lvgl_flush(disp, area, px_map); +} + +void LilyGoT547::lvgl_flush(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) { + uint8_t *fb = epd_hl_get_framebuffer(&hl_); + const int area_w = area->x2 - area->x1 + 1; + // px_map is L8 (1 byte/pixel, 8-bit gray, 0=black..255=white). Write it into + // the epdiy framebuffer; epd_draw_pixel is rotation-aware and packs the 8-bit + // value into the panel's 4-bit gray nibble. + for (int y = area->y1; y <= area->y2; ++y) { + const uint8_t *row = px_map + static_cast(y - area->y1) * area_w; + for (int x = area->x1; x <= area->x2; ++x) { + epd_draw_pixel(x, y, row[x - area->x1], fb); + } + } + + // Accumulate the dirty bounding box across this LVGL refresh cycle so we push + // one panel update rather than one per flushed area. + if (!lvgl_dirty_valid_) { + lvgl_dirty_ = *area; + lvgl_dirty_valid_ = true; + } else { + lvgl_dirty_.x1 = std::min(lvgl_dirty_.x1, area->x1); + lvgl_dirty_.y1 = std::min(lvgl_dirty_.y1, area->y1); + lvgl_dirty_.x2 = std::max(lvgl_dirty_.x2, area->x2); + lvgl_dirty_.y2 = std::max(lvgl_dirty_.y2, area->y2); + } + + if (lv_display_flush_is_last(disp)) { + EpdRect rect = {.x = lvgl_dirty_.x1, + .y = lvgl_dirty_.y1, + .width = lvgl_dirty_.x2 - lvgl_dirty_.x1 + 1, + .height = lvgl_dirty_.y2 - lvgl_dirty_.y1 + 1}; + logger_.debug("panel update rect: {}x{} at ({},{})", rect.width, rect.height, rect.x, rect.y); + bool was_off = !powered_on_; + if (was_off) { + power_on(); + } + epd_hl_update_area(&hl_, lvgl_update_mode_, temperature(), rect); + if (was_off) { + power_off(); + } + lvgl_dirty_valid_ = false; + } + + lv_display_flush_ready(disp); +} + +void LilyGoT547::full_refresh() { + if (!initialized_) { + return; + } + bool was_off = !powered_on_; + if (was_off) { + power_on(); + } + epd_hl_update_screen(&hl_, MODE_GC16, temperature()); + if (was_off) { + power_off(); + } +} + +void LilyGoT547::set_rotation(EpdRotation rotation) { + epd_set_rotation(rotation); + rotation_ = rotation; + if (lvgl_display_) { + // Resize the LVGL display to the rotated dimensions so the UI re-lays-out. + lv_display_set_resolution(lvgl_display_, epd_rotated_display_width(), + epd_rotated_display_height()); + } + logger_.info("Rotation set to {} ({}x{})", static_cast(rotation), width(), height()); +} + +void LilyGoT547::rotate() { + set_rotation(static_cast((static_cast(rotation_) + 1) % 4)); +} + +} // namespace espp diff --git a/components/lilygo-t5-47/src/power.cpp b/components/lilygo-t5-47/src/power.cpp new file mode 100644 index 000000000..f2e0ef109 --- /dev/null +++ b/components/lilygo-t5-47/src/power.cpp @@ -0,0 +1,46 @@ +#include "lilygo-t5-47.hpp" + +namespace espp { + +bool LilyGoT547::shutdown() { + if (!internal_i2c_) { + logger_.error("initialize_display() must be called before shutdown(): the PMIC is on the " + "shared I2C bus"); + return false; + } + + std::error_code ec; + if (!pmic_i2c_device_) { + pmic_i2c_device_ = internal_i2c_->add_device( + { + .device_address = pmic_address, + .scl_speed_hz = 100 * 1000, + .log_level = espp::Logger::Verbosity::WARN, + }, + ec); + if (!pmic_i2c_device_) { + logger_.error("Could not add BQ25896 PMIC I2C device: {}", ec.message()); + return false; + } + } + + // BQ25896 REG09, bit 5 = BATFET_DIS: force the battery FET off (ship mode). + // Bit 3 (BATFET_DLY) defaults to 0 = turn off immediately. Read-modify-write + // so the other bits are preserved. + static constexpr uint8_t REG09 = 0x09; + static constexpr uint8_t BATFET_DIS = 1 << 5; + uint8_t reg09 = 0; + if (!pmic_i2c_device_->read_register(REG09, ®09, 1, ec)) { + logger_.error("Could not read BQ25896 REG09: {}", ec.message()); + return false; + } + const uint8_t buf[2] = {REG09, static_cast(reg09 | BATFET_DIS)}; + logger_.info("Entering ship mode (BQ25896 BATFET off) - press PWR to power back on"); + if (!pmic_i2c_device_->write(buf, sizeof(buf), ec)) { + logger_.error("Could not write BQ25896 REG09: {}", ec.message()); + return false; + } + return true; +} + +} // namespace espp diff --git a/components/lilygo-t5-47/src/rtc.cpp b/components/lilygo-t5-47/src/rtc.cpp new file mode 100644 index 000000000..54e31e34e --- /dev/null +++ b/components/lilygo-t5-47/src/rtc.cpp @@ -0,0 +1,41 @@ +#include "lilygo-t5-47.hpp" + +namespace espp { + +bool LilyGoT547::initialize_rtc() { + if (rtc_initialized_) { + logger_.warn("RTC already initialized"); + return true; + } + if (!internal_i2c_) { + logger_.error("initialize_display() must be called before initialize_rtc(): it creates the " + "shared I2C bus"); + return false; + } + logger_.info("Initializing PCF8563 RTC on the internal I2C bus"); + + std::error_code ec; + rtc_i2c_device_ = internal_i2c_->add_device( + { + .device_address = espp::Bm8563::DEFAULT_ADDRESS, + .scl_speed_hz = 100 * 1000, // match epdiy / the board's 100 kHz bus + .log_level = espp::Logger::Verbosity::WARN, + }, + ec); + if (!rtc_i2c_device_) { + logger_.error("Could not add RTC I2C device: {}", ec.message()); + return false; + } + + // The PCF8563 shares the BM8563's register map, so the espp::Bm8563 driver + // drives it directly. + rtc_ = std::make_unique(espp::Bm8563::Config{ + .write = espp::make_i2c_addressed_write(rtc_i2c_device_), + .write_then_read = espp::make_i2c_addressed_write_then_read(rtc_i2c_device_), + .log_level = espp::Logger::Verbosity::WARN}); + + rtc_initialized_ = true; + return true; +} + +} // namespace espp diff --git a/components/lilygo-t5-47/src/sdcard.cpp b/components/lilygo-t5-47/src/sdcard.cpp new file mode 100644 index 000000000..431c35a07 --- /dev/null +++ b/components/lilygo-t5-47/src/sdcard.cpp @@ -0,0 +1,86 @@ +#include "lilygo-t5-47.hpp" + +#include + +using namespace espp; + +///////////////////////////////////////////////////////////////////////////// +// SPI bus (shared by the microSD card and the board's other SPI peripherals) +///////////////////////////////////////////////////////////////////////////// + +bool LilyGoT547::init_spi_bus() { + if (spi_) { + return spi_->initialized(); + } + logger_.info("Initializing SPI bus (host {}, sclk={}, mosi={}, miso={})", + static_cast(spi_num), static_cast(spi_sclk_io), + static_cast(spi_mosi_io), static_cast(spi_miso_io)); + spi_ = std::make_unique(Spi::Config{ + .host = spi_num, + .sclk_io_num = spi_sclk_io, + .mosi_io_num = spi_mosi_io, + .miso_io_num = spi_miso_io, + .max_transfer_sz = SPI_MAX_TRANSFER_BYTES, + .dma_channel = SPI_DMA_CH_AUTO, + .log_level = get_log_level(), + }); + if (!spi_->initialized()) { + logger_.error("Failed to initialize SPI bus"); + spi_.reset(); + return false; + } + return true; +} + +///////////////////////////////////////////////////////////////////////////// +// microSD Card +///////////////////////////////////////////////////////////////////////////// + +bool LilyGoT547::initialize_sdcard(const LilyGoT547::SdCardConfig &config) { + if (sdcard_) { + logger_.error("microSD card already initialized!"); + return false; + } + + // The microSD card shares the board's SPI bus; make sure it is up. + if (!init_spi_bus()) { + logger_.error("Failed to initialize SPI bus for the microSD card"); + return false; + } + + logger_.info("Initializing microSD card (CS={})", static_cast(sdcard_cs)); + + esp_vfs_fat_sdmmc_mount_config_t mount_config; + memset(&mount_config, 0, sizeof(mount_config)); + mount_config.format_if_mount_failed = config.format_if_mount_failed; + mount_config.max_files = config.max_files; + mount_config.allocation_unit_size = config.allocation_unit_size; + + // The SPI bus is already initialized (init_spi_bus above), so use the host on + // our spi_num and only attach the card's chip-select on this slot. + sdmmc_host_t host = SDSPI_HOST_DEFAULT(); + host.slot = spi_num; + + sdspi_device_config_t slot_config = SDSPI_DEVICE_CONFIG_DEFAULT(); + slot_config.gpio_cs = sdcard_cs; + slot_config.host_id = static_cast(host.slot); + + logger_.debug("Mounting filesystem at {}", mount_point); + esp_err_t ret = + esp_vfs_fat_sdspi_mount(mount_point, &host, &slot_config, &mount_config, &sdcard_); + if (ret != ESP_OK) { + if (ret == ESP_FAIL) { + logger_.error("Failed to mount filesystem (set format_if_mount_failed to format the card)"); + } else { + logger_.error("Failed to initialize the microSD card ({}). Make sure the card is inserted " + "and the lines have pull-ups.", + esp_err_to_name(ret)); + } + sdcard_ = nullptr; + return false; + } + + logger_.info("microSD card mounted at {}", mount_point); + sdmmc_card_print_info(stdout, sdcard_); + return true; +} diff --git a/components/lilygo-t5-47/src/touch.cpp b/components/lilygo-t5-47/src/touch.cpp new file mode 100644 index 000000000..dfb559b41 --- /dev/null +++ b/components/lilygo-t5-47/src/touch.cpp @@ -0,0 +1,188 @@ +#include "lilygo-t5-47.hpp" + +#include +#include +#include +#include + +#include + +using namespace std::chrono_literals; + +namespace espp { + +namespace { +// Reset the GT911 and strap it to the low I2C address (0x5D): hold RST low with +// INT low, then release RST while INT is low (INT is sampled on the RST rising +// edge; low -> 0x5D, high -> 0x14). Afterwards INT becomes the controller's +// data-ready output, which we leave as an input (we poll over I2C). +void gt911_reset(gpio_num_t rst_io, gpio_num_t int_io) { + gpio_config_t out_cfg = {}; + out_cfg.pin_bit_mask = (1ULL << rst_io) | (1ULL << int_io); + out_cfg.mode = GPIO_MODE_OUTPUT; + gpio_config(&out_cfg); + + gpio_set_level(rst_io, 0); + gpio_set_level(int_io, 0); + std::this_thread::sleep_for(20ms); // hold in reset + gpio_set_level(int_io, 0); // INT low -> address 0x5D + std::this_thread::sleep_for(1ms); + gpio_set_level(rst_io, 1); // release reset; INT sampled low -> 0x5D + // Keep INT low (as an output) while the controller latches its address and + // boots. Releasing it too early leaves the GT911 half-initialised, where it + // ACKs its address but NACKs register reads and can wedge the shared bus. + std::this_thread::sleep_for(50ms); + + gpio_config_t in_cfg = {}; + in_cfg.pin_bit_mask = (1ULL << int_io); + in_cfg.mode = GPIO_MODE_INPUT; + in_cfg.pull_up_en = GPIO_PULLUP_ENABLE; + gpio_config(&in_cfg); + std::this_thread::sleep_for(100ms); // let the controller finish booting +} +} // namespace + +bool LilyGoT547::initialize_touch(const LilyGoT547::touch_callback_t &callback) { + if (touch_initialized_) { + logger_.warn("Touch already initialized"); + return true; + } + if (!internal_i2c_) { + logger_.error("initialize_display() must be called before initialize_touch(): it creates the " + "shared I2C bus"); + return false; + } + logger_.info("Initializing GT911 touch on the internal I2C bus (RST={}, INT={})", + static_cast(touch_reset_io), static_cast(touch_interrupt_io)); + + // Bring the controller out of reset and strap it to address 0x5D. + gt911_reset(touch_reset_io, touch_interrupt_io); + + std::error_code ec; + touch_i2c_device_ = internal_i2c_->add_device( + { + .device_address = espp::Gt911::DEFAULT_ADDRESS_1, + .scl_speed_hz = 100 * 1000, // match epdiy / the board's 100 kHz bus + .log_level = espp::Logger::Verbosity::WARN, + }, + ec); + if (!touch_i2c_device_) { + logger_.error("Could not add GT911 I2C device: {}", ec.message()); + return false; + } + + gt911_ = std::make_unique( + espp::Gt911::Config{.write = espp::make_i2c_addressed_write(touch_i2c_device_), + .read = espp::make_i2c_addressed_read(touch_i2c_device_), + .log_level = espp::Logger::Verbosity::WARN}); + + touch_callback_ = callback; + + // Register the LVGL input device backed by our cached touch data. The + // orientation (swap/invert) and display-rotation transform are applied in + // touchpad_read(), so the input device itself does no swapping/inverting. + touchpad_input_ = std::make_shared(espp::TouchpadInput::Config{ + .touchpad_read = + std::bind(&LilyGoT547::touchpad_read, this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4), + .swap_xy = false, + .invert_x = false, + .invert_y = false, + .log_level = espp::Logger::Verbosity::WARN}); + + // Poll the GT911 for new data. The controller's config is query-mode (it does + // not reliably signal via an edge on INT), so polling is the right approach + // here - it matches the vendor firmware's isPressed()/getPoint() loop. + touch_task_ = std::make_unique(espp::Task::Config{ + .callback = [this](std::mutex &m, std::condition_variable &cv) -> bool { + if (update_gt911() && touch_callback_) { + touch_callback_(touchpad_data()); + } + std::unique_lock lock(m); + cv.wait_for(lock, 15ms); + return false; // keep running + }, + .task_config = {.name = "gt911 poll", .stack_size_bytes = 4 * 1024, .priority = 6}}); + touch_task_->start(); + + touch_initialized_ = true; + return true; +} + +bool LilyGoT547::update_gt911() { + if (!gt911_) { + return false; + } + std::error_code ec; + bool new_data = gt911_->update(ec); + if (ec) { + logger_.error("Could not update GT911: {}", ec.message()); + std::lock_guard lock(touchpad_data_mutex_); + touchpad_data_ = {}; + return false; + } + if (!new_data) { + return false; + } + espp::TouchpadData data; + gt911_->get_touch_point(&data.num_touch_points, &data.x, &data.y); + data.btn_state = gt911_->get_home_button_state(); + std::lock_guard lock(touchpad_data_mutex_); + touchpad_data_ = data; + return true; +} + +espp::TouchpadData LilyGoT547::touchpad_data() const { + std::lock_guard lock(touchpad_data_mutex_); + return touchpad_data_; +} + +void LilyGoT547::touchpad_read(uint8_t *num_touch_points, uint16_t *x, uint16_t *y, + uint8_t *btn_state) { + std::lock_guard lock(touchpad_data_mutex_); + *num_touch_points = touchpad_data_.num_touch_points; + *btn_state = touchpad_data_.btn_state; + + // 1) Apply the panel's fixed orientation (swap/invert) to get the landscape + // coordinates, which for EPD_ROT_LANDSCAPE are also the physical coords. + int lx = touchpad_data_.x; + int ly = touchpad_data_.y; + if (touch_swap_xy) { + std::swap(lx, ly); + } + if (touch_invert_x) { + lx = (panel_width - 1) - lx; + } + if (touch_invert_y) { + ly = (panel_height - 1) - ly; + } + + // 2) Map those physical coordinates into the current rotation's logical frame. + // This is the inverse of epdiy's coordinate rotation (see epd_draw_pixel), + // so touches line up with what LVGL draws at any rotation. + switch (rotation_) { + case EPD_ROT_LANDSCAPE: + *x = lx; + *y = ly; + break; + case EPD_ROT_PORTRAIT: + *x = ly; + *y = (panel_width - 1) - lx; + break; + case EPD_ROT_INVERTED_LANDSCAPE: + *x = (panel_width - 1) - lx; + *y = (panel_height - 1) - ly; + break; + case EPD_ROT_INVERTED_PORTRAIT: + *x = (panel_height - 1) - ly; + *y = lx; + break; + } +} + +bool LilyGoT547::home_button_pressed() const { + std::lock_guard lock(touchpad_data_mutex_); + return touchpad_data_.btn_state != 0; +} + +} // namespace espp diff --git a/components/pca9535/CMakeLists.txt b/components/pca9535/CMakeLists.txt new file mode 100644 index 000000000..d43ade275 --- /dev/null +++ b/components/pca9535/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + INCLUDE_DIRS "include" + REQUIRES "base_peripheral" + ) diff --git a/components/pca9535/README.md b/components/pca9535/README.md new file mode 100644 index 000000000..dfa0a6ae2 --- /dev/null +++ b/components/pca9535/README.md @@ -0,0 +1,14 @@ +# PCA9535 / PCA9555 I/O Expander + +[![Badge](https://components.espressif.com/components/espp/pca9535/badge.svg)](https://components.espressif.com/components/espp/pca9535) + +The `PCA9535` / `PCA9555` I/O expander component allows the user to configure +inputs, outputs, input polarity inversion, etc. on a 16-bit (two 8-bit port) +GPIO expander via the I2C interface. The PCA9535 and PCA9555 share an identical +register map; the PCA9535 additionally provides an interrupt output, so a single +`espp::Pca9535` driver covers both parts. + +## Example + +The [example](./example) shows how to communicate with a PCA9535 / PCA9555 I2C +digital I/O expander using the `espp::Pca9535` component. diff --git a/components/pca9535/example/CMakeLists.txt b/components/pca9535/example/CMakeLists.txt new file mode 100644 index 000000000..460a49132 --- /dev/null +++ b/components/pca9535/example/CMakeLists.txt @@ -0,0 +1,22 @@ +# 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) + +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 i2c task pca9535" + CACHE STRING + "List of components to include" + ) + +project(pca9535_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/pca9535/example/README.md b/components/pca9535/example/README.md new file mode 100644 index 000000000..fb3e9aaee --- /dev/null +++ b/components/pca9535/example/README.md @@ -0,0 +1,33 @@ +# PCA9535 Example + +This example shows how to use the `espp::Pca9535` driver to talk to a PCA9535 / +PCA9555 16-bit I2C GPIO expander: +* configure pin directions per port +* read the input pins +* drive the output pins + +## How to use example + +### Hardware Required + +This example requires a connection (via I2C) to a board with a PCA9535 (or the +register-identical PCA9555) I/O expander. Configure the I2C pins (SDA/SCL) via +`menuconfig` for your board. + +### Build and Flash + +Build the project and flash it to the board, then run the monitor tool to view +serial output: + +``` +idf.py -p PORT flash monitor +``` + +(Replace PORT with the name of the serial port to use.) + +(To exit the serial monitor, type ``Ctrl-]``.) + +## Example Output + +The example configures the expander's ports, then logs the input pin state and +toggles an output in a loop. diff --git a/components/pca9535/example/main/CMakeLists.txt b/components/pca9535/example/main/CMakeLists.txt new file mode 100644 index 000000000..a941e22ba --- /dev/null +++ b/components/pca9535/example/main/CMakeLists.txt @@ -0,0 +1,2 @@ +idf_component_register(SRC_DIRS "." + INCLUDE_DIRS ".") diff --git a/components/pca9535/example/main/Kconfig.projbuild b/components/pca9535/example/main/Kconfig.projbuild new file mode 100644 index 000000000..926929e92 --- /dev/null +++ b/components/pca9535/example/main/Kconfig.projbuild @@ -0,0 +1,39 @@ +menu "Example Configuration" + + choice EXAMPLE_HARDWARE + prompt "Hardware" + default EXAMPLE_HARDWARE_QTPYS3 + help + Select the hardware to run this example on. + + config EXAMPLE_HARDWARE_QTPYPICO + depends on IDF_TARGET_ESP32 + bool "Qt Py PICO" + + config EXAMPLE_HARDWARE_QTPYS3 + depends on IDF_TARGET_ESP32S3 + bool "Qt Py S3" + + config EXAMPLE_HARDWARE_CUSTOM + bool "Custom" + endchoice + + config EXAMPLE_I2C_SCL_GPIO + int "SCL GPIO Num" + range 0 50 + default 19 if EXAMPLE_HARDWARE_QTPYPICO + default 40 if EXAMPLE_HARDWARE_QTPYS3 + default 19 if EXAMPLE_HARDWARE_CUSTOM + help + GPIO number for I2C Master clock line. + + config EXAMPLE_I2C_SDA_GPIO + int "SDA GPIO Num" + range 0 50 + default 22 if EXAMPLE_HARDWARE_QTPYPICO + default 41 if EXAMPLE_HARDWARE_QTPYS3 + default 22 if EXAMPLE_HARDWARE_CUSTOM + help + GPIO number for I2C Master data line. + +endmenu diff --git a/components/pca9535/example/main/pca9535_example.cpp b/components/pca9535/example/main/pca9535_example.cpp new file mode 100644 index 000000000..0d5d850ef --- /dev/null +++ b/components/pca9535/example/main/pca9535_example.cpp @@ -0,0 +1,85 @@ +#include +#include +#include + +#include "i2c.hpp" +#include "pca9535.hpp" +#include "task.hpp" + +using namespace std::chrono_literals; + +extern "C" void app_main(void) { + { + fmt::print("Starting pca9535 example!\n"); + //! [pca9535 example] + // make the I2C that we'll use to communicate + espp::I2c i2c({ + .port = I2C_NUM_0, + .sda_io_num = (gpio_num_t)CONFIG_EXAMPLE_I2C_SDA_GPIO, + .scl_io_num = (gpio_num_t)CONFIG_EXAMPLE_I2C_SCL_GPIO, + }); + std::error_code ec; + auto pca9535_device = + i2c.add_device({.device_address = espp::Pca9535::DEFAULT_ADDRESS, + .timeout_ms = static_cast(i2c.config().timeout_ms), + .scl_speed_hz = i2c.config().clk_speed, + .log_level = espp::Logger::Verbosity::WARN}, + ec); + if (!pca9535_device) { + fmt::print("PCA9535 I2C device initialization failed: {}\n", ec.message()); + return; + } + // now make the pca9535 which handles GPIO. Port 0 is all inputs, and Port 1 + // is all outputs. + espp::Pca9535 pca9535({.port_0_direction_mask = 0xFF, // all inputs on port 0 + .port_1_direction_mask = 0x00, // all outputs on port 1 + .write = espp::make_i2c_addressed_write(pca9535_device), + .read_register = espp::make_i2c_addressed_read_register(pca9535_device), + .log_level = espp::Logger::Verbosity::WARN}); + // and finally, make the task to periodically poll the pca9535 and print the + // state. NOTE: the Pca9535 does not internally manage its own state update, + // so whatever rate we use here is the rate at which the state will update. + auto task_fn = [&pca9535](std::mutex &m, std::condition_variable &cv) { + static auto start = std::chrono::high_resolution_clock::now(); + static uint8_t output = 0; + auto now = std::chrono::high_resolution_clock::now(); + auto seconds = std::chrono::duration(now - start).count(); + std::error_code ec; + // read the inputs on port 0 + auto p0_pins = pca9535.get_pins(espp::Pca9535::Port::PORT0, ec); + if (ec) { + fmt::print("get_pins failed: {}\n", ec.message()); + return false; + } + // toggle the output on port 1 + output = ~output; + pca9535.set_pins(espp::Pca9535::Port::PORT1, output, ec); + if (ec) { + fmt::print("set_pins failed: {}\n", ec.message()); + return false; + } + fmt::print("{:.3f}, {:#x}, {:#x}\n", seconds, p0_pins, output); + // NOTE: sleeping in this way allows the sleep to exit early when the + // task is being stopped / destroyed + { + std::unique_lock lk(m); + cv.wait_for(lk, 500ms); + } + // don't want to stop the task + return false; + }; + auto task = espp::Task({.callback = task_fn, + .task_config = + { + .name = "Pca9535 Task", + .stack_size_bytes = 5 * 1024, + }, + .log_level = espp::Logger::Verbosity::WARN}); + fmt::print("%time(s), port_0 pins, port_1 output\n"); + task.start(); + //! [pca9535 example] + while (true) { + std::this_thread::sleep_for(1s); + } + } +} diff --git a/components/pca9535/example/sdkconfig.defaults b/components/pca9535/example/sdkconfig.defaults new file mode 100644 index 000000000..0b4a6dd05 --- /dev/null +++ b/components/pca9535/example/sdkconfig.defaults @@ -0,0 +1,13 @@ +CONFIG_IDF_TARGET="esp32s3" + +CONFIG_FREERTOS_HZ=1000 + +# ESP32-specific +# +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240 + +# Common ESP-related +# +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 diff --git a/components/pca9535/idf_component.yml b/components/pca9535/idf_component.yml new file mode 100644 index 000000000..2bb4238bf --- /dev/null +++ b/components/pca9535/idf_component.yml @@ -0,0 +1,21 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "PCA9535 / PCA9555 16-bit I2C GPIO Expander component for ESP-IDF" +url: "https://github.com/esp-cpp/espp/tree/main/components/pca9535" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/io_expander/pca9535.html" +examples: + - path: example +tags: + - cpp + - Component + - Input + - Expander + - I2C + - Peripheral +dependencies: + idf: + version: '>=5.0' + espp/base_peripheral: '>=1.0' diff --git a/components/pca9535/include/pca9535.hpp b/components/pca9535/include/pca9535.hpp new file mode 100644 index 000000000..933c8b317 --- /dev/null +++ b/components/pca9535/include/pca9535.hpp @@ -0,0 +1,219 @@ +#pragma once + +#include + +#include "base_peripheral.hpp" + +namespace espp { +/** + * Class for communicating with and controlling a PCA9535 / PCA9555 16-bit I2C + * GPIO expander. The PCA9535 and PCA9555 share an identical register map; the + * PCA9535 additionally provides an open-drain interrupt output, while the + * PCA9555 is otherwise identical. A single driver therefore covers both parts. + * + * The device exposes two 8-bit ports (Port 0 and Port 1), each of which can be + * independently configured for input or output, and each of which supports + * input polarity inversion. + * + * \section pca9535_ex1 PCA9535 Example + * \snippet pca9535_example.cpp pca9535 example + */ +class Pca9535 : public BasePeripheral<> { +public: + static constexpr uint8_t DEFAULT_ADDRESS = + 0x20; ///< Default I2C address (A2..A0 = 000). Valid range is 0x20 - 0x27. + + /** + * The two GPIO ports the PCA9535 / PCA9555 has. + */ + enum class Port { + PORT0, ///< Port 0 + PORT1 ///< Port 1 + }; + + /** + * @brief Configuration information for the Pca9535. + */ + struct Config { + uint8_t device_address = DEFAULT_ADDRESS; ///< I2C address of this device. + uint8_t port_0_direction_mask = + 0xFF; ///< Direction mask (1 = input, 0 = output) for port 0. Default all inputs. + uint8_t port_0_polarity_mask = + 0x00; ///< Polarity inversion mask (1 = inverted) for port 0 inputs. + uint8_t port_1_direction_mask = + 0xFF; ///< Direction mask (1 = input, 0 = output) for port 1. Default all inputs. + uint8_t port_1_polarity_mask = + 0x00; ///< Polarity inversion mask (1 = inverted) for port 1 inputs. + BasePeripheral::write_fn write; ///< Function to write to the device. + BasePeripheral::read_register_fn + read_register; ///< Function to read bytes at a register address from the device. + bool auto_init = true; ///< True if the device should be initialized on + ///< construction. + Logger::Verbosity log_level{Logger::Verbosity::WARN}; ///< Log verbosity for the component. + }; + + /** + * @brief Construct the Pca9535 and configure it. + * @param config Config structure for configuring the PCA9535 / PCA9555 + */ + explicit Pca9535(const Config &config) + : BasePeripheral({.address = config.device_address, + .write = config.write, + .read_register = config.read_register}, + "Pca9535", config.log_level) + , port_0_direction_mask_(config.port_0_direction_mask) + , port_0_polarity_mask_(config.port_0_polarity_mask) + , port_1_direction_mask_(config.port_1_direction_mask) + , port_1_polarity_mask_(config.port_1_polarity_mask) { + if (config.auto_init) { + std::error_code ec; + init(ec); + } + } + + /** + * @brief Initialize the device. + * @details Writes the direction (Config) registers and the polarity + * inversion registers for both ports from the configured masks. + * @param ec Error code to set if there is an error. + */ + void initialize(std::error_code &ec) { + init(ec); + if (ec) { + logger_.error("Failed to initialize: {}", ec.message()); + } + } + + /** + * @brief Read the pin values on the provided port. + * @param port The Port for which to read the pins. + * @param ec Error code to set if there is an error. + * @return The pin values as an 8 bit mask. + */ + uint8_t get_pins(Port port, std::error_code &ec) { + auto addr = port == Port::PORT0 ? Registers::InputPort0 : Registers::InputPort1; + auto val = read_u8_from_register((uint8_t)addr, ec); + if (ec) { + logger_.error("Failed to read pins: {}", ec.message()); + return 0; + } + return val; + } + + /** + * @brief Read the pin values on both Port 0 and Port 1. + * @param ec Error code to set if an error occurs. + * @return The pin values as a 16 bit mask (Port 0 low byte, Port 1 high byte). + */ + uint16_t get_pins(std::error_code &ec) { + uint8_t buf[2] = {0, 0}; + read_many_from_register((uint8_t)Registers::InputPort0, buf, 2, ec); + if (ec) { + logger_.error("Failed to read pins: {}", ec.message()); + return 0; + } + uint16_t port0 = buf[0]; + uint16_t port1 = buf[1]; + return port0 | (port1 << 8); + } + + /** + * @brief Set the pin values on the provided port. + * @param port The Port for which to set the pin outputs. + * @param output The pin values as an 8 bit mask to set. + * @param ec Error code to set if there is an error. + */ + void set_pins(Port port, uint8_t output, std::error_code &ec) { + auto addr = port == Port::PORT0 ? Registers::OutputPort0 : Registers::OutputPort1; + write_u8_to_register((uint8_t)addr, output, ec); + } + + /** + * @brief Read back the output register for the provided port. + * @param port The Port for which to read the output register. + * @param ec Error code to set if there is an error. + * @return The output register value as an 8 bit mask. + */ + uint8_t get_output(Port port, std::error_code &ec) { + auto addr = port == Port::PORT0 ? Registers::OutputPort0 : Registers::OutputPort1; + auto val = read_u8_from_register((uint8_t)addr, ec); + if (ec) { + logger_.error("Failed to read output: {}", ec.message()); + return 0; + } + return val; + } + + /** + * @brief Set the i/o direction for the pins according to mask. + * @note For the PCA9535 / PCA9555 the direction (Config) register uses the + * convention 1 = input, 0 = output. This differs from some other + * expanders, so take care when porting masks between drivers. + * @param port The port associated with the provided pin mask. + * @param mask The mask indicating direction (1 = input, 0 = output). + * @param ec Error code to set if there is an error. + */ + void set_direction(Port port, uint8_t mask, std::error_code &ec) { + logger_.debug("Setting direction for {} to {}", (uint8_t)port, mask); + auto addr = port == Port::PORT0 ? Registers::ConfigPort0 : Registers::ConfigPort1; + write_u8_to_register((uint8_t)addr, mask, ec); + } + + /// @brief Read the direction (configuration) register for a port. + /// @param port The Port whose direction register to read. + /// @param ec The error code, set on failure. + /// @return The direction mask (1 = input, 0 = output). Useful for a + /// read-modify-write when only some pins should change direction. + uint8_t get_direction(Port port, std::error_code &ec) { + auto addr = port == Port::PORT0 ? Registers::ConfigPort0 : Registers::ConfigPort1; + return read_u8_from_register((uint8_t)addr, ec); + } + + /** + * @brief Set the input polarity inversion for the pins according to mask. + * @param port The port associated with the provided polarity mask. + * @param mask Polarity mask for the pins, 1 -> invert the input pin value. + * @param ec Error code to set if there is an error. + */ + void set_polarity_inversion(Port port, uint8_t mask, std::error_code &ec) { + logger_.debug("Setting polarity inversion for {} to {}", (uint8_t)port, mask); + auto addr = port == Port::PORT0 ? Registers::PolarityPort0 : Registers::PolarityPort1; + write_u8_to_register((uint8_t)addr, mask, ec); + } + +protected: + /** + * @brief Register map for the PCA9535 / PCA9555. + */ + enum class Registers : uint8_t { + InputPort0 = 0x00, ///< Input port 0 - reflects the incoming logic levels of the pins + InputPort1 = 0x01, ///< Input port 1 - reflects the incoming logic levels of the pins + OutputPort0 = 0x02, ///< Output port 0 - the outgoing logic levels of the pins defined as + ///< outputs + OutputPort1 = 0x03, ///< Output port 1 - the outgoing logic levels of the pins defined as + ///< outputs + PolarityPort0 = 0x04, ///< Polarity inversion (port 0) - 1 = input value inverted + PolarityPort1 = 0x05, ///< Polarity inversion (port 1) - 1 = input value inverted + ConfigPort0 = 0x06, ///< Configuration / direction (port 0) - 1 = input, 0 = output + ConfigPort1 = 0x07, ///< Configuration / direction (port 1) - 1 = input, 0 = output + }; + + void init(std::error_code &ec) { + set_direction(Port::PORT0, port_0_direction_mask_, ec); + if (ec) + return; + set_direction(Port::PORT1, port_1_direction_mask_, ec); + if (ec) + return; + set_polarity_inversion(Port::PORT0, port_0_polarity_mask_, ec); + if (ec) + return; + set_polarity_inversion(Port::PORT1, port_1_polarity_mask_, ec); + } + + uint8_t port_0_direction_mask_; + uint8_t port_0_polarity_mask_; + uint8_t port_1_direction_mask_; + uint8_t port_1_polarity_mask_; +}; +} // namespace espp diff --git a/doc/Doxyfile b/doc/Doxyfile index cc1f27d40..7860b6cf2 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -89,6 +89,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/bldc_haptics/example/main/bldc_haptics_example.cpp \ $(PROJECT_PATH)/components/bm8563/example/main/bm8563_example.cpp \ $(PROJECT_PATH)/components/bmi270/example/main/bmi270_example.cpp \ + $(PROJECT_PATH)/components/bq27220/example/main/bq27220_example.cpp \ $(PROJECT_PATH)/components/button/example/main/button_example.cpp \ $(PROJECT_PATH)/components/byte90/example/main/byte90_example.cpp \ $(PROJECT_PATH)/components/chsc6x/example/main/chsc6x_example.cpp \ @@ -129,6 +130,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/pi4ioe5v/example/main/pi4ioe5v_example.cpp \ $(PROJECT_PATH)/components/led/example/main/led_example.cpp \ $(PROJECT_PATH)/components/led_strip/example/main/led_strip_example.cpp \ + $(PROJECT_PATH)/components/lilygo-t5-47/example/main/lilygo-t5-47_example.cpp \ $(PROJECT_PATH)/components/logger/example/main/logger_example.cpp \ $(PROJECT_PATH)/components/lsm6dso/example/main/lsm6dso_example.cpp \ $(PROJECT_PATH)/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp \ @@ -146,6 +148,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/neopixel/example/main/neopixel_example.cpp \ $(PROJECT_PATH)/components/nvs/example/main/nvs_example.cpp \ $(PROJECT_PATH)/components/odrive_ascii/example/main/odrive_ascii_example.cpp \ + $(PROJECT_PATH)/components/pca9535/example/main/pca9535_example.cpp \ $(PROJECT_PATH)/components/pcf85063/example/main/pcf85063_example.cpp \ $(PROJECT_PATH)/components/pid/example/main/pid_example.cpp \ $(PROJECT_PATH)/components/provisioning/example/main/provisioning_example.cpp \ @@ -229,6 +232,7 @@ INPUT = \ $(PROJECT_PATH)/components/bm8563/include/bm8563.hpp \ $(PROJECT_PATH)/components/bmi270/include/bmi270.hpp \ $(PROJECT_PATH)/components/bmi270/include/bmi270_detail.hpp \ + $(PROJECT_PATH)/components/bq27220/include/bq27220.hpp \ $(PROJECT_PATH)/components/button/include/button.hpp \ $(PROJECT_PATH)/components/byte90/include/byte90.hpp \ $(PROJECT_PATH)/components/chsc6x/include/chsc6x.hpp \ @@ -316,6 +320,7 @@ INPUT = \ $(PROJECT_PATH)/components/pi4ioe5v/include/pi4ioe5v.hpp \ $(PROJECT_PATH)/components/led/include/led.hpp \ $(PROJECT_PATH)/components/led_strip/include/led_strip.hpp \ + $(PROJECT_PATH)/components/lilygo-t5-47/include/lilygo-t5-47.hpp \ $(PROJECT_PATH)/components/logger/include/logger.hpp \ $(PROJECT_PATH)/components/lp5817/include/lp5817.hpp \ $(PROJECT_PATH)/components/lsm6dso/include/lsm6dso.hpp \ @@ -346,6 +351,7 @@ INPUT = \ $(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/pca9535/include/pca9535.hpp \ $(PROJECT_PATH)/components/pcf85063/include/pcf85063.hpp \ $(PROJECT_PATH)/components/pid/include/pid.hpp \ $(PROJECT_PATH)/components/ping/include/ping.hpp \ diff --git a/doc/en/battery/bq27220.rst b/doc/en/battery/bq27220.rst new file mode 100644 index 000000000..9bf8fb555 --- /dev/null +++ b/doc/en/battery/bq27220.rst @@ -0,0 +1,24 @@ +BQ27220 +******* + +The `BQ27220` is a Texas Instruments I2C battery fuel gauge (gas gauge) for +single-cell lithium-ion batteries. It uses TI's Impedance Track™ algorithm to +report the battery state-of-charge, state-of-health, remaining and full-charge +capacity, voltage, instantaneous and average current, average power, +temperature, cycle count, and time-to-empty / time-to-full estimates over I2C. + +The `espp::Bq27220` component provides a simple interface to read these +quantities. All values are 16-bit little-endian standard commands. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + bq27220_example + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/bq27220.inc diff --git a/doc/en/battery/bq27220_example.md b/doc/en/battery/bq27220_example.md new file mode 100644 index 000000000..56d345a75 --- /dev/null +++ b/doc/en/battery/bq27220_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/bq27220/example/README.md +``` diff --git a/doc/en/battery/index.rst b/doc/en/battery/index.rst index d72e3a5b8..aa0cb4007 100644 --- a/doc/en/battery/index.rst +++ b/doc/en/battery/index.rst @@ -4,6 +4,7 @@ Battery APIs .. toctree:: :maxdepth: 1 + bq27220 max1704x These components provide a common interface to battery devices. They provide diff --git a/doc/en/dev_boards/lilygo/index.rst b/doc/en/dev_boards/lilygo/index.rst index 7df6af30f..3cf12de27 100644 --- a/doc/en/dev_boards/lilygo/index.rst +++ b/doc/en/dev_boards/lilygo/index.rst @@ -4,5 +4,6 @@ LilyGo Boards .. toctree:: :maxdepth: 1 + lilygo_t5_47 t_deck t_dongle_s3 diff --git a/doc/en/dev_boards/lilygo/lilygo_t5_47.rst b/doc/en/dev_boards/lilygo/lilygo_t5_47.rst new file mode 100644 index 000000000..4f08e7567 --- /dev/null +++ b/doc/en/dev_boards/lilygo/lilygo_t5_47.rst @@ -0,0 +1,34 @@ +LilyGo T5 4.7" e-paper +********************** + +T5 4.7" +------- + +The LilyGo T5 4.7" (ESP32-S3) is an e-paper development board built around a +4.7" 960x540, 16-level-grayscale ED047TC1 panel driven over a parallel bus (the +ESP32-S3 ``LCD_CAM`` peripheral). This BSP wraps the +`epdiy `_ library, which owns the panel +timing, waveforms, grayscale rendering, and partial-update handling. + +The `espp::LilyGoT547` component provides a singleton hardware abstraction that +brings up the e-paper display, an LVGL grayscale (``L8``) display, the GT911 +capacitive touch (as an LVGL input device), the PCF8563 RTC, the BQ27220 battery +fuel gauge, the PCA9535 I/O expander (including the PWR button), the BOOT button, +the frontlight, the qwiic connector, and the microSD card. + +epdiy's e-paper power ICs and the board's other I2C peripherals share a single +internal I2C bus (SDA=39 / SCL=40) that this BSP creates and hands to epdiy, so +touch, RTC, battery gauge, I/O expander, and qwiic all live on the same bus. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + lilygo_t5_47_example + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/lilygo-t5-47.inc diff --git a/doc/en/dev_boards/lilygo/lilygo_t5_47_example.md b/doc/en/dev_boards/lilygo/lilygo_t5_47_example.md new file mode 100644 index 000000000..c10d956b4 --- /dev/null +++ b/doc/en/dev_boards/lilygo/lilygo_t5_47_example.md @@ -0,0 +1,2 @@ +```{include} ../../components/lilygo-t5-47/example/README.md +``` diff --git a/doc/en/io_expander/index.rst b/doc/en/io_expander/index.rst index fe659fea6..751c268ef 100644 --- a/doc/en/io_expander/index.rst +++ b/doc/en/io_expander/index.rst @@ -8,6 +8,7 @@ IO Expander APIs pi4ioe5v kts1622 mcp23x17 + pca9535 There are several different types of I/O expanders provided which are standalone components for interacting with I/O expander chips over a serial interface such diff --git a/doc/en/io_expander/pca9535.rst b/doc/en/io_expander/pca9535.rst new file mode 100644 index 000000000..fd5df6531 --- /dev/null +++ b/doc/en/io_expander/pca9535.rst @@ -0,0 +1,23 @@ +PCA9535 / PCA9555 I/O Expander +****************************** + +The `PCA9535` (and the register-identical `PCA9555`) is a 16-bit I2C GPIO +expander with two 8-bit ports. Each pin can be configured as an input or an +output, with optional input polarity inversion. The `PCA9535` additionally +provides an open-drain interrupt output. + +The `espp::Pca9535` component allows the user to read the input pins, drive the +output pins, and configure per-port direction and polarity over I2C. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + pca9535_example + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/pca9535.inc diff --git a/doc/en/io_expander/pca9535_example.md b/doc/en/io_expander/pca9535_example.md new file mode 100644 index 000000000..ae3052fa2 --- /dev/null +++ b/doc/en/io_expander/pca9535_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/pca9535/example/README.md +```