Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions products/go/docs/display_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ bool init(const DisplayValues &initial, bool defer_refresh = false);
| `defer_refresh` | Behavior |
|---|---|
| `false` (default) | Synchronous: renders frame, performs full SPI refresh (~3 s), then starts worker. Used by `run_interactive()` and `run_fast_path()`. |
| `true` | Deferred: renders frame into buffer, copies to SPI buffer, marks full refresh pending, starts worker and immediately signals it to run the initial refresh in the background. Returns in ~10 ms. |
| `true` | Deferred: renders and reserves the framebuffer, marks a full refresh pending, starts the worker, and immediately signals it to run the initial refresh in the background. Returns in ~10 ms. |

When `defer_refresh=true`, `init()` returns before the SPI refresh begins.
The worker task acquires the SPI bus and holds it for the duration of the
Expand Down Expand Up @@ -184,25 +184,32 @@ Key points:

## Architecture

### Dual-Buffer Pattern
### Framebuffer Ownership

| Buffer | Size | Context | Purpose |
|---|---|---|---|
| `_render_buf[4096]` | 4096 B | Orchestrator thread | u8g2 render target |
| `_spi_buf[4096]` | 4096 B | Worker task | SPI transmit source |
| `_region_buf[3712]` | 3712 B | Worker task | Body region for partial writes |
| `_render_buf[4096]` | 4096 B | Orchestrator, then worker | u8g2 render target and immutable worker source |
| DMA bounce buffer | 4096 B | Display driver | DMA-capable source for polling SPI transfers |

On each `update()`, the render buffer is `memcpy`'d to the SPI buffer before
signaling the worker. The orchestrator can re-render freely without corrupting
an in-progress SPI transfer.
`update()` claims the framebuffer before rendering and transfers ownership to
the worker through its task notification. Until the worker clears
`_worker_busy`, another non-waiting update is rejected and a waiting update
blocks. This keeps the framebuffer immutable while Full and Fast refreshes
read it for both SSD1680 RAM planes.

Partial updates use a direct full-width framebuffer span. Body updates pass
bytes 288–3999 (rows 18–249, 3712 bytes); setup-session updates pass bytes
0–3999 (the full 128×250 canvas). The driver copies either span into the DMA
bounce buffer before its synchronous SPI transfer, so no intermediate region
buffer is required.

### Async Worker Task

The worker task waits on an RTOS task notification, then drives the SPI
hardware. The orchestrator signals frame-ready via `RTOS::task_notify_give()`.
A `volatile bool _worker_busy` flag allows the orchestrator to check if the
worker is available (`wait=false` returns false if busy; `wait=true` spins
until ready).
An atomic `_worker_busy` flag owns the framebuffer across tasks
(`wait=false` returns false if busy; `wait=true` waits until it can claim the
buffer).

In the deferred-refresh mode (`defer_refresh=true`), `init()` itself signals
the worker with the initial full-refresh job before returning. This means the
Expand Down
118 changes: 57 additions & 61 deletions products/go/main/go_display.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,7 @@ void draw_shutdown_text(u8g2_t *u, const char *title_l1, const char *title_l2, c
}

// ---------------------------------------------------------------------------
// Session-screen helpers (Info / Provisioning / ProvisioningConfirm)
// Full-canvas session-screen helpers
// ---------------------------------------------------------------------------

