Files
firmware/src/graphics/tftSetup.cpp
T
4b4e82bd72 SenseCAP Indicator: RP2040 peripherals for the main firmware (#6220)
* indicator: RP2040 peripherals for the main firmware

The SenseCAP Indicator RP2040 co-processor serves as a generic
peripheral bridge over a serial protobuf link (interdevice.proto):

- FakeI2C implements TwoWire and tunnels write and read transactions,
  so the standard sensor drivers and the I2C scan work unmodified on
  the bridged second bus (WIRE1)
- FakeUART forwards GPS NMEA to the regular GPS driver
- SD card access with chunked file transfers, paged directory
  listings and card statistics; device-ui loads map tiles and map
  styles from the card behind the RP2040
- link at 2M baud with 4KB chunks, message structs kept off task
  stacks

Log messages carrying their own bracket tag render it like a thread
name. Replaces the earlier IndicatorSensor/COBS approach.

* indicator: address review

Correlate responses with request ids, serialize the shared TX buffer,
reject oversized frames, fix RX buffer overflow and NMEA truncation,
full-length file paths.

* indicator: assign the GPS FakeUART at runtime

Static initialization order across translation units is undefined,
so createGps() assigns and null-checks the bridged serial instead.
Bound the NMEA length defensively.

* indicator: bump device-ui pin to 27e6c0c

* indicator: ping/pong link probe, non-blocking runOnce, FakeI2C locking

The RP2040 sends nothing unsolicited without a GPS module attached, so
wait_ready now probes with the new ping message instead of listening
passively. runOnce skips its pump while a requester holds link_lock,
keeping the main loop from blocking for a full request timeout. FakeI2C
serializes transactions between the UI task and the main loop with an
owner-tracked lock held from beginTransmission to transaction end.

* indicator: link resync, config-honoring GPS, bridged-bus routing, stats validity

Frame resync scans to the next magic instead of flushing the RX buffer,
and the pump handles all buffered frames per pass. The RX drain reads in
bulk and the protobuf encoder gets the correct buffer bound. GPS honors
the gps_mode setting on the Indicator instead of always running. RTC,
I2C keyboard and motion sensor drivers resolve WIRE1 through
ScanI2CTwoWire::fetchI2CBus so bridged buses reach the right transport.
FakeUART implements flush/availableForWrite/const-write from the Stream
contract and fences its cross-core ring buffer. SdCardInfo.stats_valid
is passed through to device-ui, and the remote FS backend gains the
remove operation used for cleanup of failed tile saves.

* indicator: retry lost link round trips, I2CResult UNSPECIFIED

Remote FS operations retry once on a transport timeout. Correlation ids
drop late responses of the first attempt; a retried append whose first
attempt landed is recognized by the offset conflict carrying the
resulting file size. Definitive failures are not retried, missing-tile
probes stay a single round trip. Regenerated bindings add the
I2CResult.Status UNSPECIFIED zero value so an empty result cannot
decode as success.

* indicator: nack responses, rename bridge classes to I2CProxy/UARTProxy

A request the co-processor cannot decode or handle is nacked, so the
requester fails fast instead of burning its timeout. All requests stage
the shared tx_message under link_lock. FakeI2C and FakeUART are renamed
to I2CProxy and UARTProxy after the pattern they implement, with their
instances following suit. Drops dead code (unused NO_NEWS_PAUSE,
unreachable not-running branches, doubled include guards) and the GPS
pin log line that is meaningless on the tunneled port.

* indicator: refuse a co-processor that speaks another protocol version

The ping/pong handshake now carries InterdeviceVersion. A pong reporting
a version other than ours means the RP2040 runs firmware that does not
match this build, so the bridge stays shut down for the session and the
mismatch is logged with both versions. Requests fail fast instead of
being misinterpreted by the other side.

* indicator: regen protos, interdevice protocol version 2

* indicator: per-task I2C contexts, gated handshake, retryable link failures

The bridged I2C bus is shared between the main loop and the UI task, and
TwoWire has no transaction bracket a lock can span: drivers drain the read
buffer with available()/read() long after requestFrom() returned. Each
calling task therefore gets its own staging and read buffers instead of a
lock that could be left held (or that could not protect the read buffer
anyway). The transaction is staged inside the link, under its lock.

No request is sent before the co-processor has completed the version
handshake, and runOnce keeps probing until it does, so a co-processor that
boots slowly or reboots on its watchdog no longer leaves the bridge dead
for the session. Requests in flight are counted, not flagged: two threads
can be in a request and the first one out must not clear the other's state.

File operations are retried on a lost frame and on a co-processor busy with
card maintenance, but not on a refusal (nack) or a definitive failure, and
they release the SPI lock while they wait so a slow link does not starve
the radio.

* indicator: fail safe on a peer mismatch, wait out card maintenance

FileStatus moved to a fresh tag: reusing the tag of the removed success flag
made every failure status decode as success on a peer that predates it.

A card being mounted (busy) is retried rather than reported as an empty
slot, and a co-processor busy with card maintenance is waited out: mounting
takes seconds and the free space scan of a large card walks its whole FAT,
which is not a reason to report a missing tile. The bridged I2C bus releases
the SPI lock as well, so the keyboard scan on the UI task cannot starve the
radio either. Slot claims in the I2C proxy are atomic, NMEA is not sent to a
peer we refuse to talk to, and the handshake is completed by the unsolicited
ping the co-processor sends when it has booted, which also reports a
reboot.

* indicator: regen protos, FileStatus back on the original tags

* indicator: regen protos, ping/pong carry the InterdeviceVersion enum

* indicator: point the protobufs submodule at the merged interdevice protos

* indicator: pin device-ui to the branch with the remote SD support

* indicator: honor the txOnly flag of flush, report dropped GPS writes

flush() through a Stream pointer discarded the receive buffer: the flag is
txOnly, and HardwareSerial::flush() keeps what has been received. write()
reported bytes as written even when the link refused to send them. The link
probe uses Throttle for its rate limit.

* indicator: decide the log tag on the formatted message, hex request ids

The thread tag was suppressed based on the printf template, which disagrees
with the rendered message it is compared against: a format starting with a
conversion could produce two tags, and one without a trailing bracket-space
lost the tag entirely. vprintf now receives the thread name and picks. Also
shifts only the bytes actually buffered after a frame, throttles with
Throttle and logs request ids as hex.

* indicator: SD mount, eject and format commands over the link

* indicator: bound how long a busy card state blocks the UI task

* indicator: a busy co-processor must not block the UI task for ever

The busy retry re-armed its own budget on every busy answer, so a
co-processor that stayed busy kept the caller in the loop with no way out.
Transport retries and the wait for a busy card are now separate budgets that
only count down.

* indicator: start each request from an aligned receive buffer

A byte run lost mid-response (a UART overflow during a 4KB tile chunk, when
the display starves the RX interrupt) misaligns the assembly buffer. The
buffer was never reset, so the poison outlived the request and cascaded into
the following chunks of the same tile: one glitch dropped a whole multi-chunk
tile, while single-chunk tiles resynced in the idle gap and survived. Each
request now flushes the buffer first, bounding a glitch to the one chunk it
hit. Adds resync/decode/timeout counters, logged rarely, to see the rate.

* indicator: enlarge the LVGL heap for low-zoom map tiles

The heap was 3MB and the image cache reserves 1.5MB of it, so a low-zoom map
tile could not find a large enough contiguous block to decode and rendered
white. 5MB of the 8MB PSRAM fixes it with room to spare.

* indicator: advance the device-ui and protobufs pins to the merged commits

Point the protobufs submodule at the merged SD command protos (protobufs
#986) so it matches the checked in interdevice sources, and bump the
device-ui archive to the current indicator branch tip that carries the SD
button and format UI.

* Update device-ui library dependency URL

* remove cutom sdkconfig

* remove duplicated synchronisation (after PR11278 is in place)

* set commit reference to updated RemoteSDService class

* Add board_level configuration for release

* fix cppcheck errors

---------

Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: mverch67 <manuel.verch@gmx.de>
2026-08-01 16:23:30 +00:00

425 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 <cstdio>
#include <cstdlib>
#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
CallbackObserver<DeviceScreen, void *> tftSleepObserver =
CallbackObserver<DeviceScreen, void *>(deviceScreen, &DeviceScreen::prepareSleep);
CallbackObserver<DeviceScreen, esp_sleep_wakeup_cause_t> endSleepObserver =
CallbackObserver<DeviceScreen, esp_sleep_wakeup_cause_t>(deviceScreen, &DeviceScreen::wakeUp);
#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;
}
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
char rbuf[4];
snprintf(rbuf, sizeof(rbuf), "%d", portduino_config.displayRotate ? (portduino_config.displayOffsetRotate & 3) : 0);
if (setenv("MESHTASTIC_FB_ROTATION", rbuf, 1) != 0)
LOG_ERROR("Failed to set MESHTASTIC_FB_ROTATION, framebuffer will use its default rotation");
if (portduino_config.displayWidth && portduino_config.displayHeight)
displayConfig = DisplayDriverConfig(DisplayDriverConfig::device_t::FB, (uint16_t)portduino_config.displayWidth,
(uint16_t)portduino_config.displayHeight);
else
displayConfig.device(DisplayDriverConfig::device_t::FB);
} 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
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