From dc35d51ba2b9b80627d1223318212108ebf647fe Mon Sep 17 00:00:00 2001 From: LM Date: Sun, 6 Sep 2026 16:25:44 -0400 Subject: [PATCH 1/3] refactor: derive the radar layout from the panel size at runtime Every length in radar_theme.h was a constexpr tuned for a 240x240 panel. They are now variables that initMetrics() computes from tft.width() and tft.height(), scaled against a 240 reference, so a different panel gets the same design rather than a second set of hand-tuned constants. Values that are not lengths -- the ring count, the track horizon in seconds, the reference range in km, plain ratios -- stay constexpr. The k-prefixed names and their storage in the header keep the shape the colours in this file have always had, so no call site changes. status_screens.cpp gets the same treatment: centre, spinner radius, text panel width, line gaps and VLW point sizes all scale. No behaviour change on the 240x240 build. Co-Authored-By: Claude Opus 5 (1M context) --- include/ui/radar_theme.h | 87 ++++++++++++++++++---------- src/hardware/display.cpp | 4 ++ src/ui/radar_theme.cpp | 118 ++++++++++++++++++++++++++++++++++++++ src/ui/status_screens.cpp | 94 +++++++++++++++++++++--------- 4 files changed, 245 insertions(+), 58 deletions(-) create mode 100644 src/ui/radar_theme.cpp diff --git a/include/ui/radar_theme.h b/include/ui/radar_theme.h index 2d763a638..83e996197 100644 --- a/include/ui/radar_theme.h +++ b/include/ui/radar_theme.h @@ -4,63 +4,90 @@ namespace ui::radar { -constexpr int kSize = 240; -constexpr int kCenterX = kSize / 2; -constexpr int kCenterY = kSize / 2; +/** + * Radar layout. + * + * Lengths were chosen against a 240x240 panel and are scaled at runtime by + * initMetrics() to the panel actually fitted, so they are variables rather than + * constants — the same shape the colours below have always had. Values that are + * not lengths (counts, times, km, ratios) stay constexpr. + */ + +/** The panel this layout was designed on; every scaled value is a ratio of it. */ +constexpr int kReferenceSize = 240; + +/** + * Recompute the layout for a panel of this size. Call once, after `tft.init()` + * and before anything draws. `panel_width`/`panel_height` come from + * `tft.width()` / `tft.height()` rather than from `config.h`, so a panel that + * reports a size different from the configured one still lays out correctly. + */ +void initMetrics(int panel_width, int panel_height); + +/** Scale actually applied, = min(width, height) / kReferenceSize. 1.0 on 240px. */ +extern float kScale; + +/** Square extent of the radar, = min(panel width, panel height). */ +extern int kSize; +extern int kCenterX; +extern int kCenterY; /** Outermost grid ring (inside edge labels). */ -constexpr int kGridOuterRadius = 107; +extern int kGridOuterRadius; /** N: offset from top edge (top_center, negative = up). */ -constexpr int kCardinalNorthOffsetY = -1; +extern int kCardinalNorthOffsetY; /** S: offset from bottom edge (bottom_center, positive = down). */ -constexpr int kCardinalSouthOffsetY = 3; +extern int kCardinalSouthOffsetY; /** Gap between scale label right edge and outer ring on the east spoke (px). */ -constexpr int kScaleGapFromOuterRing = 6; +extern int kScaleGapFromOuterRing; /** Target cap height (px) for N/S/E/W. */ -constexpr int kCardinalLabelHeightPx = 14; +extern int kCardinalLabelHeightPx; /** Scale label is this many px shorter than cardinals. */ -constexpr int kScaleBelowCardinalPx = 3; +extern int kScaleBelowCardinalPx; +/** A count, not a length — the same four rings at any panel size. */ constexpr int kRingCount = 4; -/** Shared grid stroke: drawWideLine half-width (~2 px total); rings use the same px count. */ -constexpr float kGridStrokeHalfWidth = 1.0f; +/** Shared grid stroke: drawWideLine half-width; rings use the same px count. */ +extern float kGridStrokeHalfWidth; -constexpr int kCenterDotRadius = 2; +extern int kCenterDotRadius; /** Filled aircraft symbol (nose triangle). */ -constexpr int kAircraftNoseLenPx = 8; -constexpr int kAircraftTailLenPx = 3; -constexpr int kAircraftTailHalfPx = 4; +extern int kAircraftNoseLenPx; +extern int kAircraftTailLenPx; +extern int kAircraftTailHalfPx; + /** Track vector: ground distance covered in this many seconds at current gs. */ constexpr float kAircraftTrackHorizonSec = 60.0f; -/** Minimum visible vector when gs > 0 (px). */ -constexpr int kAircraftSpeedLineMinPx = 2; /** Track line length uses this outer_km, not the active range preset. */ constexpr float kAircraftTrackRefOuterKm = 13.3f; -/** Shorter than full 60 s horizon at ref scale; ×1.5 length boost applied. */ +/** Shorter than full 60 s horizon at ref scale; x1.5 length boost applied. */ constexpr float kAircraftTrackLengthScale = 1.5f / 5.0f; -/** drawWideLine half-width for speed vectors (~2 px total). */ -constexpr float kAircraftTrackLineHalfWidth = 1.0f; -constexpr float kRunwayLineWidthPx = 2.0f; -constexpr float kRunwayLineHalfWidth = kRunwayLineWidthPx * 0.5f; -constexpr int kRunwayLabelHeightPx = kCardinalLabelHeightPx; -constexpr int kRunwayLabelGapPx = 3; +/** Minimum visible vector when gs > 0 (px). */ +extern int kAircraftSpeedLineMinPx; +/** drawWideLine half-width for speed vectors. */ +extern float kAircraftTrackLineHalfWidth; + +extern float kRunwayLineWidthPx; +extern float kRunwayLineHalfWidth; +extern int kRunwayLabelHeightPx; +extern int kRunwayLabelGapPx; + /** Gap from triangle edge to tag block (px). */ -constexpr int kAircraftLabelGapPx = 1; +extern int kAircraftLabelGapPx; /** Keep symbol centroid inside outer ring by at least this inset (px). */ -constexpr int kAircraftInsideRingInsetPx = - kAircraftNoseLenPx + kAircraftTailHalfPx + 1; +extern int kAircraftInsideRingInsetPx; /** Beyond-ring traffic: bearing cues on screen rim (correct direction, fixed radius). */ -constexpr int kBeyondRingDotRadiusPx = 4; -constexpr int kBeyondRingScreenMarginPx = 2; +extern int kBeyondRingDotRadiusPx; +extern int kBeyondRingScreenMarginPx; /** Target cap height (px) for aircraft tags (bold, slightly above scale label). */ -constexpr int kAircraftTagLabelHeightPx = 13; +extern int kAircraftTagLabelHeightPx; /** RGB565 palette targets (applied in initPalette). */ constexpr uint8_t kBgR = 4; diff --git a/src/hardware/display.cpp b/src/hardware/display.cpp index 42f82524d..ff224504c 100644 --- a/src/hardware/display.cpp +++ b/src/hardware/display.cpp @@ -1,6 +1,7 @@ #include "hardware/display.h" #include "hardware/display_font.h" +#include "ui/radar_theme.h" LGFX tft; @@ -10,4 +11,7 @@ void displayInit() { tft.setBrightness(255); tft.setTextWrap(false); displayFontInit(); + // Panel size comes from the driver rather than config.h, so the layout + // follows whichever panel is actually fitted. + ui::radar::initMetrics(tft.width(), tft.height()); } diff --git a/src/ui/radar_theme.cpp b/src/ui/radar_theme.cpp new file mode 100644 index 000000000..2b00707a8 --- /dev/null +++ b/src/ui/radar_theme.cpp @@ -0,0 +1,118 @@ +#include "ui/radar_theme.h" + +#include + +namespace ui::radar { + +// Defaults are the reference design, so anything that draws before +// initMetrics() runs still lays out correctly on a 240x240 panel. +float kScale = 1.0f; + +int kSize = kReferenceSize; +int kCenterX = kReferenceSize / 2; +int kCenterY = kReferenceSize / 2; + +int kGridOuterRadius = 107; + +int kCardinalNorthOffsetY = -1; +int kCardinalSouthOffsetY = 3; + +int kScaleGapFromOuterRing = 6; + +int kCardinalLabelHeightPx = 14; +int kScaleBelowCardinalPx = 3; + +float kGridStrokeHalfWidth = 1.0f; + +int kCenterDotRadius = 2; + +int kAircraftNoseLenPx = 8; +int kAircraftTailLenPx = 3; +int kAircraftTailHalfPx = 4; + +int kAircraftSpeedLineMinPx = 2; +float kAircraftTrackLineHalfWidth = 1.0f; + +float kRunwayLineWidthPx = 2.0f; +float kRunwayLineHalfWidth = 1.0f; +int kRunwayLabelHeightPx = 14; +int kRunwayLabelGapPx = 3; + +int kAircraftLabelGapPx = 1; +int kAircraftInsideRingInsetPx = 8 + 4 + 1; + +int kBeyondRingDotRadiusPx = 4; +int kBeyondRingScreenMarginPx = 2; +int kAircraftTagLabelHeightPx = 13; + +namespace { + +/** + * Scale a reference-design length to the fitted panel. lroundf rather than a + * cast, which would truncate half a pixel off every value; a length must not + * round away to nothing, or a stroke or dot disappears. + */ +static inline int scaled(int reference_px) { + const int value = + static_cast(lroundf(static_cast(reference_px) * kScale)); + if (reference_px > 0 && value < 1) { + return 1; + } + if (reference_px < 0 && value > -1) { + return -1; + } + return value; +} + +static inline float scaledF(float reference_px) { + return reference_px * kScale; +} + +} // namespace + +void initMetrics(int panel_width, int panel_height) { + // The radar is round, so it lives in the largest square the panel can hold. + kSize = (panel_width < panel_height) ? panel_width : panel_height; + kCenterX = panel_width / 2; + kCenterY = panel_height / 2; + kScale = static_cast(kSize) / static_cast(kReferenceSize); + + kGridOuterRadius = scaled(107); + + kCardinalNorthOffsetY = scaled(-1); + kCardinalSouthOffsetY = scaled(3); + + kScaleGapFromOuterRing = scaled(6); + + kCardinalLabelHeightPx = scaled(14); + kScaleBelowCardinalPx = scaled(3); + + kGridStrokeHalfWidth = scaledF(1.0f); + + kCenterDotRadius = scaled(2); + + kAircraftNoseLenPx = scaled(8); + kAircraftTailLenPx = scaled(3); + kAircraftTailHalfPx = scaled(4); + + kAircraftSpeedLineMinPx = scaled(2); + kAircraftTrackLineHalfWidth = scaledF(1.0f); + + kRunwayLineWidthPx = scaledF(2.0f); + kRunwayLabelGapPx = scaled(3); + + kAircraftLabelGapPx = scaled(1); + + kBeyondRingDotRadiusPx = scaled(4); + kBeyondRingScreenMarginPx = scaled(2); + kAircraftTagLabelHeightPx = scaled(13); + + // Derived from the values above, keeping the original formulas rather than + // scaling their results — the +1 below is a one-pixel safety margin that the + // reference design applied after the symbol arithmetic, not before it. + kRunwayLineHalfWidth = kRunwayLineWidthPx * 0.5f; + kRunwayLabelHeightPx = kCardinalLabelHeightPx; + kAircraftInsideRingInsetPx = kAircraftNoseLenPx + kAircraftTailHalfPx + 1; +} + +} // namespace ui::radar diff --git a/src/ui/status_screens.cpp b/src/ui/status_screens.cpp index af3713516..803203087 100644 --- a/src/ui/status_screens.cpp +++ b/src/ui/status_screens.cpp @@ -10,17 +10,51 @@ #include "config.h" #include "hardware/display.h" #include "hardware/display_font.h" +#include "ui/radar_theme.h" namespace { -constexpr int kLineGap = 6; -const int kCenterX = config::kDisplayWidth / 2; -const int kCenterY = config::kDisplayHeight / 2; +/** + * Laid out on the 240x240 reference panel, like the radar. Functions rather + * than constants because the panel size is only known once tft is initialised, + * which is when ui::radar::kScale is filled in. + */ +static inline int scaled(int reference_px) { + const int value = + static_cast(std::lround(reference_px * ui::radar::kScale)); + return (value < 1) ? 1 : value; +} + +static inline int centerX() { + return tft.width() / 2; +} + +static inline int centerY() { + return tft.height() / 2; +} + +static inline int lineGap() { + return scaled(6); +} + +static inline int spinnerRadius() { + return scaled(113); +} + +static inline int spinnerDotRadius() { + return scaled(2); +} + +static inline int spinnerEraseRadius() { + return scaled(4); +} + +static inline int connectingTextMaxWidthPx() { + return scaled(220); +} +/** A count and an angle — neither moves with the panel. */ constexpr int kSpinnerDotCount = 10; -constexpr int kSpinnerRadius = 113; -constexpr int kSpinnerDotRadius = 2; -constexpr int kSpinnerEraseRadius = 4; constexpr float kSpinnerStepDeg = 6.0f; struct SpinnerDot { @@ -31,7 +65,6 @@ struct SpinnerDot { char s_connecting_ssid[33]; char s_ssid_line[33]; -constexpr int kConnectingTextMaxWidthPx = 220; float s_spinner_angle_deg = -90.0f; SpinnerDot s_spinner_dots[kSpinnerDotCount]; bool s_connecting_text_drawn = false; @@ -62,7 +95,10 @@ int lineHeightVlw(float size) { void applyLineStyle(const TextLine& line) { if (displayFontIsSmooth()) { - displayFontSetSmoothSize(tft, line.vlw_size); + // VLW sizes are a multiple of the font's point size, so scaling them keeps + // text the same fraction of the screen on a larger panel. The bitmap + // fallback below cannot scale continuously and is left alone. + displayFontSetSmoothSize(tft, line.vlw_size * ui::radar::kScale); } else { displayFontSetBitmap(tft, line.gfx_font); } @@ -81,18 +117,18 @@ void drawTextBlock(uint16_t bg, uint16_t fg, const TextLine* lines, size_t count total_h += lineHeightGfx(lines[i].gfx_font); } if (i + 1 < count) { - total_h += kLineGap; + total_h += lineGap(); } } - int y = (config::kDisplayHeight - total_h) / 2; + int y = (tft.height() - total_h) / 2; for (size_t i = 0; i < count; ++i) { applyLineStyle(lines[i]); const int h = displayFontIsSmooth() ? lineHeightVlw(lines[i].vlw_size) : lineHeightGfx(lines[i].gfx_font); - tft.drawString(lines[i].text, kCenterX, y + h / 2); - y += h + kLineGap; + tft.drawString(lines[i].text, centerX(), y + h / 2); + y += h + lineGap(); } } @@ -100,25 +136,25 @@ constexpr float kConnectingDetailVlw = 0.92f; void applyConnectingDetailStyle() { if (displayFontIsSmooth()) { - displayFontSetSmoothSize(tft, kConnectingDetailVlw); + displayFontSetSmoothSize(tft, kConnectingDetailVlw * ui::radar::kScale); } else { displayFontSetBitmap(tft, &kConnectingGfxDetail); } } -/** SSID on one line; truncate with … if wider than kConnectingTextMaxWidthPx. */ +/** SSID on one line; truncate with … if wider than connectingTextMaxWidthPx(). */ void fitSsidLine() { strncpy(s_ssid_line, s_connecting_ssid, sizeof(s_ssid_line) - 1); s_ssid_line[sizeof(s_ssid_line) - 1] = '\0'; applyConnectingDetailStyle(); - if (tft.textWidth(s_ssid_line) <= kConnectingTextMaxWidthPx) { + if (tft.textWidth(s_ssid_line) <= connectingTextMaxWidthPx()) { return; } const size_t len = strlen(s_connecting_ssid); for (size_t n = len; n > 0; --n) { snprintf(s_ssid_line, sizeof(s_ssid_line), "%.*s…", static_cast(n), s_connecting_ssid); - if (tft.textWidth(s_ssid_line) <= kConnectingTextMaxWidthPx) { + if (tft.textWidth(s_ssid_line) <= connectingTextMaxWidthPx()) { return; } } @@ -134,16 +170,17 @@ void drawConnectingText() { applyConnectingDetailStyle(); const int detail_h = tft.fontHeight(); - const int total_h = detail_h * 2 + kLineGap; - const int block_top = (config::kDisplayHeight - total_h) / 2; - constexpr int kPanelPadY = 8; - tft.fillRect(kCenterX - kConnectingTextMaxWidthPx / 2, block_top - kPanelPadY, - kConnectingTextMaxWidthPx, total_h + kPanelPadY * 2, config::kColorBlack); + const int total_h = detail_h * 2 + lineGap(); + const int block_top = (tft.height() - total_h) / 2; + const int panel_pad_y = scaled(8); + const int panel_w = connectingTextMaxWidthPx(); + tft.fillRect(centerX() - panel_w / 2, block_top - panel_pad_y, panel_w, + total_h + panel_pad_y * 2, config::kColorBlack); int y = block_top; - tft.drawString("Connecting to", kCenterX, y + detail_h / 2); - y += detail_h + kLineGap; - tft.drawString(s_ssid_line, kCenterX, y + detail_h / 2); + tft.drawString("Connecting to", centerX(), y + detail_h / 2); + y += detail_h + lineGap(); + tft.drawString(s_ssid_line, centerX(), y + detail_h / 2); s_connecting_text_drawn = true; } @@ -153,7 +190,7 @@ void eraseSpinnerDots() { if (!s_spinner_dots[i].drawn) { continue; } - tft.fillCircle(s_spinner_dots[i].x, s_spinner_dots[i].y, kSpinnerEraseRadius, + tft.fillCircle(s_spinner_dots[i].x, s_spinner_dots[i].y, spinnerEraseRadius(), config::kColorBlack); s_spinner_dots[i].drawn = false; } @@ -165,12 +202,13 @@ void drawSpinnerDots() { for (int i = 0; i < kSpinnerDotCount; ++i) { const float a = head_rad - static_cast(i) * (6.283185307f / kSpinnerDotCount); - const int x = kCenterX + static_cast(std::lround(std::cos(a) * kSpinnerRadius)); - const int y = kCenterY + static_cast(std::lround(std::sin(a) * kSpinnerRadius)); + const int radius = spinnerRadius(); + const int x = centerX() + static_cast(std::lround(std::cos(a) * radius)); + const int y = centerY() + static_cast(std::lround(std::sin(a) * radius)); const int fade = 255 - i * 22; const uint16_t color = tft.color565(0, fade, 0); - tft.fillSmoothCircle(x, y, kSpinnerDotRadius, color); + tft.fillSmoothCircle(x, y, spinnerDotRadius(), color); s_spinner_dots[i].x = x; s_spinner_dots[i].y = y; From f1f5e694c8200a1831e2840e0e3b5197f3f27b44 Mon Sep 17 00:00:00 2001 From: LM Date: Sun, 6 Sep 2026 16:26:07 -0400 Subject: [PATCH 2/3] feat: support the 2.1" GC9B72 360x360 panel Selected with -DPLANE_RADAR_PANEL_GC9B72, env supermini_gc9b72. The default supermini env and the GC9A01 wiring are untouched. LovyanGFX gained Panel_GC9B72 in 1.2.28, so the lib_deps floor moves up from ^1.2.7. The panel needs a backlight line, which the 1.28" modules tie on at the module: it sits on GPIO5 through Light_PWM so it can be dimmed if the 3V3 rail sags under WiFi transmit -- a 2.1" backlight draws several times what the 1.28" one does. LEDC channel 0, because the C3 has only six channels and the 7 used in most LovyanGFX examples is out of range on it. The 360x360 frame buffer is 259,200 B at 16 bits, which does not fit on an ESP32-C3 once WiFi is up -- measured, 188,404 B largest free block. The frame sprite now walks a depth ladder and lands on 8-bit RGB332 at 129,600 B. There is deliberately no 4bpp rung: LovyanGFX has no RGB-to-palette-index converter, so a blended write to a palette sprite goes through copy_bit_affine and corrupts every antialiased primitive we draw, fillSmoothCircle and drawWideLine among them. RGB332 keeps them. Unlike the GC9A01 modules this panel must not be inverted; its init sequence does not set inversion itself. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 84 ++++++++++++++++++++++++++++++-- include/config.h | 41 +++++++++++++++- include/hardware/lgfx_config.hpp | 29 ++++++++++- platformio.ini | 22 +++++++-- src/ui/radar_display.cpp | 29 ++++++++--- 5 files changed, 189 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0318b3f4f..15e4875a2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ **3D printed case (STL + assembly):** [MakerWorld](https://makerworld.com/en/models/2872376-esp32-plane-radar-live-ads-b-on-a-round-display#profileId-3207083) · **Firmware:** [Releases](https://github.com/MatixYo/ESP32-Plane-Radar/releases) -Firmware for an **ESP32-C3 Super Mini** and a **1.28″ round GC9A01** display (240×240). Shows a circular **ADS-B radar** around your configured location, with **WiFiManager** for first-time setup. +Firmware for an **ESP32-C3 Super Mini** and a round SPI display — **1.28″ GC9A01** (240×240) or **2.1″ GC9B72** (360×360). Shows a circular **ADS-B radar** around your configured location, with **WiFiManager** for first-time setup. + +The radar layout is derived from the panel size at runtime, so both displays get the same design scaled to fit rather than a second set of hand-tuned constants. ## What it does @@ -138,7 +140,13 @@ src/ services/ ``` -## Wiring (GC9A01 ↔ ESP32-C3 Super Mini) +## Wiring + +`BOOT (user)` is the on-board button on **GPIO 9** in every combination below — no wire. + +### GC9A01 (1.28″ round, 240×240) ↔ ESP32-C3 Super Mini + +The shipping build. | Display | ESP32-C3 | |---------|----------| @@ -151,16 +159,84 @@ src/ | SCL (SCLK) | GPIO **4** | | BOOT (user) | GPIO **9** | +### GC9B72 (2.1″ round, 360×360) — proposed + +> **In progress, not yet supported by the firmware.** The ESP32-C6 column also +> needs a PlatformIO platform shipping Arduino core 3.x — the official +> `espressif32` is still on 2.0.17 and has no C6 Arduino variant. + +| Display | ESP32-C3 | ESP32-C6 | | +|---------|----------|----------|---| +| VCC | 3V3 | 3V3 | **3.3 V only** — the module has no regulator | +| GND | GND | GND | | +| RES | GPIO **0** | GPIO **0** | | +| CS | GPIO **1** | GPIO **1** | | +| DC | GPIO **10** | GPIO **2** | the one wire that differs — GPIO 10 is not broken out on the C6 | +| SDA (MOSI) | GPIO **3** | GPIO **3** | | +| SCL (SCLK) | GPIO **4** | GPIO **4** | | +| BL | GPIO **5** | GPIO **5** | backlight, on a GPIO so it can be dimmed | +| SDO (MISO) | GPIO **6** | GPIO **6** | optional — see below | +| TE | GPIO **7** | GPIO **7** | optional — see below | +| BOOT (user) | GPIO **9** | GPIO **9** | on-board button | + +**SDO is unused by the radar.** Frames are composed in an off-screen sprite and +pushed in one pass, so nothing reads back from the panel and `pin_miso` stays +`-1`. It matters only on the fallback path taken when the sprite cannot be +allocated, where LovyanGFX's antialiased primitives read the panel in order to +blend against it — with SDO floating, those blends read garbage. + +**TE is not used by LovyanGFX.** Both GC9 init lists enable the tearing-effect +output (`0x35 0x00`), but no SPI panel driver in LovyanGFX consumes it +(`getScanLine()` returns `-1`), so using it means polling the pin from +application code and starting the push just after the pulse. A full 360×360 +frame is 259,200 bytes on the wire — about 52 ms at 40 MHz — long enough for a +seam to be visible. + +**Strapping pins.** Do not move DC to GPIO 2 on the **C3** to make the two boards +identical: there it is a boot strapping pin that must be high at reset, and a +high-impedance DC input will not hold it. On the C6, GPIO 2 is not a strapping +pin (the C6's are GPIO 4, 5, 8, 9 and 15). + +**Board orientation.** Holding the C3 Super Mini with USB-C up, `GPIO5` and `5V` +are the pads nearest the connector and `GPIO20`/`GPIO21` the two furthest. +Published pinouts appear in both orientations — check the silkscreen, not a +diagram. + +**Backlight current.** The 2.1″ backlight draws several times what the 1.28″ one +does, and rides the Super Mini's 3V3 LDO alongside WiFi TX bursts. Measure 3V3 +under load before trusting it; the TX power is already capped to 8.5 dBm for +related reasons. + ## Build +Pick the environment that matches the display fitted: + +| Display | Environment | Resolution | +|---------|-------------|------------| +| 1.28″ GC9A01 | `supermini` | 240×240 | +| 2.1″ GC9B72 | `supermini_gc9b72` | 360×360 | + ```bash -pio run -t upload +# 1.28" GC9A01 (default) +pio run -e supermini -t upload + +# 2.1" GC9B72 +pio run -e supermini_gc9b72 -t upload + pio device monitor ``` -- PlatformIO env: **`supermini`** - Serial: **115200** baud - USB CDC on boot enabled in `platformio.ini` for the Super Mini +- The two environments differ only by `-DPLANE_RADAR_PANEL_GC9B72`, which selects + the panel driver, pins, SPI clock and colour settings in `include/config.h` +- The 360×360 frame buffer does not fit at 16 bits on an ESP32-C3, so the radar + falls back to 8-bit RGB332 (129,600 B instead of 259,200 B). The chosen depth is + printed at boot: + + ``` + radar: frame sprite 360x360 @8bpp + ``` ### Web-flashable release image diff --git a/include/config.h b/include/config.h index 6555c0342..13a7d624a 100644 --- a/include/config.h +++ b/include/config.h @@ -29,12 +29,49 @@ constexpr unsigned long kBootResetHoldMs = 3000UL; /** Ignore BOOT taps shorter than this (debounce). */ constexpr unsigned long kBootTapMinMs = 40UL; -// --- Display: GC9A01 1.28" round 240×240 (SPI) --- +// --- Display --- +// Two panels are supported. Define PLANE_RADAR_PANEL_GC9B72 (PlatformIO env +// `supermini_gc9b72`) for the 2.1" 360x360 panel; the default is the 1.28" +// GC9A01 the project shipped with. Wiring for both is in the README. +// +// kDisplayWidth/kDisplayHeight configure the panel driver. The UI does NOT read +// them — it asks the driver at runtime via ui::radar::initMetrics(), so a panel +// that reports a size of its own still lays out correctly. +#if defined(PLANE_RADAR_PANEL_GC9B72) + +// GC9B72 2.1" round 360x360 (SPI) constexpr gpio_num_t kDisplayPinRst = GPIO_NUM_0; constexpr gpio_num_t kDisplayPinCs = GPIO_NUM_1; constexpr gpio_num_t kDisplayPinDc = GPIO_NUM_10; constexpr gpio_num_t kDisplayPinMosi = GPIO_NUM_3; // display SDA constexpr gpio_num_t kDisplayPinSclk = GPIO_NUM_4; // display SCL +/** + * Backlight. The 2.1" backlight draws several times what the 1.28" one does, so + * it sits on a GPIO rather than being strapped to 3V3 — that way it can be + * dimmed if the 3V3 rail sags under WiFi transmit. -1 = not wired. + */ +constexpr int kDisplayPinBacklight = 5; + +constexpr int kDisplayWidth = 360; +constexpr int kDisplayHeight = 360; + +/** The only published GC9B72 driver reports ~20 MHz tested on short leads. */ +constexpr uint32_t kDisplaySpiWriteHz = 20000000; +// The GC9B72 init sequence does not set inversion itself, and this panel is +// normally black — so, unlike the GC9A01 modules, it must not be inverted. +constexpr bool kDisplayInvert = false; +constexpr bool kDisplayRgbOrder = false; + +#else + +// GC9A01 1.28" round 240x240 (SPI) +constexpr gpio_num_t kDisplayPinRst = GPIO_NUM_0; +constexpr gpio_num_t kDisplayPinCs = GPIO_NUM_1; +constexpr gpio_num_t kDisplayPinDc = GPIO_NUM_10; +constexpr gpio_num_t kDisplayPinMosi = GPIO_NUM_3; // display SDA +constexpr gpio_num_t kDisplayPinSclk = GPIO_NUM_4; // display SCL +/** Not wired on the GC9A01 modules — backlight is tied on at the module. */ +constexpr int kDisplayPinBacklight = -1; constexpr int kDisplayWidth = 240; constexpr int kDisplayHeight = 240; @@ -44,6 +81,8 @@ constexpr uint32_t kDisplaySpiWriteHz = 40000000; constexpr bool kDisplayInvert = true; constexpr bool kDisplayRgbOrder = true; +#endif + // --- Radar center defaults (overridden via WiFi setup portal) --- constexpr double kDefaultRadarLat = 52.3676; constexpr double kDefaultRadarLon = 4.9041; diff --git a/include/hardware/lgfx_config.hpp b/include/hardware/lgfx_config.hpp index 4f1484b6a..14da332c7 100644 --- a/include/hardware/lgfx_config.hpp +++ b/include/hardware/lgfx_config.hpp @@ -5,10 +5,21 @@ #include "config.h" -/** LovyanGFX device: GC9A01 on SPI. Pin values come from config.h. */ +/** + * LovyanGFX device. Pin values and panel geometry come from config.h. + * + * Both panels derive from LovyanGFX's shared Panel_GC9xxx base, so only the + * type differs — the bus, pins and backlight setup below are identical. + * Panel_GC9B72 needs LovyanGFX >= 1.2.28. + */ class LGFX : public lgfx::LGFX_Device { lgfx::Bus_SPI _bus; +#if defined(PLANE_RADAR_PANEL_GC9B72) + lgfx::Panel_GC9B72 _panel; +#else lgfx::Panel_GC9A01 _panel; +#endif + lgfx::Light_PWM _light; public: LGFX() { @@ -18,7 +29,7 @@ class LGFX : public lgfx::LGFX_Device { cfg.freq_write = config::kDisplaySpiWriteHz; cfg.pin_sclk = static_cast(config::kDisplayPinSclk); cfg.pin_mosi = static_cast(config::kDisplayPinMosi); - cfg.pin_miso = -1; + cfg.pin_miso = -1; // nothing reads back from the panel; see README cfg.pin_dc = static_cast(config::kDisplayPinDc); _bus.config(cfg); _panel.setBus(&_bus); @@ -31,6 +42,20 @@ class LGFX : public lgfx::LGFX_Device { cfg.rgb_order = config::kDisplayRgbOrder; _panel.config(cfg); } + // Only attach a light when a backlight pin is actually wired; on the GC9A01 + // modules the backlight is tied on at the module and setBrightness is a + // no-op, which is the behaviour this project has always had. + if (config::kDisplayPinBacklight >= 0) { + auto cfg = _light.config(); + cfg.pin_bl = config::kDisplayPinBacklight; + cfg.invert = false; + cfg.freq = 44100; + // The ESP32-C3 has only 6 LEDC channels (0-5), so the 7 used in most + // LovyanGFX examples (written for the 16-channel ESP32) is out of range. + cfg.pwm_channel = 0; + _light.config(cfg); + _panel.setLight(&_light); + } setPanel(&_panel); } }; diff --git a/platformio.ini b/platformio.ini index c81ce535f..fdcf6a177 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,7 +1,12 @@ -; ESP32-C3 Super Mini + 1.28" round GC9A01 (240×240) +; ESP32-C3 Super Mini + a round GC9 SPI display. ; Board in Arduino IDE: "ESP32C3 Dev Module" or "MakerGO ESP32 C3 SuperMini" +; +; supermini 1.28" GC9A01, 240x240 (the shipping build) +; supermini_gc9b72 2.1" GC9B72, 360x360 +; +; Wiring for both is in README.md. -[env:supermini] +[env] platform = espressif32@6.5.0 board = esp32-c3-devkitm-1 framework = arduino @@ -21,6 +26,17 @@ build_flags = -DWM_MDNS lib_deps = - lovyan03/LovyanGFX@^1.2.7 + ; Panel_GC9B72 was added in LovyanGFX 1.2.28. + lovyan03/LovyanGFX@^1.2.28 tzapu/WiFiManager@^2.0.17 bblanchon/ArduinoJson@^7.4.2 + +[env:supermini] +; 1.28" GC9A01 240x240 — the default panel, no extra flags. + +[env:supermini_gc9b72] +; 2.1" GC9B72 360x360. +build_flags = + ${env.build_flags} + -DPLANE_RADAR_PANEL_GC9B72 + diff --git a/src/ui/radar_display.cpp b/src/ui/radar_display.cpp index 3ececd6e8..63e2f5516 100644 --- a/src/ui/radar_display.cpp +++ b/src/ui/radar_display.cpp @@ -663,13 +663,30 @@ bool ensureFrameSprite() { if (s_frame_ready) { return true; } - s_frame.setColorDepth(16); - if (!s_frame.createSprite(radar::kSize, radar::kSize)) { - Serial.println("radar: frame sprite alloc failed"); - return false; + + // Best colour depth that fits. 16bpp is 115,200 B at 240x240 and always wins + // there; at 360x360 it is 259,200 B, which an ESP32-C3 cannot spare once WiFi + // is up. + // + // 8 is the floor worth trying. LovyanGFX maps setColorDepth(8) to RGB332 — a + // true RGB format whose converter supports alpha blending. Palette depths go + // through copy_bit_affine instead, which masks raw bytes into index bits and + // silently corrupts every antialiased primitive we draw (fillSmoothCircle, + // drawWideLine), so there is deliberately no 4bpp rung. + static constexpr int kDepthLadder[] = {16, 8}; + + for (const int depth : kDepthLadder) { + s_frame.setColorDepth(depth); + if (s_frame.createSprite(radar::kSize, radar::kSize)) { + Serial.printf("radar: frame sprite %dx%d @%dbpp\n", radar::kSize, + radar::kSize, depth); + s_frame_ready = true; + return true; + } } - s_frame_ready = true; - return true; + + Serial.println("radar: no frame sprite fits - drawing direct to panel"); + return false; } // Double-buffered frame: composite the grid AND aircraft into the off-screen From fcf009de77af05960285ce235a90e95fa1c05075 Mon Sep 17 00:00:00 2001 From: LM Date: Sun, 6 Sep 2026 16:31:58 -0400 Subject: [PATCH 3/3] feat: use the SDO and TE lines when they are wired Both were hard-coded off. They are now per-panel pins in config.h, defaulting to -1 on the GC9A01 where neither is broken out. kDisplayPinMiso feeds LovyanGFX's bus config. It makes the panel readable, which the antialiased primitives need on the direct-to-panel path taken when no frame sprite fits, and it silences the "SPI Does not have default pins on ESP32C3" error that -1 provoked at every boot. kDisplayPinTe is polled by displayWaitForFrameStart() before each pushSprite. No SPI panel in LovyanGFX consumes TE -- getScanLine() returns -1 for all of them -- so the edge is caught here instead. A full 360x360 frame is 259,200 B on the wire, roughly 104 ms at 20 MHz, which is long enough to tear without it. The wait gives up after 25 ms so a miswired line cannot stall the main loop. Also drops the ESP32-C6 material from the README. It was documenting hardware that is not supported yet and needs a PlatformIO platform this project does not use; the wiring table is now the one board that works. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 81 +++++++++++++++----------------- include/config.h | 15 ++++++ include/hardware/display.h | 11 +++++ include/hardware/lgfx_config.hpp | 2 +- src/hardware/display.cpp | 36 ++++++++++++++ src/ui/radar_display.cpp | 1 + 6 files changed, 101 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 15e4875a2..58b46a59c 100644 --- a/README.md +++ b/README.md @@ -159,54 +159,47 @@ The shipping build. | SCL (SCLK) | GPIO **4** | | BOOT (user) | GPIO **9** | -### GC9B72 (2.1″ round, 360×360) — proposed - -> **In progress, not yet supported by the firmware.** The ESP32-C6 column also -> needs a PlatformIO platform shipping Arduino core 3.x — the official -> `espressif32` is still on 2.0.17 and has no C6 Arduino variant. - -| Display | ESP32-C3 | ESP32-C6 | | -|---------|----------|----------|---| -| VCC | 3V3 | 3V3 | **3.3 V only** — the module has no regulator | -| GND | GND | GND | | -| RES | GPIO **0** | GPIO **0** | | -| CS | GPIO **1** | GPIO **1** | | -| DC | GPIO **10** | GPIO **2** | the one wire that differs — GPIO 10 is not broken out on the C6 | -| SDA (MOSI) | GPIO **3** | GPIO **3** | | -| SCL (SCLK) | GPIO **4** | GPIO **4** | | -| BL | GPIO **5** | GPIO **5** | backlight, on a GPIO so it can be dimmed | -| SDO (MISO) | GPIO **6** | GPIO **6** | optional — see below | -| TE | GPIO **7** | GPIO **7** | optional — see below | -| BOOT (user) | GPIO **9** | GPIO **9** | on-board button | - -**SDO is unused by the radar.** Frames are composed in an off-screen sprite and -pushed in one pass, so nothing reads back from the panel and `pin_miso` stays -`-1`. It matters only on the fallback path taken when the sprite cannot be -allocated, where LovyanGFX's antialiased primitives read the panel in order to -blend against it — with SDO floating, those blends read garbage. - -**TE is not used by LovyanGFX.** Both GC9 init lists enable the tearing-effect -output (`0x35 0x00`), but no SPI panel driver in LovyanGFX consumes it -(`getScanLine()` returns `-1`), so using it means polling the pin from -application code and starting the push just after the pulse. A full 360×360 -frame is 259,200 bytes on the wire — about 52 ms at 40 MHz — long enough for a -seam to be visible. - -**Strapping pins.** Do not move DC to GPIO 2 on the **C3** to make the two boards -identical: there it is a boot strapping pin that must be high at reset, and a -high-impedance DC input will not hold it. On the C6, GPIO 2 is not a strapping -pin (the C6's are GPIO 4, 5, 8, 9 and 15). - -**Board orientation.** Holding the C3 Super Mini with USB-C up, `GPIO5` and `5V` -are the pads nearest the connector and `GPIO20`/`GPIO21` the two furthest. -Published pinouts appear in both orientations — check the silkscreen, not a -diagram. +### GC9B72 (2.1″ round, 360×360) ↔ ESP32-C3 Super Mini + +| Display | ESP32-C3 | | +|---------|----------|---| +| VCC | 3V3 | **3.3 V only** — the module has no regulator | +| GND | GND | | +| RES | GPIO **0** | | +| CS | GPIO **1** | | +| DC | GPIO **10** | | +| SDA (MOSI) | GPIO **3** | | +| SCL (SCLK) | GPIO **4** | | +| BL | GPIO **5** | backlight, on a GPIO so it can be dimmed | +| SDO (MISO) | GPIO **6** | optional — see below | +| TE | GPIO **7** | optional — see below | +| BOOT (user) | GPIO **9** | on-board button | + +Only `RES`, `CS`, `DC`, `SDA` and `SCL` are required; the rest are set to `-1` in +`include/config.h` when not wired, and the firmware adapts. + +**SDO (MISO).** The radar composes each frame in an off-screen sprite and pushes +it in one pass, so nothing normally reads back from the panel. It matters on the +fallback path taken when no frame sprite can be allocated, where LovyanGFX's +antialiased primitives read the panel to blend against it — with SDO unwired +those blends have nothing to read. + +**TE (tearing effect).** The GC9B72 init sequence enables this output, but +LovyanGFX does not consume it — `getScanLine()` returns `-1` for every SPI panel +— so the firmware polls it directly in `displayWaitForFrameStart()` and starts +the push on the edge. A full 360×360 frame is 259,200 bytes on the wire, about +104 ms at 20 MHz, which is long enough for a seam to show without it. The wait +times out after 25 ms so a miswired line cannot stall the main loop. **Backlight current.** The 2.1″ backlight draws several times what the 1.28″ one -does, and rides the Super Mini's 3V3 LDO alongside WiFi TX bursts. Measure 3V3 -under load before trusting it; the TX power is already capped to 8.5 dBm for +does, and rides the Super Mini's 3V3 LDO alongside WiFi transmit bursts. Measure +3V3 under load before trusting it; TX power is already capped to 8.5 dBm for related reasons. +**Board orientation.** Holding the Super Mini with USB-C up, `GPIO5` and `5V` are +the pads nearest the connector and `GPIO20`/`GPIO21` the two furthest. Published +pinouts appear in both orientations — check the silkscreen, not a diagram. + ## Build Pick the environment that matches the display fitted: diff --git a/include/config.h b/include/config.h index 13a7d624a..2c76bc002 100644 --- a/include/config.h +++ b/include/config.h @@ -51,6 +51,18 @@ constexpr gpio_num_t kDisplayPinSclk = GPIO_NUM_4; // display SCL * dimmed if the 3V3 rail sags under WiFi transmit. -1 = not wired. */ constexpr int kDisplayPinBacklight = 5; +/** + * SDO/MISO. Wire it and the panel becomes readable, which the antialiased + * primitives need on the direct-to-panel path taken when no frame sprite fits. + * -1 = not wired. + */ +constexpr int kDisplayPinMiso = 6; +/** + * Tearing effect. The controller pulses this once per frame; waiting for it + * before pushing keeps a seam out of the picture. A full 360x360 frame is + * 259,200 B on the wire, long enough to tear without it. -1 = not wired. + */ +constexpr int kDisplayPinTe = 7; constexpr int kDisplayWidth = 360; constexpr int kDisplayHeight = 360; @@ -72,6 +84,9 @@ constexpr gpio_num_t kDisplayPinMosi = GPIO_NUM_3; // display SDA constexpr gpio_num_t kDisplayPinSclk = GPIO_NUM_4; // display SCL /** Not wired on the GC9A01 modules — backlight is tied on at the module. */ constexpr int kDisplayPinBacklight = -1; +/** Neither is broken out on the GC9A01 modules. */ +constexpr int kDisplayPinMiso = -1; +constexpr int kDisplayPinTe = -1; constexpr int kDisplayWidth = 240; constexpr int kDisplayHeight = 240; diff --git a/include/hardware/display.h b/include/hardware/display.h index 6191f1776..fd34fe521 100644 --- a/include/hardware/display.h +++ b/include/hardware/display.h @@ -5,3 +5,14 @@ extern LGFX tft; void displayInit(); + +/** + * Block until the panel's tearing-effect line says a new frame is starting, so + * a full-screen push lands in the vertical blanking interval instead of partway + * down a visible frame. Returns immediately when no TE pin is wired, and gives + * up after a short timeout so a miswired line cannot stall the main loop. + * + * LovyanGFX has no TE support for SPI panels -- getScanLine() returns -1 for + * all of them -- so this is polled here rather than by the driver. + */ +void displayWaitForFrameStart(); diff --git a/include/hardware/lgfx_config.hpp b/include/hardware/lgfx_config.hpp index 14da332c7..afc854d12 100644 --- a/include/hardware/lgfx_config.hpp +++ b/include/hardware/lgfx_config.hpp @@ -29,7 +29,7 @@ class LGFX : public lgfx::LGFX_Device { cfg.freq_write = config::kDisplaySpiWriteHz; cfg.pin_sclk = static_cast(config::kDisplayPinSclk); cfg.pin_mosi = static_cast(config::kDisplayPinMosi); - cfg.pin_miso = -1; // nothing reads back from the panel; see README + cfg.pin_miso = config::kDisplayPinMiso; cfg.pin_dc = static_cast(config::kDisplayPinDc); _bus.config(cfg); _panel.setBus(&_bus); diff --git a/src/hardware/display.cpp b/src/hardware/display.cpp index ff224504c..0e2d03efd 100644 --- a/src/hardware/display.cpp +++ b/src/hardware/display.cpp @@ -1,17 +1,53 @@ #include "hardware/display.h" +#include + +#include "config.h" #include "hardware/display_font.h" #include "ui/radar_theme.h" LGFX tft; +namespace { + +/** Longest we will wait for a TE edge before giving up and pushing anyway. */ +constexpr uint32_t kTearingEffectTimeoutMs = 25; + +} // namespace + void displayInit() { tft.init(); tft.setRotation(0); tft.setBrightness(255); tft.setTextWrap(false); displayFontInit(); + + if (config::kDisplayPinTe >= 0) { + pinMode(config::kDisplayPinTe, INPUT); + } + // Panel size comes from the driver rather than config.h, so the layout // follows whichever panel is actually fitted. ui::radar::initMetrics(tft.width(), tft.height()); } + +void displayWaitForFrameStart() { + if (config::kDisplayPinTe < 0) { + return; + } + + // Ride out any pulse already in progress, then wait for the next rising + // edge. Catching the edge rather than the level is what puts the push at the + // start of the blanking interval instead of somewhere inside it. + const uint32_t deadline = millis() + kTearingEffectTimeoutMs; + while (digitalRead(config::kDisplayPinTe) == HIGH) { + if (millis() >= deadline) { + return; + } + } + while (digitalRead(config::kDisplayPinTe) == LOW) { + if (millis() >= deadline) { + return; + } + } +} diff --git a/src/ui/radar_display.cpp b/src/ui/radar_display.cpp index 63e2f5516..972b89755 100644 --- a/src/ui/radar_display.cpp +++ b/src/ui/radar_display.cpp @@ -698,6 +698,7 @@ void renderFrame() { const DrawScope scope(s_frame); drawAircraft(); } + displayWaitForFrameStart(); s_frame.pushSprite(0, 0); tft.setTextDatum(textdatum_t::top_left); }