diff --git a/README.md b/README.md index 0318b3f4f..58b46a59c 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,77 @@ src/ | SCL (SCLK) | GPIO **4** | | BOOT (user) | GPIO **9** | +### 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 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: + +| 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 d084875e7..86dc966f5 100644 --- a/include/config.h +++ b/include/config.h @@ -29,12 +29,64 @@ 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; +/** + * 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; + +/** 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; +/** Neither is broken out on the GC9A01 modules. */ +constexpr int kDisplayPinMiso = -1; +constexpr int kDisplayPinTe = -1; constexpr int kDisplayWidth = 240; constexpr int kDisplayHeight = 240; @@ -44,6 +96,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/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 4f1484b6a..afc854d12 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 = config::kDisplayPinMiso; 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/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/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/hardware/display.cpp b/src/hardware/display.cpp index 42f82524d..0e2d03efd 100644 --- a/src/hardware/display.cpp +++ b/src/hardware/display.cpp @@ -1,13 +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 5dcc5764d..f9fc6f73a 100644 --- a/src/ui/radar_display.cpp +++ b/src/ui/radar_display.cpp @@ -696,13 +696,26 @@ bool ensureFrameSprite() { if (s_frame_ready) { return true; } + // RGB332, one byte a pixel, on every panel: the frame is the largest single + // allocation this firmware makes, and at 16bpp it costs 115,204 B at 240x240 + // and 259,200 B at 360x360 -- more than an ESP32-C3 can spare once WiFi and + // the ADS-B fetch are up. At 8bpp it is 57,602 B and 129,600 B. + // + // LovyanGFX's RGB332 is 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 + // fallback below this. s_frame.setColorDepth(lgfx::color_depth_t::rgb332_1Byte); - if (!s_frame.createSprite(radar::kSize, radar::kSize)) { - Serial.println("radar: frame sprite alloc failed"); - return false; + if (s_frame.createSprite(radar::kSize, radar::kSize)) { + Serial.printf("radar: frame sprite %dx%d @8bpp\n", radar::kSize, + radar::kSize); + 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 @@ -714,6 +727,7 @@ void renderFrame() { const DrawScope scope(s_frame); drawAircraft(); } + displayWaitForFrameStart(); s_frame.pushSprite(0, 0); tft.setTextDatum(textdatum_t::top_left); } 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;