Files
meshtastic_firmware/src/graphics/tftSetup.cpp
T
Thomas Göttgens bb6a81f1e9 Pass framebuffer rotation through DisplayDriverConfig (#11534)
* tftSetup: pass framebuffer rotation via DisplayDriverConfig

Replaces the MESHTASTIC_FB_ROTATION environment variable with
DisplayDriverConfig::rotation(), which device-ui reads in
FBDriver::create(const DisplayDriverConfig &).

* tftSetup: carry framebuffer rotation in the panel config

Use the DisplayDriverConfig builder with panel_config_t::offset_rotation
instead of a dedicated rotation setter. Width and height fall back to the
device-ui defaults when the yaml does not set them.

* tftSetup: pass the framebuffer panel config unfiltered

Take Display.Width, Display.Height and Display.OffsetRotate straight from
the portduino config, like the CUSTOM_TFT branch does.
2026-08-19 15:50:49 +00:00

436 lines
19 KiB
C++

#if HAS_TFT
#include "SPILock.h"
#include "sleep.h"
#include "api/PacketAPI.h"
#include "comms/PacketClient.h"
#include "comms/PacketServer.h"
#include "graphics/DeviceScreen.h"
#include "graphics/driver/DisplayDriverConfig.h"
#include "util/ISpiLock.h"
#ifdef ARCH_PORTDUINO
#include "PortduinoGlue.h"
#endif
#if defined(ARCH_PORTDUINO) || !defined(HAS_FREE_RTOS)
#include <thread>
#endif
#ifdef SENSECAP_INDICATOR
#include "graphics/map/RemoteSDService.h"
#include "input/I2CKeyboardScanner.h"
#include "mesh/IndicatorSerial.h"
#include "mesh/comms/I2CProxy.h"
// Serves the UI map tiles from the SD card behind the RP2040, chunk-wise
// over the interdevice link.
//
// Retry policy: a lost frame (timeout) and a co-processor busy with card
// maintenance (FILE_BUSY) are transient, so they are retried; a request the
// co-processor refused (nack) or answered definitively (missing file, IO
// error) is not. Correlation ids make retrying safe, a late response to the
// first attempt is dropped as stale.
class IndicatorRemoteFS : public IRemoteFS
{
// A lost frame is retried a couple of times. A co-processor busy with
// card maintenance is a different story: mounting a card takes seconds,
// and the free space scan of a large card walks its whole FAT, so wait
// that out rather than reporting a missing tile.
static const int LINK_ATTEMPTS = 3;
static const int BUSY_ATTEMPTS = 20;
// card state is answered from a cache, so it is only ever busy across a
// mount: a much shorter wait than a file operation has to sit out
static const int INFO_BUSY_ATTEMPTS = 10;
static const uint32_t BUSY_BACKOFF_MS = 250;
// Both budgets are separate and only ever count down: a co-processor that
// stays busy must not be able to keep a caller here for ever. This runs on
// the UI task, an unbounded wait is a frozen screen.
struct Budget {
int attempts = LINK_ATTEMPTS;
int busy = BUSY_ATTEMPTS;
};
// Returns true when the request should be sent again. `answered` is
// false when the link itself failed (timeout), true when the
// co-processor replied.
static bool retryable(bool answered, meshtastic_FileStatus status, Budget &budget)
{
if (!answered) {
if (--budget.attempts <= 0)
return false;
return !sensecapIndicator->last_request_nacked(); // refused, not lost
}
if (status == meshtastic_FileStatus_FILE_BUSY) {
if (--budget.busy <= 0)
return false;
delay(BUSY_BACKOFF_MS);
return true;
}
return false;
}
public:
IndicatorRemoteFS() : result(meshtastic_FileTransfer_init_zero), listing(meshtastic_DirectoryListing_init_zero) {}
bool readChunk(const char *path, uint32_t offset, uint8_t *buf, uint32_t len, uint32_t *bytesRead,
uint32_t *fileSize) override
{
if (!sensecapIndicator)
return false;
Budget budget;
do {
memset(&result, 0, sizeof(result));
bool answered = sensecapIndicator->file_read(path, offset, len, &result);
if (answered && result.status == meshtastic_FileStatus_FILE_OK) {
uint32_t n = result.filedata.size;
if (n > len)
n = len;
memcpy(buf, result.filedata.bytes, n);
*bytesRead = n;
// lv_fs positions are 32 bit, map tiles never come close
*fileSize = (uint32_t)result.file_size;
return true;
}
if (!retryable(answered, result.status, budget))
return false;
} while (true);
}
bool writeChunk(const char *path, uint32_t offset, const uint8_t *buf, uint32_t len, bool create) override
{
if (!sensecapIndicator)
return false;
Budget budget;
bool retried = false;
do {
memset(&result, 0, sizeof(result));
bool answered = sensecapIndicator->file_write(path, offset, buf, len, create, &result);
if (answered) {
if (result.status == meshtastic_FileStatus_FILE_OK)
return true;
// An append whose first attempt landed but whose response was
// lost is refused as an offset conflict, and the file already
// holds this chunk: that is the outcome we wanted
if (retried && !create && result.status == meshtastic_FileStatus_FILE_OFFSET_CONFLICT &&
result.file_size == (uint64_t)offset + len)
return true;
}
if (!retryable(answered, result.status, budget))
return false;
retried = true;
} while (true);
}
bool sdInfo(RemoteSdInfo &info) override
{
if (!sensecapIndicator)
return false;
meshtastic_SdCardInfo sdState = meshtastic_SdCardInfo_init_zero;
// A mount takes up to two seconds. Waiting it out here is worth it (an
// empty slot reported once sticks in the UI), but this runs on the UI
// task, so the wait is bounded well below what a user would call a
// hang, and a card that stays busy longer is simply asked again later.
Budget budget;
budget.busy = INFO_BUSY_ATTEMPTS; // a mount, not a whole FAT scan
while (true) {
sdState = meshtastic_SdCardInfo_init_zero;
bool answered = sensecapIndicator->sd_info(&sdState);
if (answered && !sdState.busy)
break;
meshtastic_FileStatus status = answered ? meshtastic_FileStatus_FILE_BUSY : meshtastic_FileStatus_FILE_UNSPECIFIED;
if (!retryable(answered, status, budget))
return false;
}
info.present = sdState.present;
info.cardType = (uint8_t)sdState.card_type;
info.fatType = (uint8_t)sdState.fat_type;
info.cardSize = sdState.card_size;
info.usedBytes = sdState.used_bytes;
info.freeBytes = sdState.free_bytes;
info.statsValid = sdState.stats_valid;
info.unformatted = sdState.unformatted;
return true;
}
bool sdEject(void) override { return sdCommand(meshtastic_SdCommand_SD_EJECT); }
bool sdMount(void) override { return sdCommand(meshtastic_SdCommand_SD_MOUNT); }
bool sdFormat(void) override
{
// wiping the card takes seconds; the co-processor answers right away
// and mounts the fresh filesystem afterwards
return sdCommand(meshtastic_SdCommand_SD_FORMAT);
}
bool remove(const char *path) override
{
if (!sensecapIndicator)
return false;
Budget budget;
do {
memset(&result, 0, sizeof(result));
bool answered = sensecapIndicator->file_remove(path, &result);
// delete is idempotent on the co-processor: a file that is
// already gone reports OK, so a retry after a lost response does
// not have to be guessed at here
if (answered && result.status == meshtastic_FileStatus_FILE_OK)
return true;
if (!retryable(answered, result.status, budget))
return false;
} while (true);
}
bool listDir(const char *path, std::set<std::string> &entries) override
{
if (!sensecapIndicator)
return false;
uint32_t offset = 0;
while (true) {
bool got_page = false;
Budget budget;
while (!got_page) {
memset(&listing, 0, sizeof(listing));
bool answered = sensecapIndicator->list_directory(path, offset, &listing);
if (answered && listing.status == meshtastic_FileStatus_FILE_OK)
got_page = true;
else if (!retryable(answered, listing.status, budget))
return false;
}
if (!got_page)
return false;
for (pb_size_t i = 0; i < listing.filenames_count; i++)
entries.insert(listing.filenames[i]);
offset += listing.filenames_count;
if (listing.filenames_count == 0 || offset >= listing.total_count)
break;
}
return true;
}
private:
bool sdCommand(meshtastic_SdCommand command)
{
if (!sensecapIndicator)
return false;
meshtastic_SdCardInfo state = meshtastic_SdCardInfo_init_zero;
return sensecapIndicator->sd_command(command, &state);
}
// Several KB each, kept off the UI task stack. All file operations
// originate from the single UI task.
meshtastic_FileTransfer result;
meshtastic_DirectoryListing listing;
};
#endif
DeviceScreen *deviceScreen = nullptr;
#ifndef TFT_TASK_STACK_SIZE
#define TFT_TASK_STACK_SIZE 16384
#endif
#ifdef ARCH_ESP32
// Get notified when the system is entering light sleep
static CallbackObserver<DeviceScreen, void *> *tftSleepObserver = nullptr;
static CallbackObserver<DeviceScreen, esp_sleep_wakeup_cause_t> *endSleepObserver = nullptr;
#endif
/**
* Lends spiLock to device-ui so it can guard its own bus traffic.
*
* This used to be a coarse hold around the whole UI cycle in tft_task_handler(). On
* boards where the TFT, SD card and LoRa radio share one SPI bus (T-Deck), that blocked
* every radio operation on the main loop for an entire render+flush - tens to hundreds
* of milliseconds while the UI animates, felt as mesh RX/TX latency. Most of that time
* is LVGL timer work and rendering into the draw buffer, which touches no SPI at all.
*
* device-ui now takes the lock only around real transfers, so the bus is free during the
* CPU-only majority of a redraw.
*
* Reentrant because ISpiLock requires it: device-ui guards at method granularity, so a
* caller that already holds the bus can reach another guarded method, while spiLock is a
* plain binary semaphore that would self-deadlock on the second take. Track the owning
* thread and only touch the underlying lock on the outermost acquire.
*
* Only the owner writes owner/depth, and only while holding the lock, so no additional
* synchronization is needed: a thread that does not own it either reads some other
* thread's id or the unowned sentinel, and in both cases correctly goes on to block.
*/
class ReentrantSpiLock : public ISpiLock
{
public:
void lock(void) override
{
ThreadId self = currentThread();
if (depth && owner == self) {
depth++;
return;
}
spiLock->lock();
owner = self;
depth = 1;
}
bool lock(uint32_t timeout) override
{
ThreadId self = currentThread();
if (depth && owner == self) {
depth++;
return true;
}
bool result = spiLock->lock(timeout);
owner = self;
depth = 1;
return result;
}
void unlock(void) override
{
if (--depth == 0) {
owner = ThreadId();
spiLock->unlock();
}
}
private:
// Portduino builds have no FreeRTOS, but device-ui runs there too, so the
// reentrancy has to be portable rather than an ESP32-only path.
#ifdef HAS_FREE_RTOS
using ThreadId = TaskHandle_t;
static ThreadId currentThread(void) { return xTaskGetCurrentTaskHandle(); }
#else
using ThreadId = std::thread::id;
static ThreadId currentThread(void) { return std::this_thread::get_id(); }
#endif
ThreadId owner = ThreadId();
uint32_t depth = 0;
};
static ReentrantSpiLock reentrantSpiLock;
void tft_task_handler(void *param = nullptr)
{
while (true) {
// No lock held here on purpose - device-ui guards its own SPI access.
deviceScreen->task_handler();
deviceScreen->sleep();
}
}
void tftSetup(void)
{
#ifdef SENSECAP_INDICATOR
RemoteSDService::setBackend(new IndicatorRemoteFS());
// the second bus is bridged to the RP2040, keep the keyboard scan off the
// uninitialized local Wire1
I2CKeyboardScanner::setSecondaryBus(i2cProxy);
#endif
#ifndef ARCH_PORTDUINO
deviceScreen = &DeviceScreen::create(reentrantSpiLock);
PacketAPI::create(PacketServer::init());
deviceScreen->init(new PacketClient);
#else
if (portduino_config.displayPanel != no_screen) {
DisplayDriverConfig displayConfig;
static char *panels[] = {"NOSCREEN", "X11", "FB", "ST7789", "ST7735", "ST7735S",
"ST7796", "ILI9341", "ILI9342", "ILI9486", "ILI9488", "HX8357D"};
static char *touch[] = {"NOTOUCH", "XPT2046", "STMPE610", "GT911", "FT5x06"};
#if defined(USE_X11)
if (portduino_config.displayPanel == x11) {
if (portduino_config.displayWidth && portduino_config.displayHeight)
displayConfig = DisplayDriverConfig(DisplayDriverConfig::device_t::X11, (uint16_t)portduino_config.displayWidth,
(uint16_t)portduino_config.displayHeight);
else
displayConfig.device(DisplayDriverConfig::device_t::X11);
} else
#elif defined(USE_FRAMEBUFFER)
if (portduino_config.displayPanel == fb) {
// Rotation from yaml Display.OffsetRotate: 1=90, 2=180, 3=270 deg
displayConfig.device(DisplayDriverConfig::device_t::FB)
.panel(DisplayDriverConfig::panel_config_t{.type = panels[portduino_config.displayPanel],
.panel_width = (uint16_t)portduino_config.displayWidth,
.panel_height = (uint16_t)portduino_config.displayHeight,
.offset_rotation = (uint8_t)portduino_config.displayOffsetRotate});
} else
#endif
{
displayConfig.device(DisplayDriverConfig::device_t::CUSTOM_TFT)
.panel(DisplayDriverConfig::panel_config_t{.type = panels[portduino_config.displayPanel],
.panel_width = (uint16_t)portduino_config.displayWidth,
.panel_height = (uint16_t)portduino_config.displayHeight,
.rotation = (bool)portduino_config.displayRotate,
.pin_cs = (int16_t)portduino_config.displayCS.pin,
.pin_rst = (int16_t)portduino_config.displayReset.pin,
.offset_x = (uint16_t)portduino_config.displayOffsetX,
.offset_y = (uint16_t)portduino_config.displayOffsetY,
.offset_rotation = (uint8_t)portduino_config.displayOffsetRotate,
.invert = portduino_config.displayInvert ? true : false,
.rgb_order = (bool)portduino_config.displayRGBOrder,
.dlen_16bit = portduino_config.displayPanel == ili9486 ||
portduino_config.displayPanel == ili9488})
.bus(DisplayDriverConfig::bus_config_t{.freq_write = (uint32_t)portduino_config.displayBusFrequency,
.freq_read = 16000000,
.spi{.pin_dc = (int8_t)portduino_config.displayDC.pin,
.use_lock = true,
.spi_host = (uint16_t)portduino_config.display_spi_dev_int}})
.input(DisplayDriverConfig::input_config_t{.keyboardDevice = portduino_config.keyboardDevice,
.pointerDevice = portduino_config.pointerDevice})
.light(DisplayDriverConfig::light_config_t{.pin_bl = (int16_t)portduino_config.displayBacklight.pin,
.pwm_channel = (int8_t)portduino_config.displayBacklightPWMChannel.pin,
.invert = (bool)portduino_config.displayBacklightInvert});
if (portduino_config.touchscreenI2CAddr == -1) {
displayConfig.touch(
DisplayDriverConfig::touch_config_t{.type = touch[portduino_config.touchscreenModule],
.freq = (uint32_t)portduino_config.touchscreenBusFrequency,
.pin_int = (int16_t)portduino_config.touchscreenIRQ.pin,
.offset_rotation = (uint8_t)portduino_config.touchscreenRotate,
.spi{
.spi_host = (int8_t)portduino_config.touchscreen_spi_dev_int,
},
.pin_cs = (int16_t)portduino_config.touchscreenCS.pin});
} else {
displayConfig.touch(DisplayDriverConfig::touch_config_t{
.type = touch[portduino_config.touchscreenModule],
.freq = (uint32_t)portduino_config.touchscreenBusFrequency,
.x_min = 0,
.x_max = (int16_t)((portduino_config.touchscreenRotate & 1 ? portduino_config.displayWidth
: portduino_config.displayHeight) -
1),
.y_min = 0,
.y_max = (int16_t)((portduino_config.touchscreenRotate & 1 ? portduino_config.displayHeight
: portduino_config.displayWidth) -
1),
.pin_int = (int16_t)portduino_config.touchscreenIRQ.pin,
.offset_rotation = (uint8_t)portduino_config.touchscreenRotate,
.i2c{.i2c_addr = (uint8_t)portduino_config.touchscreenI2CAddr}});
}
}
deviceScreen = &DeviceScreen::create(&displayConfig, &reentrantSpiLock);
PacketAPI::create(PacketServer::init());
deviceScreen->init(new PacketClient);
} else {
LOG_INFO("Running without TFT display");
}
#endif
if (deviceScreen) {
#ifdef ARCH_ESP32
if (!tftSleepObserver) {
tftSleepObserver = new CallbackObserver<DeviceScreen, void *>(deviceScreen, &DeviceScreen::prepareSleep);
}
if (!endSleepObserver) {
endSleepObserver = new CallbackObserver<DeviceScreen, esp_sleep_wakeup_cause_t>(deviceScreen, &DeviceScreen::wakeUp);
}
tftSleepObserver->observe(&notifyLightSleep);
endSleepObserver->observe(&notifyLightSleepEnd);
xTaskCreatePinnedToCore(tft_task_handler, "tft", TFT_TASK_STACK_SIZE, NULL, 1, NULL, 0);
#elif defined(ARCH_PORTDUINO)
std::thread *tft_task = new std::thread([] { tft_task_handler(); });
#endif
}
}
#endif