Files
tactility/Drivers/ssd1306-module/source/ssd1306.cpp
2026-07-22 22:43:34 +02:00

362 lines
14 KiB
C++

// SPDX-License-Identifier: Apache-2.0
#include <drivers/ssd1306.h>
#include <ssd1306_module.h>
#include <tactility/check.h>
#include <tactility/device.h>
#include <tactility/driver.h>
#include <tactility/drivers/display.h>
#include <tactility/drivers/esp32_i2c.h>
#include <tactility/drivers/esp32_i2c_master.h>
#include <tactility/drivers/i2c_controller.h>
#include <tactility/error.h>
#include <tactility/log.h>
#include <esp_err.h>
#include <esp_lcd_io_i2c.h>
#include <esp_lcd_panel_io.h>
#include <esp_lcd_panel_ops.h>
#include <esp_lcd_panel_ssd1306.h>
#include <driver/gpio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <cstdlib>
#include <cstring>
#define TAG "SSD1306"
#define GET_CONFIG(device) (static_cast<const Ssd1306Config*>((device)->config))
struct Ssd1306Internal {
esp_lcd_panel_io_handle_t io_handle;
esp_lcd_panel_handle_t panel_handle;
// Scratch buffer for the page-format transpose in ssd1306_draw_bitmap(); sized once for the
// full panel resolution and reused every draw_bitmap() call to avoid a malloc/free per frame.
uint8_t* page_buf;
size_t page_buf_size;
};
static gpio_num_t pin_or_nc(const struct GpioPinSpec& pin) {
return pin.gpio_controller == nullptr ? GPIO_NUM_NC : static_cast<gpio_num_t>(pin.pin);
}
// region Driver lifecycle
static esp_err_t create_io_handle(Device* parent, uint8_t address, esp_lcd_panel_io_handle_t* out_handle) {
// dc_bit_offset=6/dc_low_on_data=false gives a 0x00 control byte before command bytes and
// 0x40 before pixel data - the standard SSD1306 I2C control-byte convention. lcd_cmd_bits=0
// means tx_param()'s own "cmd" phase is skipped entirely (see send_command() below): every
// command byte is transmitted as if it were "data" following the correct control byte,
// rather than a separately-framed command - this chip's protocol has no such framing to begin
// with, unlike the multi-byte-per-command SPI/QSPI panels elsewhere in this repo.
esp_lcd_panel_io_i2c_config_t io_config = {
.dev_addr = address,
.on_color_trans_done = nullptr,
.user_ctx = nullptr,
.control_phase_bytes = 1,
.dc_bit_offset = 6,
.lcd_cmd_bits = 0,
.lcd_param_bits = 0,
.flags = {
.dc_low_on_data = false,
.disable_control_phase = false,
},
.scl_speed_hz = 0,
};
auto* parent_driver = device_get_driver(parent);
if (driver_is_compatible(parent_driver, "espressif,esp32-i2c")) {
auto port = static_cast<const Esp32I2cConfig*>(parent->config)->port;
return esp_lcd_new_panel_io_i2c_v1(port, &io_config, out_handle);
}
if (driver_is_compatible(parent_driver, "espressif,esp32-i2c-master")) {
auto bus = esp32_i2c_master_get_bus_handle(parent);
io_config.scl_speed_hz = esp32_i2c_master_get_clock_frequency(parent);
return esp_lcd_new_panel_io_i2c_v2(bus, &io_config, out_handle);
}
LOG_E(TAG, "Unsupported I2C driver");
return ESP_ERR_NOT_SUPPORTED;
}
// Sends a single SSD1306 command byte: [control byte, this byte]. lcd_cmd is passed as 0 and
// ignored on the wire (see create_io_handle()'s comment) - only param/param_size actually reach
// the bus, so this doubles as the mechanism for both standalone commands (e.g. 0xAE display-off)
// and command+parameter pairs (each byte of which is just its own call to this function, matching
// how the original hand-written driver issued them as separate I2C writes).
static bool send_command(esp_lcd_panel_io_handle_t io, uint8_t command) {
return esp_lcd_panel_io_tx_param(io, 0, &command, 1) == ESP_OK;
}
static bool send_init_sequence(esp_lcd_panel_io_handle_t io, const uint8_t* bytes, uint32_t length) {
for (uint32_t i = 0; i < length; i++) {
if (!send_command(io, bytes[i])) {
LOG_E(TAG, "Failed to send init sequence byte %lu", (unsigned long)i);
return false;
}
}
return true;
}
// Manual reset with explicit timing, rather than going through esp_lcd_panel_reset(): this
// chip/config combination is known to need a specific assert/settle timing that the panel's
// standard reset (which we intentionally never wire up - reset_gpio_num is always -1 below) may
// not otherwise match. Runs before the panel handle is even created, so a subsequent init sees a
// freshly-reset chip.
static void perform_hardware_reset(const Ssd1306Config* config) {
gpio_num_t pin = pin_or_nc(config->pin_reset);
if (pin == GPIO_NUM_NC) {
return;
}
gpio_config_t io_conf = {
.pin_bit_mask = 1ULL << pin,
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&io_conf);
// SSD1306's reset line is fixed active-low in hardware.
gpio_set_level(pin, 0);
vTaskDelay(pdMS_TO_TICKS(10));
gpio_set_level(pin, 1);
vTaskDelay(pdMS_TO_TICKS(100));
}
static error_t start(Device* device) {
auto* parent = device_get_parent(device);
check(device_get_type(parent) == &I2C_CONTROLLER_TYPE);
const auto* config = GET_CONFIG(device);
auto* internal = static_cast<Ssd1306Internal*>(malloc(sizeof(Ssd1306Internal)));
if (internal == nullptr) {
return ERROR_OUT_OF_MEMORY;
}
internal->page_buf_size = (size_t)config->horizontal_resolution * ((config->vertical_resolution + 7) / 8);
internal->page_buf = static_cast<uint8_t*>(malloc(internal->page_buf_size));
if (internal->page_buf == nullptr) {
free(internal);
return ERROR_OUT_OF_MEMORY;
}
// Lets a power rail gated by a gpio-hog node (asserted moments earlier, during the same
// kernel_init() pass) stabilize before this device tries to reset/talk to the chip. See the
// 'power-on-delay-ms' binding property.
if (config->power_on_delay_ms > 0) {
vTaskDelay(pdMS_TO_TICKS(config->power_on_delay_ms));
}
if (create_io_handle(parent, config->address, &internal->io_handle) != ESP_OK) {
LOG_E(TAG, "Failed to create panel IO");
free(internal->page_buf);
free(internal);
return ERROR_RESOURCE;
}
perform_hardware_reset(config);
esp_lcd_panel_ssd1306_config_t ssd1306_config = {
.height = static_cast<uint8_t>(config->vertical_resolution),
};
// color_space (not rgb_ele_order): this deprecated union member is the only one whose type
// can express ESP_LCD_COLOR_SPACE_MONOCHROME - rgb_ele_order's own enum only covers RGB/BGR.
esp_lcd_panel_dev_config_t panel_config = {
.reset_gpio_num = -1, // always -1: reset is handled manually above, not by the panel itself
.color_space = ESP_LCD_COLOR_SPACE_MONOCHROME,
.data_endian = LCD_RGB_DATA_ENDIAN_BIG,
.bits_per_pixel = 1,
.flags = { .reset_active_high = false },
.vendor_config = &ssd1306_config,
};
if (esp_lcd_new_panel_ssd1306(internal->io_handle, &panel_config, &internal->panel_handle) != ESP_OK) {
LOG_E(TAG, "Failed to create panel");
esp_lcd_panel_io_del(internal->io_handle);
free(internal->page_buf);
free(internal);
return ERROR_RESOURCE;
}
// Bring-up sequence. Every failure path below must clean up fully: unlike stop_device, this
// is never retried by the kernel if start_device fails (see device_start() in
// TactilityKernel), so a partial failure here would leak.
bool ok;
if (config->init_sequence != nullptr && config->init_sequence_length > 0) {
ok = send_init_sequence(internal->io_handle, config->init_sequence, config->init_sequence_length);
} else {
ok = esp_lcd_panel_init(internal->panel_handle) == ESP_OK;
}
ok = ok && (!config->invert_color || esp_lcd_panel_invert_color(internal->panel_handle, true) == ESP_OK);
ok = ok && esp_lcd_panel_disp_on_off(internal->panel_handle, true) == ESP_OK;
if (!ok) {
LOG_E(TAG, "Failed to bring up panel");
esp_lcd_panel_del(internal->panel_handle);
esp_lcd_panel_io_del(internal->io_handle);
free(internal->page_buf);
free(internal);
return ERROR_RESOURCE;
}
vTaskDelay(pdMS_TO_TICKS(100)); // Let the panel stabilize before the first real draw_bitmap()
device_set_driver_data(device, internal);
return ERROR_NONE;
}
static error_t stop(Device* device) {
auto* internal = static_cast<Ssd1306Internal*>(device_get_driver_data(device));
if (internal->panel_handle != nullptr) {
if (esp_lcd_panel_del(internal->panel_handle) != ESP_OK) {
LOG_E(TAG, "Failed to delete panel");
return ERROR_RESOURCE;
}
internal->panel_handle = nullptr;
}
if (internal->io_handle != nullptr) {
if (esp_lcd_panel_io_del(internal->io_handle) != ESP_OK) {
LOG_E(TAG, "Failed to delete panel IO");
return ERROR_RESOURCE;
}
internal->io_handle = nullptr;
}
free(internal->page_buf);
free(internal);
device_set_driver_data(device, nullptr);
return ERROR_NONE;
}
// endregion
// region DisplayApi
static error_t ssd1306_reset(Device* device) {
perform_hardware_reset(GET_CONFIG(device));
return ERROR_NONE;
}
static error_t ssd1306_init(Device* device) {
auto* internal = static_cast<Ssd1306Internal*>(device_get_driver_data(device));
const auto* config = GET_CONFIG(device);
bool ok;
if (config->init_sequence != nullptr && config->init_sequence_length > 0) {
ok = send_init_sequence(internal->io_handle, config->init_sequence, config->init_sequence_length);
} else {
ok = esp_lcd_panel_init(internal->panel_handle) == ESP_OK;
}
return ok ? ERROR_NONE : ERROR_RESOURCE;
}
// DISPLAY_COLOR_FORMAT_MONOCHROME's wire format (matching LVGL's LV_COLOR_FORMAT_I1, palette
// stripped) is row-major: each byte packs 8 horizontally-adjacent pixels, MSB = leftmost. The
// SSD1306's GDDRAM is page-addressed instead: each byte packs 8 *vertically* adjacent pixels of
// one column, LSB = topmost row of the page. Transpose one into the other before handing off to
// the vendor component, which expects page format directly.
static void transpose_row_major_to_pages(const uint8_t* src, int32_t width, int32_t height, uint8_t* out) {
uint32_t row_stride = (uint32_t)(width + 7) / 8;
for (int32_t y = 0; y < height; y++) {
uint8_t page = (uint8_t)(y / 8);
uint8_t bit_in_page = (uint8_t)(y % 8);
for (int32_t x = 0; x < width; x++) {
uint8_t src_byte = src[y * row_stride + x / 8];
uint8_t pixel = (src_byte >> (7 - (x % 8))) & 0x01;
out[page * width + x] |= (uint8_t)(pixel << bit_in_page);
}
}
}
static error_t ssd1306_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
auto* internal = static_cast<Ssd1306Internal*>(device_get_driver_data(device));
int32_t width = x_end - x_start;
int32_t height = y_end - y_start;
size_t needed = (size_t)width * ((height + 7) / 8);
check(needed <= internal->page_buf_size);
memset(internal->page_buf, 0, needed);
transpose_row_major_to_pages(static_cast<const uint8_t*>(color_data), width, height, internal->page_buf);
return esp_lcd_panel_draw_bitmap(internal->panel_handle, x_start, y_start, x_end, y_end, internal->page_buf) == ESP_OK ? ERROR_NONE : ERROR_RESOURCE;
}
// mirror/swap_xy/set_gap are not exposed: the SSD1306 vendor component doesn't implement them.
static error_t ssd1306_invert_color(Device* device, bool invert_color_data) {
auto* internal = static_cast<Ssd1306Internal*>(device_get_driver_data(device));
return esp_lcd_panel_invert_color(internal->panel_handle, invert_color_data) == ESP_OK ? ERROR_NONE : ERROR_RESOURCE;
}
static error_t ssd1306_disp_on_off(Device* device, bool on_off) {
auto* internal = static_cast<Ssd1306Internal*>(device_get_driver_data(device));
return esp_lcd_panel_disp_on_off(internal->panel_handle, on_off) == ESP_OK ? ERROR_NONE : ERROR_RESOURCE;
}
// disp_sleep is not exposed: the SSD1306 vendor component doesn't implement it either (display
// on/off via 0xAE/0xAF is this chip's equivalent, already covered by disp_on_off above).
static enum DisplayColorFormat ssd1306_get_color_format(Device*) {
return DISPLAY_COLOR_FORMAT_MONOCHROME;
}
static uint16_t ssd1306_get_resolution_x(Device* device) {
return GET_CONFIG(device)->horizontal_resolution;
}
static uint16_t ssd1306_get_resolution_y(Device* device) {
return GET_CONFIG(device)->vertical_resolution;
}
static void ssd1306_get_frame_buffer(Device*, uint8_t, void** out_buffer) {
*out_buffer = nullptr;
}
static uint8_t ssd1306_get_frame_buffer_count(Device*) {
return 0;
}
// get_backlight is not exposed: SSD1306 is a self-emitting OLED panel with no backlight.
// endregion
static const DisplayApi ssd1306_display_api = {
.capabilities = DISPLAY_CAPABILITY_INVERT_COLOR | DISPLAY_CAPABILITY_ON_OFF,
.reset = ssd1306_reset,
.init = ssd1306_init,
.draw_bitmap = ssd1306_draw_bitmap,
.mirror = nullptr,
.swap_xy = nullptr,
.get_swap_xy = nullptr,
.get_mirror_x = nullptr,
.get_mirror_y = nullptr,
.set_gap = nullptr,
.invert_color = ssd1306_invert_color,
.disp_on_off = ssd1306_disp_on_off,
.disp_sleep = nullptr,
.get_color_format = ssd1306_get_color_format,
.get_resolution_x = ssd1306_get_resolution_x,
.get_resolution_y = ssd1306_get_resolution_y,
.get_frame_buffer = ssd1306_get_frame_buffer,
.get_frame_buffer_count = ssd1306_get_frame_buffer_count,
.get_backlight = nullptr,
.has_capability = nullptr,
};
Driver ssd1306_driver = {
.name = "ssd1306",
.compatible = (const char*[]) { "solomon,ssd1306", nullptr },
.start_device = start,
.stop_device = stop,
.api = &ssd1306_display_api,
.device_type = &DISPLAY_TYPE,
.owner = &ssd1306_module,
.internal = nullptr
};