inline bool is_session_screen(Screen s) {
Expand Down Expand Up @@ -1115,8 +1115,8 @@ void draw_list_rows(u8g2_t *u, const DisplayValues &v, bool full_screen) {
// ===========================================================================

DisplayService::DisplayService(const Config &config)
: _config(config), _u8g2{}, _render_buf{}, _spi_buf{}, _region_buf{}, _prev_values{},
_diff_count(0), _pending_mode(RefreshMode::Full), _task_handle(nullptr), _running(false),
: _config(config), _u8g2{}, _render_buf{}, _prev_values{}, _diff_count(0),
_pending_mode(RefreshMode::Full), _task_handle(nullptr), _running(false),
_worker_busy(false) {}

bool DisplayService::init(const DisplayValues &initial, bool defer_refresh) {
Expand Down Expand Up @@ -1155,28 +1155,27 @@ bool DisplayService::init(const DisplayValues &initial, bool defer_refresh) {
}
_diff_count = 0;
} else {
// Deferred refresh: render is already in _render_buf. Copy to _spi_buf,
// mark a full refresh pending, then start the worker and signal it to
// perform the initial refresh in the background. Returns in ~10 ms.
// Deferred refresh: render is already in _render_buf. Reserve it for the
// worker, then signal the initial full refresh. Returns in ~10 ms.
// The worker acquires the SPI bus for ~3 s; any other SPI device (NAND)
// that tries to transmit will block until the worker releases the bus.
memcpy(_spi_buf, _render_buf, sizeof(_render_buf));
_pending_mode = RefreshMode::Full;
_worker_busy = true; // will be cleared by worker when refresh completes
_worker_busy.store(true); // cleared by worker when refresh completes
// The _prev_values header now reflects the snapshot-based initial frame,
// which may differ from the live runtime state the orchestrator produces.
// Skip the header-change penalty on the first update()
_defer_header_check = true;
}

// Start async worker task.
_running = true;
_running.store(true);
const bool created = RTOS::task_create(
_worker_entry, "disp_worker", static_cast<uint32_t>(_config.task_stack_size), this,
static_cast<uint32_t>(_config.task_priority), &_task_handle);
if (!created) {
ESP_LOGE(TAG, "failed to create worker task");
_running = false;
_running.store(false);
_worker_busy.store(false);
_task_handle = nullptr;
return false;
}
Expand All @@ -1190,16 +1189,11 @@ bool DisplayService::init(const DisplayValues &initial, bool defer_refresh) {
}

bool DisplayService::update(const DisplayValues &values, bool wait) {
if (_task_handle == nullptr)
if (_task_handle == nullptr || !_running.load())
return false;

if (_worker_busy) {
if (!wait)
return false;
// One RTOS tick (10 ms at the project tick rate); sub-tick delays round up.
while (_worker_busy) {
RTOS::delay_ms(10);
}
if (!_claim_framebuffer(wait)) {
return false;
}

const bool same_list_screen =
Expand All @@ -1209,12 +1203,9 @@ bool DisplayService::update(const DisplayValues &values, bool wait) {
const bool both_navigable = is_navigable(_prev_values.screen) && is_navigable(values.screen);
const bool can_partial = (both_navigable && !header_changed) || same_list_screen;

// Session-screen refresh policy keys off the triple {Info, Provisioning,
// ProvisioningConfirm}. Crossing the session boundary forces a full
// refresh so no ghosting from the prior layout remains. All in-session
// transitions use partial refresh — the worker drives the full canvas
// (y=0..249) for session screens so Info text under a subsequent
// Provisioning / ProvisioningConfirm layout is cleared cleanly without
// Crossing the full-canvas session boundary forces a full refresh so no
// ghosting from the prior layout remains. All in-session transitions use
// partial refresh over y=0..249 so disjoint layouts clear cleanly without
// the Full waveform's ~3 s flash.
const bool prev_in_session = is_session_screen(_prev_values.screen);
const bool next_in_session = is_session_screen(values.screen);
Expand All @@ -1237,7 +1228,7 @@ bool DisplayService::update(const DisplayValues &values, bool wait) {
} else if (prev_in_session && next_in_session) {
// Intra-session transitions (Info text update, Provisioning status
// change, Provisioning <-> ProvisioningConfirm, No <-> Yes) are always
// body-only partial refreshes. The partial-op counter is NOT
// full-canvas partial refreshes. The partial-op counter is NOT
// consulted inside the session.
_pending_mode = RefreshMode::Partial;
_menu_exited = false;
Expand All @@ -1259,20 +1250,20 @@ bool DisplayService::update(const DisplayValues &values, bool wait) {
_menu_exited = false;
}

memcpy(_spi_buf, _render_buf, sizeof(_render_buf));
_worker_busy = true;
_prev_values = values;

RTOS::task_notify_give(_task_handle);
return true;
}

void DisplayService::update_sync(const DisplayValues &values) {
(void)_claim_framebuffer(true);
_render_frame(values);

esp_err_t err = driver_bus_acquire();
if (err != ESP_OK) {
ESP_LOGE(TAG, "bus acquire failed for sync update: %s", esp_err_to_name(err));
_release_framebuffer();
return;
}
err = driver_hw_init_full();
Expand All @@ -1286,41 +1277,36 @@ void DisplayService::update_sync(const DisplayValues &values) {
}
_diff_count = 0;
_prev_values = values;
_release_framebuffer();
}

void DisplayService::flush() {
// Spin until the worker finishes its current job (if any). Same cheap
// polling pattern as clear()/stop().
while (_worker_busy) {
RTOS::delay_ms(1);
while (_worker_busy.load()) {
RTOS::delay_ms(WORKER_POLL_MS);
}
}

void DisplayService::clear() {
(void)_claim_framebuffer(true);
memset(_render_buf, 0xFF, sizeof(_render_buf));

// Wait for worker to finish if active
while (_worker_busy) {
RTOS::delay_ms(1);
}

memcpy(_spi_buf, _render_buf, sizeof(_render_buf));

esp_err_t err = driver_bus_acquire();
if (err != ESP_OK) {
ESP_LOGE(TAG, "bus acquire failed for clear: %s", esp_err_to_name(err));
_release_framebuffer();
return;
}
err = driver_hw_init_full();
if (err == ESP_OK) {
err = driver_set_basemap(_spi_buf);
err = driver_set_basemap(_render_buf);
}
driver_bus_release();

if (err != ESP_OK) {
ESP_LOGE(TAG, "clear failed: %s", esp_err_to_name(err));
}
_diff_count = 0;
_release_framebuffer();
}

void DisplayService::deep_sleep() {
Expand All @@ -1340,17 +1326,30 @@ void DisplayService::deep_sleep() {
void DisplayService::stop() {
if (_task_handle == nullptr)
return;
_running = false;

// Complete the pending frame before asking the idle worker to exit.
flush();
_running.store(false);
RTOS::task_notify_give(_task_handle); // Wake worker so it can exit
RTOS::delay_ms(50); // Let worker task fully exit
_task_handle = nullptr;
}

// Wait for worker to finish current operation
while (_worker_busy) {
RTOS::delay_ms(1);
bool DisplayService::_claim_framebuffer(bool wait) {
while (true) {
bool expected = false;
if (_worker_busy.compare_exchange_strong(expected, true)) {
return true;
}
if (!wait) {
return false;
}
RTOS::delay_ms(WORKER_POLL_MS);
}
RTOS::delay_ms(50); // Let worker task fully exit
_task_handle = nullptr;
}

void DisplayService::_release_framebuffer() { _worker_busy.store(false); }

// ===========================================================================
// DisplayService — rendering pipeline
// ===========================================================================
Expand Down Expand Up @@ -2205,31 +2204,31 @@ void DisplayService::_worker_entry(void *arg) {
}

void DisplayService::_worker_loop() {
while (_running) {
while (true) {
// Block until the orchestrator signals frame-ready.
RTOS::task_notify_take(UINT32_MAX);
if (!_running)
if (!_running.load())
break;

esp_err_t err = driver_bus_acquire();
if (err != ESP_OK) {
ESP_LOGE(TAG, "worker: bus acquire failed: %s", esp_err_to_name(err));
_worker_busy = false;
_release_framebuffer();
continue;
}

switch (_pending_mode) {
case RefreshMode::Full:
err = driver_hw_init_full();
if (err == ESP_OK)
err = driver_set_basemap(_spi_buf);
err = driver_set_basemap(_render_buf);
_diff_count = 0;
break;

case RefreshMode::Fast:
err = driver_hw_init_fast();
if (err == ESP_OK)
err = driver_fast_write(_spi_buf);
err = driver_fast_write(_render_buf);
if (err == ESP_OK)
err = driver_fast_commit();
if (_diff_count < UINT8_MAX) {
Expand All @@ -2240,16 +2239,13 @@ void DisplayService::_worker_loop() {
case RefreshMode::Partial:
err = driver_part_begin();
if (err == ESP_OK) {
// Session screens (Info / Provisioning / ProvisioningConfirm) own
// the full canvas — the title region (y=0..17) is part of their
// layout, so a body-only partial would leave the prior frame's
// pixels there ghosting under the new content. Push the whole
// 128x250 frame for those; everything else stays body-only.
const bool session = is_session_screen(_prev_values.screen);
const int y0 = session ? 0 : BODY_Y;
const int h = session ? FULL_H : BODY_H;
memcpy(_region_buf, _spi_buf + y0 * BUF_ROW_BYTES, h * BUF_ROW_BYTES);
err = driver_part_write_region(0, y0, _region_buf, h, SCREEN_W);
// Full-canvas session screens own the title region (y=0..17), so a
// body-only partial would leave prior pixels there. Everything else
// stays body-only.
const auto region =
go_display_geometry::partial_region(is_session_screen(_prev_values.screen));
err = driver_part_write_region(0, region.y, _render_buf + region.byte_offset, region.height,
SCREEN_W);
}
if (err == ESP_OK)
err = driver_part_commit();
Expand All @@ -2264,7 +2260,7 @@ void DisplayService::_worker_loop() {
}

driver_bus_release();
_worker_busy = false;
_release_framebuffer();
}
}

Expand Down
30 changes: 11 additions & 19 deletions products/go/main/go_display.h
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#ifndef GO_DISPLAY_H
#define GO_DISPLAY_H

#include <atomic>
#include <cstdint>

#include "go_display_geometry.h"
#include "measures_types.h"
#include "rtos.h"
#include "services/provisioning_qr.h"
Expand Down Expand Up @@ -290,9 +292,7 @@ class DisplayService {
void update_sync(const DisplayValues &values);

/// Wait until the most-recently-queued frame has finished painting.
/// Returns immediately when the worker is idle. Cheap polling loop
/// using RTOS::delay_ms(1), mirroring the existing clear()/stop()
/// busy-wait pattern.
/// Returns immediately when the worker is idle. Polls once per RTOS tick.
///
/// Must NOT be called from the display worker task itself
/// (self-deadlocks because _worker_busy clears only when the worker
Expand All @@ -310,27 +310,16 @@ class DisplayService {
void stop();

private:
static constexpr int BUF_ROW_BYTES = 16;
static constexpr int BUF_TILE_HEIGHT = 32;
static constexpr int BUF_SIZE = BUF_ROW_BYTES * BUF_TILE_HEIGHT * 8; // 4096
static constexpr int BODY_Y = 18;
static constexpr int BODY_H = 232;
// Sized to the full canvas (128x250) so session screens can run a
// whole-screen partial refresh and avoid title-region ghosting. Non-
// session screens still copy only the body slice (BODY_H rows).
static constexpr int FULL_H = 250;
static constexpr int REGION_SIZE = BUF_ROW_BYTES * FULL_H; // 4000
static constexpr int BUF_TILE_HEIGHT = static_cast<int>(go_display_geometry::RENDER_ROWS / 8);
static constexpr size_t BUF_SIZE = go_display_geometry::RENDER_BYTES;
static constexpr uint32_t WORKER_POLL_MS = 10;

Config _config;

// u8g2 instance and render buffer
u8g2_t _u8g2;
uint8_t _render_buf[BUF_SIZE];

// SPI transmit buffer (owned by worker after signal)
uint8_t _spi_buf[BUF_SIZE];
uint8_t _region_buf[REGION_SIZE];

// Refresh state
DisplayValues _prev_values;
uint8_t _diff_count = 0;
Expand All @@ -340,8 +329,11 @@ class DisplayService {

// Worker task
RtosTaskHandle _task_handle = nullptr;
volatile bool _running = false;
volatile bool _worker_busy = false;
std::atomic<bool> _running{false};
std::atomic<bool> _worker_busy{false};

bool _claim_framebuffer(bool wait);
void _release_framebuffer();

// Render methods
void _render_frame(const DisplayValues &v);
Expand Down
Loading
Loading