#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 #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 &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 *tftSleepObserver = nullptr; static CallbackObserver *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, &DeviceScreen::prepareSleep); } if (!endSleepObserver) { endSleepObserver = new CallbackObserver(deviceScreen, &DeviceScreen::wakeUp); } tftSleepObserver->observe(¬ifyLightSleep); endSleepObserver->observe(¬ifyLightSleepEnd); 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