/* SPDX-License-Identifier: GPL-3.0-only */ /* Bounded SSD1315-compatible OLED service with separate physical panels. */ #include "local_display.h" #include #include "board_pins.h" #include "driver/i2c_master.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" #define LOCAL_DISPLAY_PAGE_COUNT (LOCAL_DISPLAY_HEIGHT / 8U) #define LOCAL_DISPLAY_FRAMEBUFFER_SIZE (LOCAL_DISPLAY_WIDTH * LOCAL_DISPLAY_PAGE_COUNT) #define LOCAL_DISPLAY_I2C_SPEED_HZ 100000U #define LOCAL_DISPLAY_I2C_TIMEOUT_MS 250U #define LOCAL_DISPLAY_PROBE_TIMEOUT_MS 50U #define LOCAL_DISPLAY_SCAN_TIMEOUT_MS 10U #define LOCAL_DISPLAY_LOCK_TIMEOUT_MS 3000U #define LOCAL_DISPLAY_DEFAULT_CONTRAST 127U #define LOCAL_DISPLAY_COMMAND_CAPACITY 32U #define LOCAL_DISPLAY_DATA_CHUNK_SIZE 128U #define LOCAL_DISPLAY_FLUSH_BUDGET_US (500LL * 1000LL) static const uint8_t s_expected_addresses[] = {0x3cU, 0x3dU}; typedef struct { char character; uint8_t columns[5]; } glyph_t; /* Compact project-owned 5x7 ASCII subset with distinct upper/lower-case glyphs. */ static const glyph_t s_glyphs[] = { {' ', {0x00, 0x00, 0x00, 0x00, 0x00}}, {'!', {0x00, 0x00, 0x5f, 0x00, 0x00}}, {'-', {0x08, 0x08, 0x08, 0x08, 0x08}}, {'.', {0x00, 0x60, 0x60, 0x00, 0x00}}, {'/', {0x20, 0x10, 0x08, 0x04, 0x02}}, {':', {0x00, 0x36, 0x36, 0x00, 0x00}}, {'>', {0x00, 0x41, 0x22, 0x14, 0x08}}, {'?', {0x02, 0x01, 0x51, 0x09, 0x06}}, {'_', {0x40, 0x40, 0x40, 0x40, 0x40}}, {'0', {0x3e, 0x51, 0x49, 0x45, 0x3e}}, {'1', {0x00, 0x42, 0x7f, 0x40, 0x00}}, {'2', {0x42, 0x61, 0x51, 0x49, 0x46}}, {'3', {0x21, 0x41, 0x45, 0x4b, 0x31}}, {'4', {0x18, 0x14, 0x12, 0x7f, 0x10}}, {'5', {0x27, 0x45, 0x45, 0x45, 0x39}}, {'6', {0x3c, 0x4a, 0x49, 0x49, 0x30}}, {'7', {0x01, 0x71, 0x09, 0x05, 0x03}}, {'8', {0x36, 0x49, 0x49, 0x49, 0x36}}, {'9', {0x06, 0x49, 0x49, 0x29, 0x1e}}, {'A', {0x7e, 0x11, 0x11, 0x11, 0x7e}}, {'B', {0x7f, 0x49, 0x49, 0x49, 0x36}}, {'C', {0x3e, 0x41, 0x41, 0x41, 0x22}}, {'D', {0x7f, 0x41, 0x41, 0x22, 0x1c}}, {'E', {0x7f, 0x49, 0x49, 0x49, 0x41}}, {'F', {0x7f, 0x09, 0x09, 0x09, 0x01}}, {'G', {0x3e, 0x41, 0x49, 0x49, 0x7a}}, {'H', {0x7f, 0x08, 0x08, 0x08, 0x7f}}, {'I', {0x00, 0x41, 0x7f, 0x41, 0x00}}, {'J', {0x20, 0x40, 0x41, 0x3f, 0x01}}, {'K', {0x7f, 0x08, 0x14, 0x22, 0x41}}, {'L', {0x7f, 0x40, 0x40, 0x40, 0x40}}, {'M', {0x7f, 0x02, 0x0c, 0x02, 0x7f}}, {'N', {0x7f, 0x04, 0x08, 0x10, 0x7f}}, {'O', {0x3e, 0x41, 0x41, 0x41, 0x3e}}, {'P', {0x7f, 0x09, 0x09, 0x09, 0x06}}, {'Q', {0x3e, 0x41, 0x51, 0x21, 0x5e}}, {'R', {0x7f, 0x09, 0x19, 0x29, 0x46}}, {'S', {0x46, 0x49, 0x49, 0x49, 0x31}}, {'T', {0x01, 0x01, 0x7f, 0x01, 0x01}}, {'U', {0x3f, 0x40, 0x40, 0x40, 0x3f}}, {'V', {0x1f, 0x20, 0x40, 0x20, 0x1f}}, {'W', {0x7f, 0x20, 0x18, 0x20, 0x7f}}, {'X', {0x63, 0x14, 0x08, 0x14, 0x63}}, {'Y', {0x03, 0x04, 0x78, 0x04, 0x03}}, {'Z', {0x61, 0x51, 0x49, 0x45, 0x43}}, {'a', {0x20, 0x54, 0x54, 0x54, 0x78}}, {'b', {0x7f, 0x48, 0x44, 0x44, 0x38}}, {'c', {0x38, 0x44, 0x44, 0x44, 0x20}}, {'d', {0x38, 0x44, 0x44, 0x48, 0x7f}}, {'e', {0x38, 0x54, 0x54, 0x54, 0x18}}, {'f', {0x08, 0x7e, 0x09, 0x01, 0x02}}, {'g', {0x0c, 0x52, 0x52, 0x52, 0x3e}}, {'h', {0x7f, 0x08, 0x04, 0x04, 0x78}}, {'i', {0x00, 0x44, 0x7d, 0x40, 0x00}}, {'j', {0x20, 0x40, 0x44, 0x3d, 0x00}}, {'k', {0x7f, 0x10, 0x28, 0x44, 0x00}}, {'l', {0x00, 0x41, 0x7f, 0x40, 0x00}}, {'m', {0x7c, 0x04, 0x18, 0x04, 0x78}}, {'n', {0x7c, 0x08, 0x04, 0x04, 0x78}}, {'o', {0x38, 0x44, 0x44, 0x44, 0x38}}, {'p', {0x7c, 0x14, 0x14, 0x14, 0x08}}, {'q', {0x08, 0x14, 0x14, 0x18, 0x7c}}, {'r', {0x7c, 0x08, 0x04, 0x04, 0x08}}, {'s', {0x48, 0x54, 0x54, 0x54, 0x20}}, {'t', {0x04, 0x3f, 0x44, 0x40, 0x20}}, {'u', {0x3c, 0x40, 0x40, 0x20, 0x7c}}, {'v', {0x1c, 0x20, 0x40, 0x20, 0x1c}}, {'w', {0x3c, 0x40, 0x30, 0x40, 0x3c}}, {'x', {0x44, 0x28, 0x10, 0x28, 0x44}}, {'y', {0x0c, 0x50, 0x50, 0x50, 0x3c}}, {'z', {0x44, 0x64, 0x54, 0x4c, 0x44}}, }; static i2c_master_bus_handle_t s_bus; static i2c_master_dev_handle_t s_device; static StaticSemaphore_t s_mutex_storage; static SemaphoreHandle_t s_mutex; static uint8_t s_framebuffer[LOCAL_DISPLAY_FRAMEBUFFER_SIZE]; static bool s_bus_ready; static bool s_initialized; static bool s_frame_active; static TaskHandle_t s_frame_owner; static uint8_t s_address; static uint8_t s_contrast = LOCAL_DISPLAY_DEFAULT_CONTRAST; static bool s_inverted; static uint8_t s_dirty_pages; static esp_err_t s_last_error = ESP_ERR_INVALID_STATE; static TickType_t milliseconds_to_ticks(uint32_t milliseconds) { TickType_t ticks = pdMS_TO_TICKS(milliseconds); return (milliseconds > 0U && ticks == 0U) ? 1U : ticks; } static esp_err_t take_lock(void) { if (s_mutex == NULL) { return ESP_ERR_INVALID_STATE; } return xSemaphoreTake(s_mutex, milliseconds_to_ticks(LOCAL_DISPLAY_LOCK_TIMEOUT_MS)) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; } static void give_lock(void) { if (s_mutex != NULL) { (void)xSemaphoreGive(s_mutex); } } static void set_last_error(esp_err_t error) { s_last_error = error; } static esp_err_t send_commands_locked(const uint8_t *commands, size_t count) { if (!s_initialized || s_device == NULL || commands == NULL || count == 0U || count > LOCAL_DISPLAY_COMMAND_CAPACITY) { return ESP_ERR_INVALID_STATE; } uint8_t transfer[LOCAL_DISPLAY_COMMAND_CAPACITY + 1U]; transfer[0] = 0x00U; memcpy(&transfer[1], commands, count); return i2c_master_transmit(s_device, transfer, count + 1U, LOCAL_DISPLAY_I2C_TIMEOUT_MS); } static esp_err_t send_command_locked(uint8_t command) { return send_commands_locked(&command, 1U); } static esp_err_t flush_dirty_locked(void) { if (!s_initialized || s_device == NULL) { return ESP_ERR_INVALID_STATE; } int64_t started = esp_timer_get_time(); uint8_t transfer[LOCAL_DISPLAY_DATA_CHUNK_SIZE + 1U]; transfer[0] = 0x40U; for (uint8_t page = 0U; page < LOCAL_DISPLAY_PAGE_COUNT; ++page) { uint8_t page_mask = (uint8_t)(1U << page); if ((s_dirty_pages & page_mask) == 0U) { continue; } if ((esp_timer_get_time() - started) >= LOCAL_DISPLAY_FLUSH_BUDGET_US) { return ESP_ERR_TIMEOUT; } const uint8_t commands[] = { 0x21U, 0x00U, (uint8_t)(LOCAL_DISPLAY_WIDTH - 1U), 0x22U, page, page, }; esp_err_t error = send_commands_locked(commands, sizeof(commands)); if (error != ESP_OK) { return error; } if ((esp_timer_get_time() - started) >= LOCAL_DISPLAY_FLUSH_BUDGET_US) { return ESP_ERR_TIMEOUT; } memcpy(&transfer[1], &s_framebuffer[(size_t)page * LOCAL_DISPLAY_WIDTH], LOCAL_DISPLAY_WIDTH); error = i2c_master_transmit(s_device, transfer, sizeof(transfer), LOCAL_DISPLAY_I2C_TIMEOUT_MS); if (error != ESP_OK) { return error; } s_dirty_pages &= (uint8_t)~page_mask; } return ESP_OK; } static void set_pixel_raw(uint8_t x, uint8_t y, bool on) { if (x >= LOCAL_DISPLAY_WIDTH || y >= LOCAL_DISPLAY_HEIGHT) { return; } size_t index = (size_t)(y / 8U) * LOCAL_DISPLAY_WIDTH + x; uint8_t mask = (uint8_t)(1U << (y & 7U)); uint8_t before = s_framebuffer[index]; if (on) { s_framebuffer[index] |= mask; } else { s_framebuffer[index] &= (uint8_t)~mask; } if (before != s_framebuffer[index]) { s_dirty_pages |= (uint8_t)(1U << (y / 8U)); } } static bool panel_geometry(local_display_panel_t panel, uint8_t *origin_y, uint8_t *height) { if (origin_y == NULL || height == NULL) { return false; } switch (panel) { case LOCAL_DISPLAY_PANEL_STATUS: *origin_y = 0U; *height = LOCAL_DISPLAY_STATUS_HEIGHT; return true; case LOCAL_DISPLAY_PANEL_CONTENT: *origin_y = LOCAL_DISPLAY_STATUS_HEIGHT; *height = LOCAL_DISPLAY_CONTENT_HEIGHT; return true; default: return false; } } static const glyph_t *find_glyph(char character) { for (size_t index = 0U; index < sizeof(s_glyphs) / sizeof(s_glyphs[0]); ++index) { if (s_glyphs[index].character == character) { return &s_glyphs[index]; } } for (size_t index = 0U; index < sizeof(s_glyphs) / sizeof(s_glyphs[0]); ++index) { if (s_glyphs[index].character == '?') { return &s_glyphs[index]; } } return NULL; } static esp_err_t select_device_locked(uint8_t address) { if (address != 0x3cU && address != 0x3dU) { return ESP_ERR_INVALID_ARG; } esp_err_t error = i2c_master_probe(s_bus, address, LOCAL_DISPLAY_PROBE_TIMEOUT_MS); if (error != ESP_OK) { return error; } if (s_device != NULL && s_address == address) { return ESP_OK; } if (s_device != NULL) { error = i2c_master_bus_rm_device(s_device); if (error != ESP_OK) { return error; } s_device = NULL; s_initialized = false; } const i2c_device_config_t config = { .dev_addr_length = I2C_ADDR_BIT_LEN_7, .device_address = address, .scl_speed_hz = LOCAL_DISPLAY_I2C_SPEED_HZ, }; error = i2c_master_bus_add_device(s_bus, &config, &s_device); if (error == ESP_OK) { s_address = address; } return error; } static esp_err_t initialize_locked(uint8_t address) { s_initialized = false; esp_err_t error = select_device_locked(address); if (error != ESP_OK) { return error; } const uint8_t commands[] = { 0xaeU, 0xd5U, 0x80U, 0xa8U, 0x3fU, 0xd3U, 0x00U, 0x40U, 0x8dU, 0x14U, 0x20U, 0x00U, 0xa1U, 0xc8U, 0xdaU, 0x12U, 0x81U, LOCAL_DISPLAY_DEFAULT_CONTRAST, 0xd9U, 0xf1U, 0xdbU, 0x40U, 0xa4U, 0xa6U, 0x2eU, }; /* send_commands_locked requires initialized, so issue the bootstrap directly. */ uint8_t transfer[sizeof(commands) + 1U]; transfer[0] = 0x00U; memcpy(&transfer[1], commands, sizeof(commands)); error = i2c_master_transmit(s_device, transfer, sizeof(transfer), LOCAL_DISPLAY_I2C_TIMEOUT_MS); if (error != ESP_OK) { return error; } s_initialized = true; s_contrast = LOCAL_DISPLAY_DEFAULT_CONTRAST; s_inverted = false; memset(s_framebuffer, 0, sizeof(s_framebuffer)); s_dirty_pages = (uint8_t)((1U << LOCAL_DISPLAY_PAGE_COUNT) - 1U); error = flush_dirty_locked(); if (error == ESP_OK) { error = send_command_locked(0xafU); } if (error != ESP_OK) { s_initialized = false; } return error; } esp_err_t local_display_init(void) { if (s_bus_ready || s_mutex != NULL) { return ESP_ERR_INVALID_STATE; } s_mutex = xSemaphoreCreateMutexStatic(&s_mutex_storage); if (s_mutex == NULL) { return ESP_ERR_NO_MEM; } const i2c_master_bus_config_t config = { .i2c_port = LOCAL_UI_I2C_PORT, .sda_io_num = LOCAL_UI_DISPLAY_SDA_GPIO, .scl_io_num = LOCAL_UI_DISPLAY_SCL_GPIO, .clk_source = I2C_CLK_SRC_DEFAULT, .glitch_ignore_cnt = 7, .flags.enable_internal_pullup = false, }; esp_err_t error = i2c_new_master_bus(&config, &s_bus); if (error != ESP_OK) { vSemaphoreDelete(s_mutex); s_mutex = NULL; set_last_error(error); return error; } s_bus_ready = true; set_last_error(ESP_OK); return ESP_OK; } esp_err_t local_display_probe_expected(uint8_t *address_7bit) { if (address_7bit == NULL) { return ESP_ERR_INVALID_ARG; } if (!s_bus_ready) { return ESP_ERR_INVALID_STATE; } esp_err_t error = take_lock(); if (error != ESP_OK) { return error; } uint8_t found = 0U; for (size_t index = 0U; index < sizeof(s_expected_addresses) / sizeof(s_expected_addresses[0]); ++index) { uint8_t address = s_expected_addresses[index]; if (i2c_master_probe(s_bus, address, LOCAL_DISPLAY_PROBE_TIMEOUT_MS) == ESP_OK) { if (found == 0U) { *address_7bit = address; } ++found; } } error = found == 0U ? ESP_ERR_NOT_FOUND : ESP_OK; set_last_error(error); give_lock(); return error; } esp_err_t local_display_start_at(uint8_t address_7bit) { if (!s_bus_ready) { return ESP_ERR_INVALID_STATE; } esp_err_t error = take_lock(); if (error != ESP_OK) { return error; } error = initialize_locked(address_7bit); set_last_error(error); give_lock(); return error; } esp_err_t local_display_start(void) { uint8_t address = 0U; esp_err_t error = local_display_probe_expected(&address); if (error != ESP_OK) { return error; } return local_display_start_at(address); } esp_err_t local_display_stop(void) { if (!s_bus_ready) { return ESP_ERR_INVALID_STATE; } esp_err_t error = take_lock(); if (error != ESP_OK) { return error; } if (!s_initialized) { error = ESP_ERR_INVALID_STATE; } else { error = send_command_locked(0xaeU); /* A failed command leaves panel state unknown; force a clean reinit. */ s_initialized = false; } set_last_error(error); give_lock(); return error; } esp_err_t local_display_get_snapshot(local_display_snapshot_t *snapshot) { if (snapshot == NULL) { return ESP_ERR_INVALID_ARG; } esp_err_t error = take_lock(); if (error != ESP_OK) { return error; } *snapshot = (local_display_snapshot_t){ .bus_ready = s_bus_ready, .initialized = s_initialized, .address_7bit = s_address, .contrast = s_contrast, .inverted = s_inverted, .dirty_page_mask = s_dirty_pages, .last_error = s_last_error, }; give_lock(); return ESP_OK; } esp_err_t local_display_scan(local_display_scan_callback_t callback, void *context, size_t *responding_count) { if (!s_bus_ready) { return ESP_ERR_INVALID_STATE; } esp_err_t error = take_lock(); if (error != ESP_OK) { return error; } uint8_t responses[0x78U - 0x08U]; size_t found = 0U; for (uint16_t address = 0x08U; address <= 0x77U; ++address) { if (i2c_master_probe(s_bus, address, LOCAL_DISPLAY_SCAN_TIMEOUT_MS) == ESP_OK) { responses[found++] = (uint8_t)address; } vTaskDelay(1U); } if (responding_count != NULL) { *responding_count = found; } error = found == 0U ? ESP_ERR_NOT_FOUND : ESP_OK; set_last_error(error); give_lock(); /* Callers may safely use the display service from the callback. */ if (callback != NULL) { for (size_t index = 0U; index < found; ++index) { callback(responses[index], context); } } return error; } esp_err_t local_display_set_contrast(uint8_t contrast) { esp_err_t error = take_lock(); if (error != ESP_OK) { return error; } const uint8_t commands[] = {0x81U, contrast}; error = send_commands_locked(commands, sizeof(commands)); if (error == ESP_OK) { s_contrast = contrast; } else { s_initialized = false; } set_last_error(error); give_lock(); return error; } esp_err_t local_display_set_inverted(bool inverted) { esp_err_t error = take_lock(); if (error != ESP_OK) { return error; } error = send_command_locked(inverted ? 0xa7U : 0xa6U); if (error == ESP_OK) { s_inverted = inverted; } else { s_initialized = false; } set_last_error(error); give_lock(); return error; } esp_err_t local_display_frame_begin(void) { esp_err_t error = take_lock(); if (error != ESP_OK) { return error; } if (!s_initialized || s_frame_active) { give_lock(); return ESP_ERR_INVALID_STATE; } s_frame_active = true; s_frame_owner = xTaskGetCurrentTaskHandle(); return ESP_OK; } esp_err_t local_display_frame_end(void) { if (!s_frame_active || s_frame_owner != xTaskGetCurrentTaskHandle()) { return ESP_ERR_INVALID_STATE; } esp_err_t error = flush_dirty_locked(); if (error != ESP_OK) { s_initialized = false; } set_last_error(error); s_frame_active = false; s_frame_owner = NULL; give_lock(); return error; } void local_display_frame_cancel(void) { if (s_frame_active && s_frame_owner == xTaskGetCurrentTaskHandle()) { s_frame_active = false; s_frame_owner = NULL; give_lock(); } } void local_display_frame_clear(local_display_panel_t panel) { if (!s_frame_active || s_frame_owner != xTaskGetCurrentTaskHandle()) { return; } uint8_t origin_y = 0U; uint8_t height = 0U; if (!panel_geometry(panel, &origin_y, &height)) { return; } for (uint8_t y = origin_y; y < origin_y + height; ++y) { for (uint8_t x = 0U; x < LOCAL_DISPLAY_WIDTH; ++x) { set_pixel_raw(x, y, false); } } } void local_display_frame_clear_all(void) { if (!s_frame_active || s_frame_owner != xTaskGetCurrentTaskHandle()) { return; } for (size_t index = 0U; index < sizeof(s_framebuffer); ++index) { if (s_framebuffer[index] != 0U) { s_framebuffer[index] = 0U; s_dirty_pages |= (uint8_t)(1U << (index / LOCAL_DISPLAY_WIDTH)); } } } void local_display_frame_set_pixel(local_display_panel_t panel, uint8_t x, uint8_t y, bool on) { if (!s_frame_active || s_frame_owner != xTaskGetCurrentTaskHandle()) { return; } uint8_t origin_y = 0U; uint8_t height = 0U; if (!panel_geometry(panel, &origin_y, &height) || x >= LOCAL_DISPLAY_WIDTH || y >= height) { return; } set_pixel_raw(x, (uint8_t)(origin_y + y), on); } void local_display_frame_draw_text(local_display_panel_t panel, uint8_t x, uint8_t y, const char *text) { if (!s_frame_active || s_frame_owner != xTaskGetCurrentTaskHandle() || text == NULL) { return; } uint8_t origin_y = 0U; uint8_t height = 0U; if (!panel_geometry(panel, &origin_y, &height) || y >= height) { return; } uint16_t cursor_x = x; for (const char *character = text; *character != '\0'; ++character) { const glyph_t *glyph = find_glyph(*character); if (glyph == NULL || cursor_x + 5U > LOCAL_DISPLAY_WIDTH) { break; } for (uint8_t column = 0U; column < 5U; ++column) { for (uint8_t row = 0U; row < 7U; ++row) { if ((glyph->columns[column] & (uint8_t)(1U << row)) != 0U && y + row < height) { set_pixel_raw((uint8_t)(cursor_x + column), (uint8_t)(origin_y + y + row), true); } } } cursor_x += 6U; if (cursor_x >= LOCAL_DISPLAY_WIDTH) { break; } } }