From dcdcbafd68c46c8a5df91140ae587e4ac47e2d93 Mon Sep 17 00:00:00 2001 From: Crispin Proctor Date: Fri, 19 Jun 2026 19:26:58 +0100 Subject: [PATCH 1/5] Add ESP32-2424S012 board support with compile- and run-time selection The project targeted only the ESP32-C3 Super Mini with a separately wired GC9A01. Add support for the Sunton/JCZN ESP32-2424S012 integrated round board (different SPI pins, GPIO3 active-high backlight, BGR color order) without breaking the Super Mini default. - Add include/hardware/board.h + src/hardware/board.cpp: a board registry with a per-board pin/color-order table, compile-time default, and an NVS-backed runtime override. - Move display pins/color order out of config.h into the board registry. - lgfx_config: applyBoard() (re)configures the SPI bus and panel from a board. - display/radar_display: use the active board for pins, backlight, color order. Backlight is driven directly (the ESP32-C3 has only 6 LEDC channels). - Wi-Fi portal: add a Board dropdown; selection persists to NVS and reboots to apply, so one image can run on either board. - platformio.ini: add the esp32-2424s012 env (sets the build-time default). - README: document supported boards, wiring, build envs, and selection. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 41 ++++++++++++++++++---- include/config.h | 11 ++---- include/hardware/board.h | 58 ++++++++++++++++++++++++++++++++ include/hardware/lgfx_config.hpp | 22 +++++++----- platformio.ini | 9 +++++ src/hardware/board.cpp | 57 +++++++++++++++++++++++++++++++ src/hardware/display.cpp | 17 ++++++++++ src/services/wifi_setup.cpp | 57 +++++++++++++++++++++++++++++++ src/ui/radar_display.cpp | 3 +- 9 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 include/hardware/board.h create mode 100644 src/hardware/board.cpp diff --git a/README.md b/README.md index 0318b3f4..c9e09868 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,14 @@ **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 a **1.28″ round GC9A01** display (240×240) on the **ESP32-C3**. Shows a circular **ADS-B radar** around your configured location, with **WiFiManager** for first-time setup. + +**Supported boards** (see [Boards](#boards)): + +- **ESP32-C3 Super Mini** + a separately wired GC9A01 panel (default) +- **Sunton/JCZN ESP32-2424S012** — integrated 1.28″ round GC9A01 + CST816 touch board + +Pick the board at build time (PlatformIO env) and/or switch it at runtime from the Wi‑Fi setup portal. ## What it does @@ -44,6 +51,7 @@ The same portal runs on the setup AP and on the device’s LAN IP while connecte | **Latitude / Longitude** | Radar center and ADS-B query position (defaults in `config.h` until set) | | **Display distances in miles** | Ring scale label in **mi** instead of **km** (e.g. `6mi` vs `10km`) | | **Show airport runways** | Major-airport runway overlay on the radar (off to hide) | +| **Board** | Display board in use (Super Mini / ESP32-2424S012); reboots to apply | After a reset, the device reboots and shows the setup screen immediately (no “Connecting” loop on stale credentials). @@ -98,7 +106,8 @@ Edit **`include/config.h`** for hardware and behavior: | Portal | `kPortalApName`, `kPortalIp`, `kPortalHostname` / `kPortalHostUrl` (mDNS; needs `-DWM_MDNS` in `platformio.ini`) | | Wi‑Fi timing | connect attempts, reconnect grace, portal timeout (`0` = no timeout) | | BOOT | `kBootPin`, `kBootResetHoldMs`, `kBootTapMinMs` | -| Display SPI | pins, `kDisplayInvert`, `kDisplayRgbOrder`, `kDisplaySpiWriteHz` | +| Display | `kDisplayInvert`, `kDisplaySpiWriteHz` in `config.h`; per-board pins and color order in `include/hardware/board.h` | +| Boards | `include/hardware/board.h` (`kBoards` table, `Board` enum); add a board by extending both | | Default location | `kDefaultRadarLat`, `kDefaultRadarLon` (until portal overrides) | | ADS-B | `kAdsbFetchIntervalMs`, `kAdsbShowGroundAircraft` | @@ -110,6 +119,7 @@ Range presets: `include/ui/radar_range.h` (`kRangePresets`). include/ config.h hardware/ + board.h — board registry (pins per board, runtime select) lgfx_config.hpp display.h display_font.h @@ -138,7 +148,16 @@ src/ services/ ``` -## Wiring (GC9A01 ↔ ESP32-C3 Super Mini) +## Boards + +The display wiring per board lives in [`include/hardware/board.h`](include/hardware/board.h). Select a board two ways: + +- **Compile time** — pick the PlatformIO env (sets the default): `supermini` or `esp32-2424s012` (see [Build](#build)). +- **Runtime** — choose the board in the Wi‑Fi setup portal (**Board** dropdown). The choice is saved to NVS and applied on the next boot, so one firmware image can run on either board. + +> On first boot the display uses the build-time default. If you flash the “wrong” env, the screen may stay blank until you pick the correct board in the portal (connect to the `PlaneRadar-Setup` AP and open the page), then it reboots. + +### ESP32-C3 Super Mini (separately wired GC9A01) | Display | ESP32-C3 | |---------|----------| @@ -151,16 +170,26 @@ src/ | SCL (SCLK) | GPIO **4** | | BOOT (user) | GPIO **9** | +### Sunton/JCZN ESP32-2424S012 (integrated) + +Integrated board — no wiring needed. Display is fixed to: SCLK **6**, MOSI **7**, DC **2**, CS **10**, RST **not wired**, backlight **GPIO 3** (active high), panel color order **BGR**. (Touch/IMU are not used by this firmware.) + ## Build ```bash -pio run -t upload +# ESP32-C3 Super Mini (default) +pio run -e supermini -t upload + +# Sunton/JCZN ESP32-2424S012 +pio run -e esp32-2424s012 -t upload + pio device monitor ``` -- PlatformIO env: **`supermini`** +- PlatformIO envs: **`supermini`** (default) and **`esp32-2424s012`** - Serial: **115200** baud -- USB CDC on boot enabled in `platformio.ini` for the Super Mini +- USB CDC on boot enabled in `platformio.ini` (native USB on both boards) +- To flash a board that is boot-looping or busy, enter download mode first (hold **BOOT**, tap **RESET** / replug) ### Web-flashable release image diff --git a/include/config.h b/include/config.h index 6555c034..2f24fa92 100644 --- a/include/config.h +++ b/include/config.h @@ -30,19 +30,14 @@ constexpr unsigned long kBootResetHoldMs = 3000UL; constexpr unsigned long kBootTapMinMs = 40UL; // --- Display: GC9A01 1.28" round 240×240 (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 - +// Per-board pin wiring and color order live in hardware/board.h (selectable at +// compile time via -DBOARD_ESP32_2424S012 and at runtime via the setup portal). constexpr int kDisplayWidth = 240; constexpr int kDisplayHeight = 240; constexpr uint32_t kDisplaySpiWriteHz = 40000000; -// GC9A01 modules often need invert + BGR for correct black/green output +// GC9A01 modules need color inversion on for correct output. constexpr bool kDisplayInvert = true; -constexpr bool kDisplayRgbOrder = true; // --- Radar center defaults (overridden via WiFi setup portal) --- constexpr double kDefaultRadarLat = 52.3676; diff --git a/include/hardware/board.h b/include/hardware/board.h new file mode 100644 index 00000000..605b4958 --- /dev/null +++ b/include/hardware/board.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +// Board support: maps a target board to its GC9A01 display wiring. The board +// can be chosen at compile time (-DBOARD_ESP32_2424S012, see platformio.ini) +// and overridden at runtime from the Wi-Fi setup portal (persisted in NVS, +// applied on the next boot). +namespace hardware::board { + +enum class Board : uint8_t { + kSuperMini = 0, // ESP32-C3 Super Mini + separately wired GC9A01 + kEsp32_2424S012 = 1, // Sunton/JCZN integrated 1.28" round board +}; +constexpr uint8_t kBoardCount = 2; + +/** Board-dependent display wiring. Pins are GPIO numbers; -1 = not wired. */ +struct DisplayPins { + int pin_sclk; + int pin_mosi; + int pin_dc; + int pin_cs; + int pin_rst; // -1 = tied to chip reset (not wired) + int pin_backlight; // -1 = hard-wired / not GPIO-controlled + bool rgb_order; // true = RGB, false = BGR +}; + +inline constexpr DisplayPins kBoards[kBoardCount] = { + // kSuperMini: ESP32-C3 Super Mini with a separately wired GC9A01. + {/*sclk*/ 4, /*mosi*/ 3, /*dc*/ 10, /*cs*/ 1, /*rst*/ 0, /*bl*/ -1, + /*rgb_order*/ true}, + // kEsp32_2424S012: Sunton/JCZN integrated round board (BL on GPIO3, BGR). + {/*sclk*/ 6, /*mosi*/ 7, /*dc*/ 2, /*cs*/ 10, /*rst*/ -1, /*bl*/ 3, + /*rgb_order*/ false}, +}; + +constexpr Board compileDefault() { +#if defined(BOARD_ESP32_2424S012) + return Board::kEsp32_2424S012; +#else + return Board::kSuperMini; +#endif +} + +constexpr const DisplayPins& pins(Board b) { + return kBoards[static_cast(b)]; +} + +constexpr bool isValidIndex(long v) { return v >= 0 && v < kBoardCount; } + +const char* name(Board b); + +// Runtime-selected board: NVS override if set and valid, else compileDefault(). +Board active(); +const DisplayPins& activePins(); +void setActive(Board b); // persists to NVS; takes effect on next boot + +} // namespace hardware::board diff --git a/include/hardware/lgfx_config.hpp b/include/hardware/lgfx_config.hpp index 4f1484b6..47e5d8bc 100644 --- a/include/hardware/lgfx_config.hpp +++ b/include/hardware/lgfx_config.hpp @@ -4,33 +4,39 @@ #include #include "config.h" +#include "hardware/board.h" -/** LovyanGFX device: GC9A01 on SPI. Pin values come from config.h. */ +/** LovyanGFX device: GC9A01 on SPI. Pin values come from hardware/board.h. */ class LGFX : public lgfx::LGFX_Device { lgfx::Bus_SPI _bus; lgfx::Panel_GC9A01 _panel; public: LGFX() { + applyBoard(hardware::board::pins(hardware::board::compileDefault())); + setPanel(&_panel); + } + + /** (Re)configure SPI bus and panel for a board. Call before init(). */ + void applyBoard(const hardware::board::DisplayPins& p) { { auto cfg = _bus.config(); cfg.spi_host = SPI2_HOST; cfg.freq_write = config::kDisplaySpiWriteHz; - cfg.pin_sclk = static_cast(config::kDisplayPinSclk); - cfg.pin_mosi = static_cast(config::kDisplayPinMosi); + cfg.pin_sclk = p.pin_sclk; + cfg.pin_mosi = p.pin_mosi; cfg.pin_miso = -1; - cfg.pin_dc = static_cast(config::kDisplayPinDc); + cfg.pin_dc = p.pin_dc; _bus.config(cfg); _panel.setBus(&_bus); } { auto cfg = _panel.config(); - cfg.pin_cs = static_cast(config::kDisplayPinCs); - cfg.pin_rst = static_cast(config::kDisplayPinRst); + cfg.pin_cs = p.pin_cs; + cfg.pin_rst = p.pin_rst; cfg.invert = config::kDisplayInvert; - cfg.rgb_order = config::kDisplayRgbOrder; + cfg.rgb_order = p.rgb_order; _panel.config(cfg); } - setPanel(&_panel); } }; diff --git a/platformio.ini b/platformio.ini index c81ce535..602f019f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -24,3 +24,12 @@ lib_deps = lovyan03/LovyanGFX@^1.2.7 tzapu/WiFiManager@^2.0.17 bblanchon/ArduinoJson@^7.4.2 + +; Sunton/JCZN ESP32-2424S012 — integrated 1.28" round GC9A01 + CST816 touch. +; Same ESP32-C3 target as supermini; only the default display pinout differs. +; The board can also be switched at runtime from the Wi-Fi setup portal. +[env:esp32-2424s012] +extends = env:supermini +build_flags = + ${env:supermini.build_flags} + -DBOARD_ESP32_2424S012 diff --git a/src/hardware/board.cpp b/src/hardware/board.cpp new file mode 100644 index 00000000..ea027fef --- /dev/null +++ b/src/hardware/board.cpp @@ -0,0 +1,57 @@ +#include "hardware/board.h" + +#include +#include + +namespace hardware::board { + +namespace { + +constexpr char kPrefsNamespace[] = "board"; +constexpr char kKeySel[] = "sel"; + +const char* const kNames[kBoardCount] = { + "ESP32-C3 Super Mini", + "ESP32-2424S012", +}; + +bool s_loaded = false; +Board s_active = compileDefault(); + +} // namespace + +const char* name(Board b) { return kNames[static_cast(b)]; } + +Board active() { + if (s_loaded) { + return s_active; + } + Preferences prefs; + if (prefs.begin(kPrefsNamespace, true)) { + if (prefs.isKey(kKeySel)) { + const long v = prefs.getUChar(kKeySel, static_cast(compileDefault())); + if (isValidIndex(v)) { + s_active = static_cast(v); + } + } + prefs.end(); + } + s_loaded = true; + Serial.printf("[board] active: %s (%s)\n", name(s_active), + s_active == compileDefault() ? "default" : "override"); + return s_active; +} + +const DisplayPins& activePins() { return pins(active()); } + +void setActive(Board b) { + Preferences prefs; + if (prefs.begin(kPrefsNamespace, false)) { + prefs.putUChar(kKeySel, static_cast(b)); + prefs.end(); + } + s_active = b; + s_loaded = true; +} + +} // namespace hardware::board diff --git a/src/hardware/display.cpp b/src/hardware/display.cpp index 42f82524..d14bcee2 100644 --- a/src/hardware/display.cpp +++ b/src/hardware/display.cpp @@ -1,10 +1,27 @@ #include "hardware/display.h" +#include + +#include "hardware/board.h" #include "hardware/display_font.h" LGFX tft; void displayInit() { + // Apply the runtime-selected board (NVS override, else compile default) before + // init so a portal board change takes effect after reboot. + const hardware::board::DisplayPins& p = hardware::board::activePins(); + tft.applyBoard(p); + + // Boards that gate the backlight on a GPIO (e.g. ESP32-2424S012) need it + // driven high (active high). Driven directly rather than via LEDC so it does + // not depend on PWM channel availability. Boards with a hard-wired backlight + // use pin -1 and skip this. + if (p.pin_backlight >= 0) { + pinMode(p.pin_backlight, OUTPUT); + digitalWrite(p.pin_backlight, HIGH); + } + tft.init(); tft.setRotation(0); tft.setBrightness(255); diff --git a/src/services/wifi_setup.cpp b/src/services/wifi_setup.cpp index 2fa8b723..427e1d46 100644 --- a/src/services/wifi_setup.cpp +++ b/src/services/wifi_setup.cpp @@ -14,6 +14,7 @@ #endif #include "config.h" +#include "hardware/board.h" #include "services/radar_location.h" #include "ui/radar_range.h" #include "ui/status_screens.h" @@ -84,6 +85,33 @@ char s_runways_checkbox_attrs[32] = "type=\"checkbox\""; WiFiManagerParameter s_param_runways("show_runways", "Show airport runways", "T", 2, s_runways_checkbox_attrs, WFM_LABEL_AFTER); +// Board selector. Custom-HTML-only param renders a "); + for (uint8_t i = 0; i < hardware::board::kBoardCount && n > 0 && + n < static_cast(sizeof(s_board_select_html)); + ++i) { + n += snprintf(s_board_select_html + n, sizeof(s_board_select_html) - n, + "", i, + i == cur ? " selected" : "", + hardware::board::name(static_cast(i))); + } + if (n > 0 && n < static_cast(sizeof(s_board_select_html))) { + snprintf(s_board_select_html + n, sizeof(s_board_select_html) - n, ""); + } +} + void refreshPortalParamDefaults() { char lat_buf[kCoordParamLen + 1]; char lon_buf[kCoordParamLen + 1]; @@ -97,6 +125,29 @@ void refreshPortalParamDefaults() { snprintf(s_runways_checkbox_attrs, sizeof(s_runways_checkbox_attrs), "type=\"checkbox\"%s", ui::radar::showRunways() ? " checked" : ""); s_param_runways.setValue("T", 2); + buildBoardSelectHtml(); +} + +void saveBoardFromPortal() { + if (!s_wm.server) { + return; + } + const String value = s_wm.server->arg("board"); + if (value.length() == 0) { + return; + } + const long sel = value.toInt(); + if (!hardware::board::isValidIndex(sel)) { + return; + } + const auto chosen = static_cast(sel); + if (chosen == hardware::board::active()) { + return; + } + hardware::board::setActive(chosen); + Serial.printf("Board set to %s — rebooting to apply\n", + hardware::board::name(chosen)); + s_reboot_at_ms = millis() + 1200; // let the HTTP response flush first } void onPortalParamsSaved() { @@ -106,6 +157,7 @@ void onPortalParamsSaved() { } ui::radar::saveMilesFromPortal(s_param_miles.getValue()); ui::radar::saveRunwaysFromPortal(s_param_runways.getValue()); + saveBoardFromPortal(); } void attachPortalParams(WiFiManager& wm) { @@ -114,6 +166,7 @@ void attachPortalParams(WiFiManager& wm) { wm.addParameter(&s_param_lon); wm.addParameter(&s_param_miles); wm.addParameter(&s_param_runways); + wm.addParameter(&s_param_board); wm.setSaveParamsCallback(onPortalParamsSaved); } @@ -414,6 +467,10 @@ bool wifiReconnect() { } void wifiLoop() { + if (s_reboot_at_ms != 0 && millis() >= s_reboot_at_ms) { + Serial.println("Rebooting to apply board change..."); + esp_restart(); + } ensureWifiManager(); if (wifiLinkUp()) { if (!s_wm.getWebPortalActive() && !s_wm.getConfigPortalActive()) { diff --git a/src/ui/radar_display.cpp b/src/ui/radar_display.cpp index b0d33399..4f178225 100644 --- a/src/ui/radar_display.cpp +++ b/src/ui/radar_display.cpp @@ -7,6 +7,7 @@ #include #include "config.h" +#include "hardware/board.h" #include "hardware/display.h" #include "hardware/display_font.h" #include "services/adsb_client.h" @@ -180,7 +181,7 @@ void initPalette() { radar::kColorLabel = tft.color565(255, 255, 255); radar::kColorCenter = tft.color565(255, 255, 255); // GC9A01 BGR panel: swap R/B in color565 so logical red renders red on screen. - if (config::kDisplayRgbOrder) { + if (hardware::board::activePins().rgb_order) { radar::kColorAircraft = tft.color565(radar::kAircraftB, radar::kAircraftG, radar::kAircraftR); } else { From 8851d7907ad43d703509b42b2c2cdf57e2ca9818 Mon Sep 17 00:00:00 2001 From: Crispin Proctor Date: Fri, 19 Jun 2026 19:27:20 +0100 Subject: [PATCH 2/5] Log radar location and per-aircraft callsigns over serial Print the active radar lat/lon (and whether it came from NVS or the default) at startup, and list each fetched aircraft's callsign/flight, position, altitude, track and ground speed. Aids debugging location and ADS-B issues. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/adsb_client.cpp | 8 ++++++++ src/services/radar_location.cpp | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/src/services/adsb_client.cpp b/src/services/adsb_client.cpp index 84be21bb..139c22fc 100644 --- a/src/services/adsb_client.cpp +++ b/src/services/adsb_client.cpp @@ -276,6 +276,14 @@ bool fetchUpdate(double center_lat, double center_lon, float fetch_radius_km) { s_aircraft_count = n; Serial.printf("adsb: %u aircraft\n", static_cast(n)); + for (size_t i = 0; i < n; ++i) { + Serial.printf(" [%u] %s %.4f,%.4f %s trk=%.0f gs=%.0f\n", + static_cast(i), + s_aircraft[i].callsign[0] != '\0' ? s_aircraft[i].callsign + : "(no id)", + s_aircraft[i].lat, s_aircraft[i].lon, s_aircraft[i].alt, + s_aircraft[i].track_deg, s_aircraft[i].gs_knots); + } return true; } diff --git a/src/services/radar_location.cpp b/src/services/radar_location.cpp index a8c9c273..e4393041 100644 --- a/src/services/radar_location.cpp +++ b/src/services/radar_location.cpp @@ -47,6 +47,7 @@ void persist(double lat, double lon) { } // namespace void init() { + bool loaded_from_nvs = false; Preferences prefs; prefs.begin(kPrefsNamespace, true); if (prefs.isKey(kKeyLat) && prefs.isKey(kKeyLon)) { @@ -55,9 +56,12 @@ void init() { if (validLatLon(lat, lon)) { s_lat = lat; s_lon = lon; + loaded_from_nvs = true; } } prefs.end(); + Serial.printf("[location] init: lat=%.6f lon=%.6f (%s)\n", s_lat, s_lon, + loaded_from_nvs ? "saved" : "default"); } double lat() { return s_lat; } From 046c3af4e8251de7a2d10033d9afad4ac29dd771 Mon Sep 17 00:00:00 2001 From: Crispin Proctor Date: Sat, 20 Jun 2026 13:54:28 +0100 Subject: [PATCH 3/5] Add optional airline label on aircraft tags Resolve each flight's airline from its callsign's ICAO prefix (e.g. BAW in BAW123) via a curated lookup table holding the ICAO code, IATA acronym, full name, and a friendly short name. A new Show: selector in the Wi-Fi setup portal chooses None / Full Airline Name / Airline Abbreviation, persisted to NVS. When set and the callsign resolves, the airline is rendered as an extra line on the aircraft tag. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/data/airlines.h | 23 +++++ include/services/adsb_client.h | 9 +- include/ui/radar_range.h | 10 +++ src/data/airlines_data.cpp | 158 +++++++++++++++++++++++++++++++++ src/services/adsb_client.cpp | 14 +-- src/services/wifi_setup.cpp | 30 +++++++ src/ui/radar_display.cpp | 75 +++++++++------- src/ui/radar_range.cpp | 34 +++++++ 8 files changed, 313 insertions(+), 40 deletions(-) create mode 100644 include/data/airlines.h create mode 100644 src/data/airlines_data.cpp diff --git a/include/data/airlines.h b/include/data/airlines.h new file mode 100644 index 00000000..c31f96ba --- /dev/null +++ b/include/data/airlines.h @@ -0,0 +1,23 @@ +#pragma once + +// Airline lookup: maps a flight callsign's 3-letter ICAO airline designator +// (e.g. "BAW" in "BAW123") to the airline's IATA acronym and full name. +// Curated list of common/major airlines; unknown or non-airline (GA, military, +// registration) callsigns resolve to nullptr. +namespace data::airlines { + +struct Airline { + const char* icao; // 3-letter ICAO designator (callsign prefix) + const char* iata; // 2-letter IATA code / acronym (may be "") + const char* name; // full airline name, e.g. "British Airways" + const char* short_name; // friendly short name, e.g. "Virgin", "American" +}; + +/** + * Resolve a flight callsign (e.g. "BAW123") to an airline, or nullptr if the + * callsign does not look like an airline flight (needs 3 letters + a digit) or + * the ICAO code is not in the table. + */ +const Airline* forCallsign(const char* callsign); + +} // namespace data::airlines diff --git a/include/services/adsb_client.h b/include/services/adsb_client.h index 2b5dd787..bca00cf3 100644 --- a/include/services/adsb_client.h +++ b/include/services/adsb_client.h @@ -2,6 +2,8 @@ #include +#include "data/airlines.h" + namespace services::adsb { struct Aircraft { @@ -10,9 +12,12 @@ struct Aircraft { float nose_deg; float track_deg; float gs_knots; - char callsign[9]; - char type[5]; + char callsign[9]; // flight/callsign, e.g. "BAW123" + char type[5]; // aircraft type code, e.g. "A320" char alt[12]; + // Resolved airline (nullptr if the callsign is not a known airline flight). + // Provides the IATA acronym, full name, and friendly short name. + const data::airlines::Airline* airline; }; constexpr size_t kMaxAircraft = 64; diff --git a/include/ui/radar_range.h b/include/ui/radar_range.h index a95c5327..a4d4ca78 100644 --- a/include/ui/radar_range.h +++ b/include/ui/radar_range.h @@ -43,11 +43,21 @@ uint8_t rangeIndex(); /** ADSB fetch radius (km): scaled to screen edge so beyond-ring dots have data. */ float fetchRadiusKm(); +/** How to label aircraft with their airline (portal "Show:" selector). */ +enum class AirlineDisplay : uint8_t { + kNone = 0, // don't show airline + kFullName = 1, // e.g. "British Airways" + kAbbrev = 2, // friendly short name, e.g. "Virgin" +}; + bool useMiles(); bool showRunways(); +AirlineDisplay airlineDisplay(); /** WiFi portal checkbox: "T" = miles, otherwise km. */ void saveMilesFromPortal(const char* checkbox_value); void saveRunwaysFromPortal(const char* checkbox_value); +/** WiFi portal select: "0" none, "1" full name, "2" abbreviation. */ +void saveAirlineDisplayFromPortal(const char* select_value); void formatRing3Label(char* buf, size_t len, float ring3_km, bool use_miles); void formatCurrentRing3Label(char* buf, size_t len); /** Reset distance units to km (e.g. with WiFi credential wipe). */ diff --git a/src/data/airlines_data.cpp b/src/data/airlines_data.cpp new file mode 100644 index 00000000..0fc3cd64 --- /dev/null +++ b/src/data/airlines_data.cpp @@ -0,0 +1,158 @@ +#include "data/airlines.h" + +#include +#include + +namespace data::airlines { + +namespace { + +// Curated list of common/major airlines, keyed by ICAO designator (callsign +// prefix). Extend as needed. Order is not significant (linear lookup). +constexpr Airline kAirlines[] = { + // {ICAO, IATA, full name, friendly short name} + // North America + {"AAL", "AA", "American Airlines", "American"}, + {"UAL", "UA", "United Airlines", "United"}, + {"DAL", "DL", "Delta Air Lines", "Delta"}, + {"SWA", "WN", "Southwest Airlines", "Southwest"}, + {"JBU", "B6", "JetBlue Airways", "JetBlue"}, + {"ASA", "AS", "Alaska Airlines", "Alaska"}, + {"FFT", "F9", "Frontier Airlines", "Frontier"}, + {"NKS", "NK", "Spirit Airlines", "Spirit"}, + {"HAL", "HA", "Hawaiian Airlines", "Hawaiian"}, + {"ACA", "AC", "Air Canada", "Air Canada"}, + {"WJA", "WS", "WestJet", "WestJet"}, + {"AMX", "AM", "Aeromexico", "Aeromexico"}, + {"VOI", "Y4", "Volaris", "Volaris"}, + {"FDX", "FX", "FedEx Express", "FedEx"}, + {"UPS", "5X", "UPS Airlines", "UPS"}, + {"GTI", "5Y", "Atlas Air", "Atlas"}, + // United Kingdom & Ireland + {"BAW", "BA", "British Airways", "British Airways"}, + {"VIR", "VS", "Virgin Atlantic", "Virgin"}, + {"EZY", "U2", "easyJet", "easyJet"}, + {"EXS", "LS", "Jet2", "Jet2"}, + {"RYR", "FR", "Ryanair", "Ryanair"}, + {"TOM", "BY", "TUI Airways", "TUI"}, + // Western Europe + {"DLH", "LH", "Lufthansa", "Lufthansa"}, + {"AFR", "AF", "Air France", "Air France"}, + {"KLM", "KL", "KLM", "KLM"}, + {"IBE", "IB", "Iberia", "Iberia"}, + {"SWR", "LX", "Swiss", "Swiss"}, + {"AUA", "OS", "Austrian Airlines", "Austrian"}, + {"BEL", "SN", "Brussels Airlines", "Brussels"}, + {"TAP", "TP", "TAP Air Portugal", "TAP"}, + {"ITY", "AZ", "ITA Airways", "ITA"}, + {"VLG", "VY", "Vueling", "Vueling"}, + {"EWG", "EW", "Eurowings", "Eurowings"}, + {"CFG", "DE", "Condor", "Condor"}, + {"TVF", "TO", "Transavia France", "Transavia"}, + {"TRA", "HV", "Transavia", "Transavia"}, + // Nordics & Baltics + {"SAS", "SK", "Scandinavian Airlines", "SAS"}, + {"FIN", "AY", "Finnair", "Finnair"}, + {"NAX", "DY", "Norwegian", "Norwegian"}, + {"NSZ", "DY", "Norwegian", "Norwegian"}, + {"ICE", "FI", "Icelandair", "Icelandair"}, + {"BTI", "BT", "airBaltic", "airBaltic"}, + // Central & Eastern Europe + {"WZZ", "W6", "Wizz Air", "Wizz"}, + {"LOT", "LO", "LOT Polish Airlines", "LOT"}, + {"CSA", "OK", "Czech Airlines", "Czech"}, + {"AEE", "A3", "Aegean Airlines", "Aegean"}, + {"THY", "TK", "Turkish Airlines", "Turkish"}, + {"PGT", "PC", "Pegasus Airlines", "Pegasus"}, + {"AFL", "SU", "Aeroflot", "Aeroflot"}, + {"SBI", "S7", "S7 Airlines", "S7"}, + {"ROT", "RO", "Tarom", "Tarom"}, + // Middle East + {"UAE", "EK", "Emirates", "Emirates"}, + {"QTR", "QR", "Qatar Airways", "Qatar"}, + {"ETD", "EY", "Etihad Airways", "Etihad"}, + {"FDB", "FZ", "flydubai", "flydubai"}, + {"ABY", "G9", "Air Arabia", "Air Arabia"}, + {"SVA", "SV", "Saudia", "Saudia"}, + {"ELY", "LY", "El Al", "El Al"}, + {"MEA", "ME", "Middle East Airlines", "MEA"}, + {"RJA", "RJ", "Royal Jordanian", "Royal Jordanian"}, + {"GFA", "GF", "Gulf Air", "Gulf Air"}, + {"OMA", "WY", "Oman Air", "Oman Air"}, + {"KAC", "KU", "Kuwait Airways", "Kuwait"}, + // Africa + {"SAA", "SA", "South African Airways", "South African"}, + {"ETH", "ET", "Ethiopian Airlines", "Ethiopian"}, + {"MSR", "MS", "EgyptAir", "EgyptAir"}, + {"RAM", "AT", "Royal Air Maroc", "Royal Air Maroc"}, + {"KQA", "KQ", "Kenya Airways", "Kenya"}, + {"DAH", "AH", "Air Algerie", "Air Algerie"}, + {"TAR", "TU", "Tunisair", "Tunisair"}, + // Asia-Pacific + {"SIA", "SQ", "Singapore Airlines", "Singapore"}, + {"CPA", "CX", "Cathay Pacific", "Cathay"}, + {"ANA", "NH", "All Nippon Airways", "ANA"}, + {"JAL", "JL", "Japan Airlines", "Japan"}, + {"KAL", "KE", "Korean Air", "Korean"}, + {"AAR", "OZ", "Asiana Airlines", "Asiana"}, + {"CCA", "CA", "Air China", "Air China"}, + {"CES", "MU", "China Eastern Airlines", "China Eastern"}, + {"CSN", "CZ", "China Southern Airlines", "China Southern"}, + {"CHH", "HU", "Hainan Airlines", "Hainan"}, + {"CAL", "CI", "China Airlines", "China Airlines"}, + {"EVA", "BR", "EVA Air", "EVA"}, + {"THA", "TG", "Thai Airways", "Thai"}, + {"MAS", "MH", "Malaysia Airlines", "Malaysia"}, + {"AXM", "AK", "AirAsia", "AirAsia"}, + {"GIA", "GA", "Garuda Indonesia", "Garuda"}, + {"AIC", "AI", "Air India", "Air India"}, + {"IGO", "6E", "IndiGo", "IndiGo"}, + {"PAL", "PR", "Philippine Airlines", "Philippine"}, + {"CEB", "5J", "Cebu Pacific", "Cebu Pacific"}, + {"VJC", "VJ", "VietJet Air", "VietJet"}, + {"HVN", "VN", "Vietnam Airlines", "Vietnam"}, + {"QFA", "QF", "Qantas", "Qantas"}, + {"VOZ", "VA", "Virgin Australia", "Virgin Australia"}, + {"JST", "JQ", "Jetstar", "Jetstar"}, + {"ANZ", "NZ", "Air New Zealand", "Air New Zealand"}, + // Latin America + {"LAN", "LA", "LATAM Airlines", "LATAM"}, + {"TAM", "JJ", "LATAM Brasil", "LATAM"}, + {"GLO", "G3", "Gol", "Gol"}, + {"AZU", "AD", "Azul", "Azul"}, + {"ARG", "AR", "Aerolineas Argentinas", "Aerolineas"}, + {"AVA", "AV", "Avianca", "Avianca"}, + {"CMP", "CM", "Copa Airlines", "Copa"}, +}; + +constexpr size_t kAirlineCount = sizeof(kAirlines) / sizeof(kAirlines[0]); + +} // namespace + +const Airline* forCallsign(const char* callsign) { + if (callsign == nullptr) { + return nullptr; + } + // Airline callsign = 3-letter ICAO designator + flight number (starts digit). + if (!std::isalpha(static_cast(callsign[0])) || + !std::isalpha(static_cast(callsign[1])) || + !std::isalpha(static_cast(callsign[2])) || + !std::isdigit(static_cast(callsign[3]))) { + return nullptr; + } + + char code[4] = { + static_cast(std::toupper(static_cast(callsign[0]))), + static_cast(std::toupper(static_cast(callsign[1]))), + static_cast(std::toupper(static_cast(callsign[2]))), + '\0'}; + + for (size_t i = 0; i < kAirlineCount; ++i) { + if (std::strcmp(kAirlines[i].icao, code) == 0) { + return &kAirlines[i]; + } + } + return nullptr; +} + +} // namespace data::airlines diff --git a/src/services/adsb_client.cpp b/src/services/adsb_client.cpp index 139c22fc..bdfa834b 100644 --- a/src/services/adsb_client.cpp +++ b/src/services/adsb_client.cpp @@ -8,6 +8,7 @@ #include #include "config.h" +#include "data/airlines.h" namespace services::adsb { @@ -189,6 +190,8 @@ void formatAltitudeTag(const JsonObject& plane, char* out, size_t out_len) { void fillTagFields(Aircraft* ac, const JsonObject& plane) { copyJsonStringTrimmed(plane, "flight", ac->callsign, sizeof(ac->callsign)); + // Resolve the airline from the flight callsign before any hex fallback. + ac->airline = data::airlines::forCallsign(ac->callsign); if (ac->callsign[0] == '\0') { copyJsonStringTrimmed(plane, "hex", ac->callsign, sizeof(ac->callsign)); } @@ -277,12 +280,13 @@ bool fetchUpdate(double center_lat, double center_lon, float fetch_radius_km) { s_aircraft_count = n; Serial.printf("adsb: %u aircraft\n", static_cast(n)); for (size_t i = 0; i < n; ++i) { - Serial.printf(" [%u] %s %.4f,%.4f %s trk=%.0f gs=%.0f\n", + const Aircraft& ac = s_aircraft[i]; + Serial.printf(" [%u] %s %s %s %.4f,%.4f %s trk=%.0f gs=%.0f\n", static_cast(i), - s_aircraft[i].callsign[0] != '\0' ? s_aircraft[i].callsign - : "(no id)", - s_aircraft[i].lat, s_aircraft[i].lon, s_aircraft[i].alt, - s_aircraft[i].track_deg, s_aircraft[i].gs_knots); + ac.callsign[0] != '\0' ? ac.callsign : "(no id)", + ac.airline != nullptr ? ac.airline->iata : "", + ac.airline != nullptr ? ac.airline->name : "", + ac.lat, ac.lon, ac.alt, ac.track_deg, ac.gs_knots); } return true; } diff --git a/src/services/wifi_setup.cpp b/src/services/wifi_setup.cpp index 427e1d46..504c85ac 100644 --- a/src/services/wifi_setup.cpp +++ b/src/services/wifi_setup.cpp @@ -85,6 +85,11 @@ char s_runways_checkbox_attrs[32] = "type=\"checkbox\""; WiFiManagerParameter s_param_runways("show_runways", "Show airport runways", "T", 2, s_runways_checkbox_attrs, WFM_LABEL_AFTER); +// Airline label selector. Custom-HTML-only param renders a ; the // posted value is read back directly from the web server (see onPortalParamsSaved). char s_board_select_html[320] = ""; @@ -112,6 +117,25 @@ void buildBoardSelectHtml() { } } +void buildAirlineSelectHtml() { + const uint8_t cur = static_cast(ui::radar::airlineDisplay()); + const char* options[] = {"None", "Full Airline Name", "Airline Abbreviation"}; + int n = snprintf(s_airline_select_html, sizeof(s_airline_select_html), + "
" + ""); + } +} + void refreshPortalParamDefaults() { char lat_buf[kCoordParamLen + 1]; char lon_buf[kCoordParamLen + 1]; @@ -125,6 +149,7 @@ void refreshPortalParamDefaults() { snprintf(s_runways_checkbox_attrs, sizeof(s_runways_checkbox_attrs), "type=\"checkbox\"%s", ui::radar::showRunways() ? " checked" : ""); s_param_runways.setValue("T", 2); + buildAirlineSelectHtml(); buildBoardSelectHtml(); } @@ -157,6 +182,10 @@ void onPortalParamsSaved() { } ui::radar::saveMilesFromPortal(s_param_miles.getValue()); ui::radar::saveRunwaysFromPortal(s_param_runways.getValue()); + if (s_wm.server) { + ui::radar::saveAirlineDisplayFromPortal( + s_wm.server->arg("airline_mode").c_str()); + } saveBoardFromPortal(); } @@ -166,6 +195,7 @@ void attachPortalParams(WiFiManager& wm) { wm.addParameter(&s_param_lon); wm.addParameter(&s_param_miles); wm.addParameter(&s_param_runways); + wm.addParameter(&s_param_airline); wm.addParameter(&s_param_board); wm.setSaveParamsCallback(onPortalParamsSaved); } diff --git a/src/ui/radar_display.cpp b/src/ui/radar_display.cpp index 4f178225..8245fed6 100644 --- a/src/ui/radar_display.cpp +++ b/src/ui/radar_display.cpp @@ -379,37 +379,57 @@ void applyTagStyle() { } } -int measureTagBlockWidth(const services::adsb::Aircraft& plane) { - applyTagStyle(); - int max_w = 0; - if (plane.callsign[0] != '\0') { - const int w = s_draw->textWidth(plane.callsign); - if (w > max_w) { - max_w = w; +struct TagLine { + const char* text; + uint16_t color; +}; + +constexpr int kMaxTagLines = 4; + +// Builds the tag lines for an aircraft into out_lines. The airline line is shown +// (full name or friendly abbreviation) only when enabled and resolved. +int buildTagLines(const services::adsb::Aircraft& plane, TagLine* out_lines) { + int n = 0; + const ui::radar::AirlineDisplay mode = ui::radar::airlineDisplay(); + if (mode != ui::radar::AirlineDisplay::kNone && plane.airline != nullptr) { + const char* text = (mode == ui::radar::AirlineDisplay::kAbbrev) + ? plane.airline->short_name + : plane.airline->name; + if (text != nullptr && text[0] != '\0') { + out_lines[n++] = {text, radar::kColorLabel}; } } + if (plane.callsign[0] != '\0') { + out_lines[n++] = {plane.callsign, radar::kColorLabel}; + } if (plane.type[0] != '\0') { - const int w = s_draw->textWidth(plane.type); - if (w > max_w) { - max_w = w; - } + out_lines[n++] = {plane.type, radar::kColorTagType}; } if (plane.alt[0] != '\0') { - const int w = s_draw->textWidth(plane.alt); - if (w > max_w) { - max_w = w; - } + out_lines[n++] = {plane.alt, radar::kColorTagAltitude}; } - return max_w; + return n; } void drawAircraftTag(int x, int y, const services::adsb::Aircraft& plane) { initTagLabelMetrics(); applyTagStyle(); + TagLine lines[kMaxTagLines]; + const int n_lines = buildTagLines(plane, lines); + if (n_lines == 0) { + return; + } + const int line_h = s_draw->fontHeight(); - const int block_w = measureTagBlockWidth(plane); - const int block_h = line_h * 3; + int block_w = 0; + for (int i = 0; i < n_lines; ++i) { + const int w = s_draw->textWidth(lines[i].text); + if (w > block_w) { + block_w = w; + } + } + const int block_h = line_h * n_lines; int ly = y - block_h / 2; const int symbol_half = @@ -428,21 +448,10 @@ void drawAircraftTag(int x, int y, const services::adsb::Aircraft& plane) { } ly = std::max(1, std::min(ly, radar::kSize - block_h - 1)); - if (plane.callsign[0] != '\0') { - s_draw->setTextColor(radar::kColorLabel, radar::kColorBackground); - s_draw->drawString(plane.callsign, anchor_x, ly); - } - ly += line_h; - - if (plane.type[0] != '\0') { - s_draw->setTextColor(radar::kColorTagType, radar::kColorBackground); - s_draw->drawString(plane.type, anchor_x, ly); - } - ly += line_h; - - if (plane.alt[0] != '\0') { - s_draw->setTextColor(radar::kColorTagAltitude, radar::kColorBackground); - s_draw->drawString(plane.alt, anchor_x, ly); + for (int i = 0; i < n_lines; ++i) { + s_draw->setTextColor(lines[i].color, radar::kColorBackground); + s_draw->drawString(lines[i].text, anchor_x, ly); + ly += line_h; } } diff --git a/src/ui/radar_range.cpp b/src/ui/radar_range.cpp index d8bf002e..7c1f9c3c 100644 --- a/src/ui/radar_range.cpp +++ b/src/ui/radar_range.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include namespace ui::radar { @@ -15,6 +16,7 @@ constexpr char kPrefsNamespace[] = "planeradar"; constexpr char kPrefsRangeKey[] = "rangeIdx"; constexpr char kPrefsMilesKey[] = "useMiles"; constexpr char kPrefsRunwaysKey[] = "showRwys"; +constexpr char kPrefsAirlineKey[] = "airlnDisp"; constexpr uint8_t kDefaultRangeIndex = 1; // 10 km ring constexpr float kKmPerMile = 1.609344f; @@ -22,6 +24,7 @@ Preferences s_prefs; uint8_t s_range_index = kDefaultRangeIndex; bool s_use_miles = false; bool s_show_runways = true; +AirlineDisplay s_airline_display = AirlineDisplay::kNone; void saveRangeIndex() { if (!s_prefs.begin(kPrefsNamespace, false)) { @@ -47,6 +50,14 @@ void saveShowRunways() { s_prefs.end(); } +void saveAirlineDisplay() { + if (!s_prefs.begin(kPrefsNamespace, false)) { + return; + } + s_prefs.putUChar(kPrefsAirlineKey, static_cast(s_airline_display)); + s_prefs.end(); +} + bool portalCheckboxChecked(const char* value) { if (value == nullptr || value[0] == '\0') { return false; @@ -70,6 +81,11 @@ void rangeInit() { (saved < kRangePresetCount) ? saved : kDefaultRangeIndex; s_use_miles = s_prefs.getBool(kPrefsMilesKey, false); s_show_runways = s_prefs.getBool(kPrefsRunwaysKey, true); + const uint8_t airline_mode = s_prefs.getUChar( + kPrefsAirlineKey, static_cast(AirlineDisplay::kNone)); + s_airline_display = (airline_mode <= static_cast(AirlineDisplay::kAbbrev)) + ? static_cast(airline_mode) + : AirlineDisplay::kNone; s_prefs.end(); } @@ -93,6 +109,8 @@ bool useMiles() { return s_use_miles; } bool showRunways() { return s_show_runways; } +AirlineDisplay airlineDisplay() { return s_airline_display; } + void saveMilesFromPortal(const char* checkbox_value) { s_use_miles = portalCheckboxChecked(checkbox_value); saveUseMiles(); @@ -105,6 +123,20 @@ void saveRunwaysFromPortal(const char* checkbox_value) { Serial.printf("Runway overlay: %s\n", s_show_runways ? "on" : "off"); } +void saveAirlineDisplayFromPortal(const char* select_value) { + uint8_t mode = static_cast(AirlineDisplay::kNone); + if (select_value != nullptr && select_value[0] != '\0') { + const long v = strtol(select_value, nullptr, 10); + if (v >= 0 && v <= static_cast(AirlineDisplay::kAbbrev)) { + mode = static_cast(v); + } + } + s_airline_display = static_cast(mode); + saveAirlineDisplay(); + const char* labels[] = {"none", "full name", "abbreviation"}; + Serial.printf("Airline display: %s\n", labels[mode]); +} + void formatRing3Label(char* buf, size_t len, float ring3_km, bool use_miles) { if (use_miles) { const int mi = static_cast(lroundf(ring3_km / kKmPerMile)); @@ -122,9 +154,11 @@ void formatCurrentRing3Label(char* buf, size_t len) { void unitsReset() { s_use_miles = false; s_show_runways = true; + s_airline_display = AirlineDisplay::kNone; if (s_prefs.begin(kPrefsNamespace, false)) { s_prefs.remove(kPrefsMilesKey); s_prefs.remove(kPrefsRunwaysKey); + s_prefs.remove(kPrefsAirlineKey); s_prefs.end(); } } From f36a666800bee1c151b5152ce2ec71f92027d0af Mon Sep 17 00:00:00 2001 From: Crispin Proctor Date: Sat, 20 Jun 2026 15:02:27 +0100 Subject: [PATCH 4/5] Add touch-to-open flight details dialog (CST816) On boards with a CST816 capacitive touch panel (e.g. ESP32-2424S012), tapping an aircraft opens a full-screen details dialog; tapping again closes it. - board registry + LGFX config gain CST816 touch pins (I2C), wired via Panel::setTouch so tft.getTouch() works. - radar_display records drawn aircraft screen positions and exposes a hit-test plus a dialog renderer that blanks the screen and lists the details. - Detail fields (airline, type, altitude, speed, track, distance, position) are each toggled by a portal checkbox; a Dialog text scale value scales the dialog font. Both persist to NVS. --- include/hardware/board.h | 16 +++- include/hardware/lgfx_config.hpp | 20 ++++- include/ui/radar_display.h | 10 +++ include/ui/radar_range.h | 23 ++++++ src/main.cpp | 29 ++++++- src/services/wifi_setup.cpp | 42 ++++++++++ src/ui/radar_display.cpp | 133 +++++++++++++++++++++++++++++++ src/ui/radar_range.cpp | 73 +++++++++++++++++ 8 files changed, 340 insertions(+), 6 deletions(-) diff --git a/include/hardware/board.h b/include/hardware/board.h index 605b4958..619501c5 100644 --- a/include/hardware/board.h +++ b/include/hardware/board.h @@ -23,15 +23,23 @@ struct DisplayPins { int pin_rst; // -1 = tied to chip reset (not wired) int pin_backlight; // -1 = hard-wired / not GPIO-controlled bool rgb_order; // true = RGB, false = BGR + // Capacitive touch (CST816, I2C). touch_sda < 0 means no touch on this board. + int touch_sda; + int touch_scl; + int touch_int; // -1 if not wired + int touch_rst; // -1 if not wired }; inline constexpr DisplayPins kBoards[kBoardCount] = { - // kSuperMini: ESP32-C3 Super Mini with a separately wired GC9A01. + // kSuperMini: ESP32-C3 Super Mini with a separately wired GC9A01 (no touch). {/*sclk*/ 4, /*mosi*/ 3, /*dc*/ 10, /*cs*/ 1, /*rst*/ 0, /*bl*/ -1, - /*rgb_order*/ true}, - // kEsp32_2424S012: Sunton/JCZN integrated round board (BL on GPIO3, BGR). + /*rgb_order*/ true, + /*touch sda*/ -1, /*scl*/ -1, /*int*/ -1, /*rst*/ -1}, + // kEsp32_2424S012: Sunton/JCZN integrated round board (BL on GPIO3, BGR, + // CST816 touch on I2C). {/*sclk*/ 6, /*mosi*/ 7, /*dc*/ 2, /*cs*/ 10, /*rst*/ -1, /*bl*/ 3, - /*rgb_order*/ false}, + /*rgb_order*/ false, + /*touch sda*/ 4, /*scl*/ 5, /*int*/ 0, /*rst*/ 1}, }; constexpr Board compileDefault() { diff --git a/include/hardware/lgfx_config.hpp b/include/hardware/lgfx_config.hpp index 47e5d8bc..a1b54cb6 100644 --- a/include/hardware/lgfx_config.hpp +++ b/include/hardware/lgfx_config.hpp @@ -10,6 +10,7 @@ class LGFX : public lgfx::LGFX_Device { lgfx::Bus_SPI _bus; lgfx::Panel_GC9A01 _panel; + lgfx::Touch_CST816S _touch; public: LGFX() { @@ -17,7 +18,7 @@ class LGFX : public lgfx::LGFX_Device { setPanel(&_panel); } - /** (Re)configure SPI bus and panel for a board. Call before init(). */ + /** (Re)configure SPI bus, panel and touch for a board. Call before init(). */ void applyBoard(const hardware::board::DisplayPins& p) { { auto cfg = _bus.config(); @@ -38,5 +39,22 @@ class LGFX : public lgfx::LGFX_Device { cfg.rgb_order = p.rgb_order; _panel.config(cfg); } + if (p.touch_sda >= 0) { + auto cfg = _touch.config(); + cfg.i2c_port = 0; + cfg.i2c_addr = 0x15; // CST816 default address + cfg.pin_sda = p.touch_sda; + cfg.pin_scl = p.touch_scl; + cfg.pin_int = p.touch_int; + cfg.pin_rst = p.touch_rst; + cfg.freq = 400000; + cfg.x_min = 0; + cfg.x_max = config::kDisplayWidth - 1; + cfg.y_min = 0; + cfg.y_max = config::kDisplayHeight - 1; + cfg.offset_rotation = 0; + _touch.config(cfg); + _panel.setTouch(&_touch); + } } }; diff --git a/include/ui/radar_display.h b/include/ui/radar_display.h index 5ab3bb29..2c6d72b5 100644 --- a/include/ui/radar_display.h +++ b/include/ui/radar_display.h @@ -8,4 +8,14 @@ void radarDisplayDraw(); /** Redraw aircraft only (blits cached grid; no full-screen clear). */ void radarDisplayRefreshAircraft(); +/** + * Find the aircraft nearest to a screen point (from the last radar draw). + * Returns its index into services::adsb::aircraftList(), or -1 if none is + * within tap range. + */ +int radarDisplayHitTest(int x, int y); + +/** Blank the screen and draw the flight details dialog for an aircraft. */ +void radarDisplayDrawDialog(int aircraft_index); + } // namespace ui diff --git a/include/ui/radar_range.h b/include/ui/radar_range.h index a4d4ca78..c64b17ba 100644 --- a/include/ui/radar_range.h +++ b/include/ui/radar_range.h @@ -50,6 +50,29 @@ enum class AirlineDisplay : uint8_t { kAbbrev = 2, // friendly short name, e.g. "Virgin" }; +/** Detail fields shown in the tap-to-open flight dialog (each toggle in portal). */ +enum class DialogField : uint8_t { + kAirline = 0, + kType, + kAltitude, + kSpeed, + kTrack, + kDistance, + kPosition, + kCount, +}; + +bool dialogFieldEnabled(DialogField field); +uint16_t dialogFieldsMask(); +void setDialogFieldsMask(uint16_t mask); +/** Multiplier on the flight dialog text size (1.0 = base size). */ +float dialogTextScale(); +void saveDialogTextScaleFromPortal(const char* value); +/** Portal form field name (e.g. "dlg_alt") for a dialog field. */ +const char* dialogFieldId(DialogField field); +/** Human label (e.g. "Altitude") for a dialog field. */ +const char* dialogFieldLabel(DialogField field); + bool useMiles(); bool showRunways(); AirlineDisplay airlineDisplay(); diff --git a/src/main.cpp b/src/main.cpp index 797aecc0..d9862147 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,6 +20,7 @@ bool g_radar_visible = false; unsigned long g_wifi_down_since = 0; unsigned long g_last_reconnect_ms = 0; unsigned long g_last_adsb_fetch_ms = 0; +bool g_dialog_open = false; void showRadarIfConnected() { if (WiFi.status() != WL_CONNECTED) { @@ -49,6 +50,29 @@ void handleBootButton() { } } +// Tap a flight to open a details dialog; tap again (anywhere) to close it. +void handleTouch() { + int32_t tx = 0; + int32_t ty = 0; + const bool touched = tft.getTouch(&tx, &ty); + static bool was_touched = false; + + if (touched && !was_touched) { // act on touch-down edge only + if (g_dialog_open) { + g_dialog_open = false; + showRadarIfConnected(); // repaint radar under the dialog + } else { + const int idx = ui::radarDisplayHitTest(static_cast(tx), + static_cast(ty)); + if (idx >= 0) { + g_dialog_open = true; + ui::radarDisplayDrawDialog(idx); + } + } + } + was_touched = touched; +} + void fetchAndDrawAircraft() { const float fetch_km = ui::radar::fetchRadiusKm(); if (!services::adsb::fetchUpdate(services::location::lat(), @@ -84,6 +108,7 @@ void setup() { void loop() { handleBootButton(); + handleTouch(); wifiLoop(); if (WiFi.status() != WL_CONNECTED) { @@ -107,7 +132,9 @@ void loop() { } } else { g_wifi_down_since = 0; - if (!g_radar_visible) { + if (g_dialog_open) { + // Hold the dialog on screen; don't redraw the radar underneath it. + } else if (!g_radar_visible) { showRadarIfConnected(); } else if (millis() - g_last_adsb_fetch_ms >= config::kAdsbFetchIntervalMs) { g_last_adsb_fetch_ms = millis(); diff --git a/src/services/wifi_setup.cpp b/src/services/wifi_setup.cpp index 504c85ac..1d6e3488 100644 --- a/src/services/wifi_setup.cpp +++ b/src/services/wifi_setup.cpp @@ -90,6 +90,16 @@ WiFiManagerParameter s_param_runways("show_runways", "Show airport runways", "T" char s_airline_select_html[320] = ""; WiFiManagerParameter s_param_airline(s_airline_select_html); +// Flight dialog detail checkboxes. Custom-HTML-only param renders one checkbox +// per DialogField; each is read back from the web server on save. +char s_dialog_fields_html[640] = ""; +WiFiManagerParameter s_param_dialog_fields(s_dialog_fields_html); + +constexpr char kScaleInputAttrs[] = + " type=\"number\" step=\"0.1\" min=\"0.5\" max=\"3\""; +WiFiManagerParameter s_param_dlg_scale("dlg_scale", "Dialog text scale", "1.0", 6, + kScaleInputAttrs); + // Board selector. Custom-HTML-only param renders a %s
", + ui::radar::dialogFieldId(field), + ui::radar::dialogFieldEnabled(field) ? " checked" : "", + ui::radar::dialogFieldLabel(field)); + } +} + void refreshPortalParamDefaults() { char lat_buf[kCoordParamLen + 1]; char lon_buf[kCoordParamLen + 1]; @@ -150,6 +176,10 @@ void refreshPortalParamDefaults() { "type=\"checkbox\"%s", ui::radar::showRunways() ? " checked" : ""); s_param_runways.setValue("T", 2); buildAirlineSelectHtml(); + buildDialogFieldsHtml(); + char scale_buf[8]; + snprintf(scale_buf, sizeof(scale_buf), "%.1f", ui::radar::dialogTextScale()); + s_param_dlg_scale.setValue(scale_buf, 6); buildBoardSelectHtml(); } @@ -185,7 +215,17 @@ void onPortalParamsSaved() { if (s_wm.server) { ui::radar::saveAirlineDisplayFromPortal( s_wm.server->arg("airline_mode").c_str()); + uint16_t dialog_mask = 0; + for (uint8_t i = 0; i < static_cast(ui::radar::DialogField::kCount); + ++i) { + const auto field = static_cast(i); + if (s_wm.server->hasArg(ui::radar::dialogFieldId(field))) { + dialog_mask |= (1u << i); + } + } + ui::radar::setDialogFieldsMask(dialog_mask); } + ui::radar::saveDialogTextScaleFromPortal(s_param_dlg_scale.getValue()); saveBoardFromPortal(); } @@ -196,6 +236,8 @@ void attachPortalParams(WiFiManager& wm) { wm.addParameter(&s_param_miles); wm.addParameter(&s_param_runways); wm.addParameter(&s_param_airline); + wm.addParameter(&s_param_dialog_fields); + wm.addParameter(&s_param_dlg_scale); wm.addParameter(&s_param_board); wm.setSaveParamsCallback(onPortalParamsSaved); } diff --git a/src/ui/radar_display.cpp b/src/ui/radar_display.cpp index 8245fed6..efc102c4 100644 --- a/src/ui/radar_display.cpp +++ b/src/ui/radar_display.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "config.h" @@ -463,11 +464,21 @@ struct AircraftDrawItem { }; struct BeyondDotDrawItem { + size_t index = 0; int x = 0; int y = 0; int dist_sq = 0; }; +// Screen positions of aircraft from the last draw, for touch hit-testing. +struct HitTarget { + size_t index; + int x; + int y; +}; +HitTarget s_hit_targets[services::adsb::kMaxAircraft]; +size_t s_hit_count = 0; + void sortDrawItemsFarFirst(AircraftDrawItem* items, size_t count) { for (size_t i = 1; i < count; ++i) { const AircraftDrawItem key = items[i]; @@ -502,6 +513,7 @@ void drawAircraft() { BeyondDotDrawItem dots[services::adsb::kMaxAircraft]; size_t draw_count = 0; size_t dot_count = 0; + s_hit_count = 0; for (size_t i = 0; i < n; ++i) { float dx_km = 0.0f; @@ -518,6 +530,7 @@ void drawAircraft() { items[draw_count].y = y; items[draw_count].dist_sq = distSqFromCenter(x, y); ++draw_count; + s_hit_targets[s_hit_count++] = {i, x, y}; continue; } @@ -527,10 +540,12 @@ void drawAircraft() { &dot_y)) { continue; } + dots[dot_count].index = i; dots[dot_count].x = dot_x; dots[dot_count].y = dot_y; dots[dot_count].dist_sq = distSqFromCenter(dot_x, dot_y); ++dot_count; + s_hit_targets[s_hit_count++] = {i, dot_x, dot_y}; } sortBeyondDotsFarFirst(dots, dot_count); @@ -721,4 +736,122 @@ void radarDisplayRefreshAircraft() { radarDisplayDraw(); } +int radarDisplayHitTest(int x, int y) { + constexpr int kTapRadius = 26; + constexpr int kTapRadiusSq = kTapRadius * kTapRadius; + int best = -1; + int best_sq = kTapRadiusSq + 1; + for (size_t i = 0; i < s_hit_count; ++i) { + const int dx = s_hit_targets[i].x - x; + const int dy = s_hit_targets[i].y - y; + const int d2 = dx * dx + dy * dy; + if (d2 <= kTapRadiusSq && d2 < best_sq) { + best_sq = d2; + best = static_cast(s_hit_targets[i].index); + } + } + return best; +} + +void radarDisplayDrawDialog(int aircraft_index) { + const size_t n = services::adsb::aircraftCount(); + if (aircraft_index < 0 || static_cast(aircraft_index) >= n) { + return; + } + const services::adsb::Aircraft& ac = + services::adsb::aircraftList()[static_cast(aircraft_index)]; + + float dx_km = 0.0f; + float dy_km = 0.0f; + float dist_km = 0.0f; + offsetKmFromCenter(ac.lat, ac.lon, &dx_km, &dy_km, &dist_km); + + // Title (callsign) plus one line per enabled detail field. + char title[16]; + snprintf(title, sizeof(title), "%s", + ac.callsign[0] != '\0' ? ac.callsign : "(no id)"); + + char lines[8][28]; + int nl = 0; + using ui::radar::DialogField; + if (ui::radar::dialogFieldEnabled(DialogField::kAirline) && + ac.airline != nullptr) { + snprintf(lines[nl++], sizeof(lines[0]), "%s", ac.airline->name); + } + if (ui::radar::dialogFieldEnabled(DialogField::kType) && ac.type[0] != '\0') { + snprintf(lines[nl++], sizeof(lines[0]), "Type: %s", ac.type); + } + if (ui::radar::dialogFieldEnabled(DialogField::kAltitude) && + ac.alt[0] != '\0') { + snprintf(lines[nl++], sizeof(lines[0]), "Alt: %s", ac.alt); + } + if (ui::radar::dialogFieldEnabled(DialogField::kSpeed)) { + snprintf(lines[nl++], sizeof(lines[0]), "Spd: %d kt", + static_cast(lroundf(ac.gs_knots))); + } + if (ui::radar::dialogFieldEnabled(DialogField::kTrack)) { + snprintf(lines[nl++], sizeof(lines[0]), "Trk: %d deg", + static_cast(lroundf(ac.track_deg))); + } + if (ui::radar::dialogFieldEnabled(DialogField::kDistance)) { + if (ui::radar::useMiles()) { + snprintf(lines[nl++], sizeof(lines[0]), "Dist: %.1f mi", + dist_km / 1.609344f); + } else { + snprintf(lines[nl++], sizeof(lines[0]), "Dist: %.1f km", dist_km); + } + } + if (ui::radar::dialogFieldEnabled(DialogField::kPosition)) { + snprintf(lines[nl++], sizeof(lines[0]), "%.3f, %.3f", ac.lat, ac.lon); + } + + // Blank the screen to de-clutter, then draw the details centred. + initTagLabelMetrics(); + initPalette(); + const DrawScope scope(tft); + displayFontEnsureLoaded(tft); + tft.fillScreen(radar::kColorBackground); + tft.setTextDatum(textdatum_t::middle_center); + + const int cx = radar::kCenterX; + + const float scale = ui::radar::dialogTextScale(); + auto setTitleFont = [scale]() { + if (displayFontIsSmooth()) { + displayFontSetSmoothSize(tft, 1.6f * scale); + } else { + displayFontSetBitmap(tft, &fonts::FreeSansBold12pt7b); + } + }; + auto setBodyFont = [scale]() { + if (displayFontIsSmooth()) { + displayFontSetSmoothSize(tft, 1.1f * scale); + } else { + displayFontSetBitmap(tft, &fonts::FreeSansBold9pt7b); + } + }; + + setTitleFont(); + const int title_h = tft.fontHeight(); + setBodyFont(); + const int body_h = tft.fontHeight(); + + const int total_h = title_h + nl * body_h; + int y = radar::kCenterY - total_h / 2 + title_h / 2; + + setTitleFont(); + tft.setTextColor(radar::kColorLabel, radar::kColorBackground); + tft.drawString(title, cx, y); + y += title_h / 2 + body_h / 2; + + setBodyFont(); + for (int i = 0; i < nl; ++i) { + tft.setTextColor(radar::kColorLabel, radar::kColorBackground); + tft.drawString(lines[i], cx, y); + y += body_h; + } + + tft.setTextDatum(textdatum_t::top_left); +} + } // namespace ui diff --git a/src/ui/radar_range.cpp b/src/ui/radar_range.cpp index 7c1f9c3c..c62aeea1 100644 --- a/src/ui/radar_range.cpp +++ b/src/ui/radar_range.cpp @@ -17,14 +17,23 @@ constexpr char kPrefsRangeKey[] = "rangeIdx"; constexpr char kPrefsMilesKey[] = "useMiles"; constexpr char kPrefsRunwaysKey[] = "showRwys"; constexpr char kPrefsAirlineKey[] = "airlnDisp"; +constexpr char kPrefsDialogKey[] = "dlgFields"; +constexpr char kPrefsDialogScaleKey[] = "dlgScale"; constexpr uint8_t kDefaultRangeIndex = 1; // 10 km ring constexpr float kKmPerMile = 1.609344f; +constexpr float kDialogScaleMin = 0.5f; +constexpr float kDialogScaleMax = 3.0f; + +constexpr uint16_t kDialogFieldsAll = + (1u << static_cast(DialogField::kCount)) - 1u; Preferences s_prefs; uint8_t s_range_index = kDefaultRangeIndex; bool s_use_miles = false; bool s_show_runways = true; AirlineDisplay s_airline_display = AirlineDisplay::kNone; +uint16_t s_dialog_fields = kDialogFieldsAll; // all details on by default +float s_dialog_text_scale = 1.0f; void saveRangeIndex() { if (!s_prefs.begin(kPrefsNamespace, false)) { @@ -86,6 +95,8 @@ void rangeInit() { s_airline_display = (airline_mode <= static_cast(AirlineDisplay::kAbbrev)) ? static_cast(airline_mode) : AirlineDisplay::kNone; + s_dialog_fields = s_prefs.getUShort(kPrefsDialogKey, kDialogFieldsAll); + s_dialog_text_scale = s_prefs.getFloat(kPrefsDialogScaleKey, 1.0f); s_prefs.end(); } @@ -111,6 +122,64 @@ bool showRunways() { return s_show_runways; } AirlineDisplay airlineDisplay() { return s_airline_display; } +bool dialogFieldEnabled(DialogField field) { + return (s_dialog_fields & (1u << static_cast(field))) != 0; +} + +uint16_t dialogFieldsMask() { return s_dialog_fields; } + +void setDialogFieldsMask(uint16_t mask) { + s_dialog_fields = mask & kDialogFieldsAll; + if (s_prefs.begin(kPrefsNamespace, false)) { + s_prefs.putUShort(kPrefsDialogKey, s_dialog_fields); + s_prefs.end(); + } +} + +const char* dialogFieldId(DialogField field) { + switch (field) { + case DialogField::kAirline: return "dlg_airline"; + case DialogField::kType: return "dlg_type"; + case DialogField::kAltitude: return "dlg_alt"; + case DialogField::kSpeed: return "dlg_speed"; + case DialogField::kTrack: return "dlg_track"; + case DialogField::kDistance: return "dlg_dist"; + case DialogField::kPosition: return "dlg_pos"; + default: return ""; + } +} + +const char* dialogFieldLabel(DialogField field) { + switch (field) { + case DialogField::kAirline: return "Airline"; + case DialogField::kType: return "Aircraft type"; + case DialogField::kAltitude: return "Altitude"; + case DialogField::kSpeed: return "Ground speed"; + case DialogField::kTrack: return "Track"; + case DialogField::kDistance: return "Distance"; + case DialogField::kPosition: return "Position"; + default: return ""; + } +} + +float dialogTextScale() { return s_dialog_text_scale; } + +void saveDialogTextScaleFromPortal(const char* value) { + float scale = 1.0f; + if (value != nullptr && value[0] != '\0') { + const float v = strtof(value, nullptr); + if (v >= kDialogScaleMin && v <= kDialogScaleMax) { + scale = v; + } + } + s_dialog_text_scale = scale; + if (s_prefs.begin(kPrefsNamespace, false)) { + s_prefs.putFloat(kPrefsDialogScaleKey, s_dialog_text_scale); + s_prefs.end(); + } + Serial.printf("Dialog text scale: %.2f\n", s_dialog_text_scale); +} + void saveMilesFromPortal(const char* checkbox_value) { s_use_miles = portalCheckboxChecked(checkbox_value); saveUseMiles(); @@ -155,10 +224,14 @@ void unitsReset() { s_use_miles = false; s_show_runways = true; s_airline_display = AirlineDisplay::kNone; + s_dialog_fields = kDialogFieldsAll; + s_dialog_text_scale = 1.0f; if (s_prefs.begin(kPrefsNamespace, false)) { s_prefs.remove(kPrefsMilesKey); s_prefs.remove(kPrefsRunwaysKey); s_prefs.remove(kPrefsAirlineKey); + s_prefs.remove(kPrefsDialogKey); + s_prefs.remove(kPrefsDialogScaleKey); s_prefs.end(); } } From 5dc2500a07165cb5041012de2f2c9dd8e22dac37 Mon Sep 17 00:00:00 2001 From: Crispin Proctor Date: Sat, 20 Jun 2026 21:05:44 +0100 Subject: [PATCH 5/5] Add route lookup, radar sweep, and configurable fetch interval Three enhancements developed together on top of the touch dialog; they share the dialog, render, and portal files, so are committed as one: - Flight dialog departure/arrival: on-tap callsign route lookup via the adsbdb.com API (services/route), shown as airport code plus city; toggled by the dialog-field checkboxes. - Rotating radar sweep (green 8BF688, 25 deg, fading to 0): composited with the grid and aircraft into one buffer and pushed as a single flicker-free frame; kept animating during ADS-B fetches via the network poll callback. - ADS-B fetch interval is now a portal setting (3 to 30 s, default 3) instead of a fixed compile-time value, to trade update frequency for sweep smoothness. Also: reuse the HTTPS connection to avoid a TLS handshake every poll, filter the ADS-B JSON to only the fields used, and enable SPI DMA. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/config.h | 7 +- include/hardware/lgfx_config.hpp | 1 + include/services/route.h | 19 +++++ include/ui/radar_display.h | 13 +++- include/ui/radar_range.h | 6 ++ src/main.cpp | 39 +++++++--- src/services/adsb_client.cpp | 65 +++++++++++------ src/services/route_lookup.cpp | 92 ++++++++++++++++++++++++ src/services/wifi_setup.cpp | 10 +++ src/ui/radar_display.cpp | 119 +++++++++++++++++++++++++------ src/ui/radar_range.cpp | 37 ++++++++++ 11 files changed, 353 insertions(+), 55 deletions(-) create mode 100644 include/services/route.h create mode 100644 src/services/route_lookup.cpp diff --git a/include/config.h b/include/config.h index 2f24fa92..de2b9378 100644 --- a/include/config.h +++ b/include/config.h @@ -43,8 +43,11 @@ constexpr bool kDisplayInvert = true; constexpr double kDefaultRadarLat = 52.3676; constexpr double kDefaultRadarLon = 4.9041; -/** Poll adsb.fi (API public limit: 1 req/s). */ -constexpr unsigned long kAdsbFetchIntervalMs = 3000; +/** Poll interval for adsb.fi (seconds). Configurable in the portal; the API + * public limit is 1 req/s, so the minimum is 3 s. */ +constexpr uint8_t kAdsbFetchIntervalDefaultSec = 3; +constexpr uint8_t kAdsbFetchIntervalMinSec = 3; +constexpr uint8_t kAdsbFetchIntervalMaxSec = 30; /** Legacy scale unused — fetch uses radar::fetchRadiusKm() to screen edge. */ constexpr float kAdsbFetchRadiusScale = 1.0f; /** false = hide aircraft with alt_baro "ground"; true = show them too. */ diff --git a/include/hardware/lgfx_config.hpp b/include/hardware/lgfx_config.hpp index a1b54cb6..9f8e81b4 100644 --- a/include/hardware/lgfx_config.hpp +++ b/include/hardware/lgfx_config.hpp @@ -28,6 +28,7 @@ class LGFX : public lgfx::LGFX_Device { cfg.pin_mosi = p.pin_mosi; cfg.pin_miso = -1; cfg.pin_dc = p.pin_dc; + cfg.dma_channel = SPI_DMA_CH_AUTO; // non-blocking frame pushes _bus.config(cfg); _panel.setBus(&_bus); } diff --git a/include/services/route.h b/include/services/route.h new file mode 100644 index 00000000..bc455002 --- /dev/null +++ b/include/services/route.h @@ -0,0 +1,19 @@ +#pragma once + +// Flight route lookup: maps a callsign to its departure/arrival airports via the +// adsbdb.com public API. Looked up on demand (when a flight dialog opens), not +// per aircraft. Routes exist only for scheduled flights. +namespace services::route { + +struct RouteInfo { + bool valid; + char origin_code[5]; // IATA (falls back to ICAO) + char origin_city[24]; // municipality, may be empty + char dest_code[5]; + char dest_city[24]; +}; + +/** Look up the route for a callsign. Returns true and fills out on success. */ +bool lookup(const char* callsign, RouteInfo* out); + +} // namespace services::route diff --git a/include/ui/radar_display.h b/include/ui/radar_display.h index 2c6d72b5..3743e1dc 100644 --- a/include/ui/radar_display.h +++ b/include/ui/radar_display.h @@ -1,5 +1,7 @@ #pragma once +#include "services/route.h" + namespace ui { /** Draw the static sonar/radar grid (black disc, green overlay, labels). */ @@ -8,6 +10,9 @@ void radarDisplayDraw(); /** Redraw aircraft only (blits cached grid; no full-screen clear). */ void radarDisplayRefreshAircraft(); +/** Advance and draw the rotating radar sweep. Call frequently from loop(). */ +void radarDisplayAnimate(); + /** * Find the aircraft nearest to a screen point (from the last radar draw). * Returns its index into services::adsb::aircraftList(), or -1 if none is @@ -15,7 +20,11 @@ void radarDisplayRefreshAircraft(); */ int radarDisplayHitTest(int x, int y); -/** Blank the screen and draw the flight details dialog for an aircraft. */ -void radarDisplayDrawDialog(int aircraft_index); +/** + * Blank the screen and draw the flight details dialog for an aircraft. + * route may be nullptr (or invalid) if no departure/arrival info is available. + */ +void radarDisplayDrawDialog(int aircraft_index, + const services::route::RouteInfo* route); } // namespace ui diff --git a/include/ui/radar_range.h b/include/ui/radar_range.h index c64b17ba..b0eefff7 100644 --- a/include/ui/radar_range.h +++ b/include/ui/radar_range.h @@ -59,6 +59,7 @@ enum class DialogField : uint8_t { kTrack, kDistance, kPosition, + kRoute, // departure/arrival airports (looked up on tap) kCount, }; @@ -68,6 +69,11 @@ void setDialogFieldsMask(uint16_t mask); /** Multiplier on the flight dialog text size (1.0 = base size). */ float dialogTextScale(); void saveDialogTextScaleFromPortal(const char* value); + +/** ADS-B poll interval. Configurable (3-30 s); persisted to NVS. */ +unsigned long adsbFetchIntervalMs(); +int adsbFetchIntervalSec(); +void saveFetchIntervalFromPortal(const char* value); /** Portal form field name (e.g. "dlg_alt") for a dialog field. */ const char* dialogFieldId(DialogField field); /** Human label (e.g. "Altitude") for a dialog field. */ diff --git a/src/main.cpp b/src/main.cpp index d9862147..d5fcd5e3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,6 +9,7 @@ #include "hardware/display.h" #include "services/adsb_client.h" #include "services/radar_location.h" +#include "services/route.h" #include "services/wifi_setup.h" #include "ui/radar_display.h" #include "ui/radar_range.h" @@ -66,13 +67,31 @@ void handleTouch() { static_cast(ty)); if (idx >= 0) { g_dialog_open = true; - ui::radarDisplayDrawDialog(idx); + services::route::RouteInfo route; + route.valid = false; + if (ui::radar::dialogFieldEnabled(ui::radar::DialogField::kRoute)) { + const services::adsb::Aircraft& ac = + services::adsb::aircraftList()[static_cast(idx)]; + if (ac.callsign[0] != '\0') { + services::route::lookup(ac.callsign, &route); + } + } + ui::radarDisplayDrawDialog(idx, &route); } } } was_touched = touched; } +// Called by the ADS-B client during its (blocking) network I/O, so the WiFi +// portal stays alive AND the sweep keeps animating instead of freezing. +void radarPoll() { + wifiLoop(); + if (g_radar_visible && !g_dialog_open && WiFi.status() == WL_CONNECTED) { + ui::radarDisplayAnimate(); + } +} + void fetchAndDrawAircraft() { const float fetch_km = ui::radar::fetchRadiusKm(); if (!services::adsb::fetchUpdate(services::location::lat(), @@ -99,7 +118,7 @@ void setup() { } services::location::init(); ui::radar::rangeInit(); - services::adsb::setPollFn(wifiLoop); + services::adsb::setPollFn(radarPoll); if (wifiSetupConnect()) { showRadarIfConnected(); @@ -132,13 +151,15 @@ void loop() { } } else { g_wifi_down_since = 0; - if (g_dialog_open) { - // Hold the dialog on screen; don't redraw the radar underneath it. - } else if (!g_radar_visible) { - showRadarIfConnected(); - } else if (millis() - g_last_adsb_fetch_ms >= config::kAdsbFetchIntervalMs) { - g_last_adsb_fetch_ms = millis(); - fetchAndDrawAircraft(); + if (!g_dialog_open) { + if (!g_radar_visible) { + showRadarIfConnected(); + } else if (millis() - g_last_adsb_fetch_ms >= + ui::radar::adsbFetchIntervalMs()) { + g_last_adsb_fetch_ms = millis(); + fetchAndDrawAircraft(); + } + ui::radarDisplayAnimate(); // rotating sweep over the cached radar } } diff --git a/src/services/adsb_client.cpp b/src/services/adsb_client.cpp index bdfa834b..a1df7e7b 100644 --- a/src/services/adsb_client.cpp +++ b/src/services/adsb_client.cpp @@ -1,6 +1,7 @@ #include "services/adsb_client.h" #include +#include #include #include @@ -23,6 +24,13 @@ Aircraft s_aircraft[kMaxAircraft]; size_t s_aircraft_count = 0; PollFn s_poll_fn = nullptr; +// Persistent TLS connection: the adsb.fi server supports keep-alive, so we reuse +// the connection across fetches. This avoids a ~1-2 s CPU-bound TLS handshake +// every poll (which froze the radar sweep on the single-core ESP32-C3). +WiFiClientSecure s_client; +HTTPClient s_http; +bool s_http_ready = false; + void pollNetwork() { if (s_poll_fn != nullptr) { s_poll_fn(); @@ -218,33 +226,57 @@ bool fetchUpdate(double center_lat, double center_lon, float fetch_radius_km) { url += "/dist/"; url += String(dist_nm, 1); - WiFiClientSecure client; - client.setInsecure(); + if (!s_http_ready) { + s_client.setInsecure(); + s_http.setReuse(true); // keep the TLS connection open between fetches + s_http_ready = true; + } - HTTPClient http; - if (!http.begin(client, url)) { + if (!s_http.begin(s_client, url)) { Serial.println("adsb: http.begin failed"); return false; } - http.setTimeout(kRequestTimeoutMs); - const int code = performGetWithPoll(http); + s_http.setTimeout(kRequestTimeoutMs); + const unsigned long t_get0 = millis(); + const int code = performGetWithPoll(s_http); + const unsigned long t_get = millis() - t_get0; if (code != HTTP_CODE_OK) { - Serial.printf("adsb: HTTP %d\n", code); - http.end(); + Serial.printf("adsb: HTTP %d (get=%lums rssi=%d heap=%u)\n", code, t_get, + WiFi.RSSI(), ESP.getFreeHeap()); + s_http.end(); return false; } + const unsigned long t_body0 = millis(); String payload; - if (!readResponseBodyWithPoll(http, payload)) { + if (!readResponseBodyWithPoll(s_http, payload)) { Serial.println("adsb: empty response"); - http.end(); + s_http.end(); return false; } - http.end(); + const unsigned long t_body = millis() - t_body0; + s_http.end(); + Serial.printf("adsb: get=%lums body=%lums rssi=%d heap=%u\n", t_get, t_body, + WiFi.RSSI(), ESP.getFreeHeap()); + + // Filter: keep only the fields we use, so the parsed document is small and + // parsing is fast (the verbose adsb.fi response has ~30 fields per aircraft). + static JsonDocument s_filter; + static bool s_filter_ready = false; + if (!s_filter_ready) { + JsonObject f = s_filter["ac"].add(); + for (const char* key : + {"lat", "lon", "flight", "hex", "t", "alt_baro", "alt_geom", "gs", + "tas", "ias", "track", "true_heading", "mag_heading", "dir"}) { + f[key] = true; + } + s_filter_ready = true; + } JsonDocument doc; - const DeserializationError err = deserializeJson(doc, payload); + const DeserializationError err = + deserializeJson(doc, payload, DeserializationOption::Filter(s_filter)); if (err) { Serial.printf("adsb: JSON parse error: %s\n", err.c_str()); return false; @@ -279,15 +311,6 @@ bool fetchUpdate(double center_lat, double center_lon, float fetch_radius_km) { s_aircraft_count = n; Serial.printf("adsb: %u aircraft\n", static_cast(n)); - for (size_t i = 0; i < n; ++i) { - const Aircraft& ac = s_aircraft[i]; - Serial.printf(" [%u] %s %s %s %.4f,%.4f %s trk=%.0f gs=%.0f\n", - static_cast(i), - ac.callsign[0] != '\0' ? ac.callsign : "(no id)", - ac.airline != nullptr ? ac.airline->iata : "", - ac.airline != nullptr ? ac.airline->name : "", - ac.lat, ac.lon, ac.alt, ac.track_deg, ac.gs_knots); - } return true; } diff --git a/src/services/route_lookup.cpp b/src/services/route_lookup.cpp new file mode 100644 index 00000000..f0e237ec --- /dev/null +++ b/src/services/route_lookup.cpp @@ -0,0 +1,92 @@ +#include "services/route.h" + +#include +#include + +#include + +#include + +namespace services::route { + +namespace { + +constexpr char kApiBase[] = "https://api.adsbdb.com/v0/callsign/"; +constexpr int kConnectTimeoutMs = 1500; +constexpr unsigned long kRequestTimeoutMs = 3000; + +void copyField(const JsonObject& obj, const char* key, char* out, size_t len) { + out[0] = '\0'; + if (obj[key].is()) { + strncpy(out, obj[key].as(), len - 1); + out[len - 1] = '\0'; + } +} + +void copyCode(const JsonObject& airport, char* out, size_t len) { + copyField(airport, "iata_code", out, len); + if (out[0] == '\0') { + copyField(airport, "icao_code", out, len); + } +} + +} // namespace + +bool lookup(const char* callsign, RouteInfo* out) { + out->valid = false; + out->origin_code[0] = '\0'; + out->origin_city[0] = '\0'; + out->dest_code[0] = '\0'; + out->dest_city[0] = '\0'; + if (callsign == nullptr || callsign[0] == '\0') { + return false; + } + + String url = kApiBase; + url += callsign; + + WiFiClientSecure client; + client.setInsecure(); + + HTTPClient http; + http.setConnectTimeout(kConnectTimeoutMs); + http.setTimeout(kRequestTimeoutMs); + if (!http.begin(client, url)) { + return false; + } + + const int code = http.GET(); + if (code != HTTP_CODE_OK) { + Serial.printf("route: HTTP %d for %s\n", code, callsign); + http.end(); + return false; + } + String payload = http.getString(); + http.end(); + + JsonDocument doc; + if (deserializeJson(doc, payload)) { + return false; + } + JsonObject route = doc["response"]["flightroute"].as(); + if (route.isNull()) { + return false; + } + JsonObject origin = route["origin"].as(); + JsonObject dest = route["destination"].as(); + if (origin.isNull() || dest.isNull()) { + return false; + } + + copyCode(origin, out->origin_code, sizeof(out->origin_code)); + copyField(origin, "municipality", out->origin_city, sizeof(out->origin_city)); + copyCode(dest, out->dest_code, sizeof(out->dest_code)); + copyField(dest, "municipality", out->dest_city, sizeof(out->dest_city)); + + out->valid = out->origin_code[0] != '\0' || out->dest_code[0] != '\0'; + Serial.printf("route: %s %s -> %s\n", callsign, out->origin_code, + out->dest_code); + return out->valid; +} + +} // namespace services::route diff --git a/src/services/wifi_setup.cpp b/src/services/wifi_setup.cpp index 1d6e3488..19f6bf59 100644 --- a/src/services/wifi_setup.cpp +++ b/src/services/wifi_setup.cpp @@ -100,6 +100,11 @@ constexpr char kScaleInputAttrs[] = WiFiManagerParameter s_param_dlg_scale("dlg_scale", "Dialog text scale", "1.0", 6, kScaleInputAttrs); +constexpr char kFetchInputAttrs[] = + " type=\"number\" step=\"1\" min=\"3\" max=\"30\""; +WiFiManagerParameter s_param_fetch("fetch_sec", "ADS-B fetch interval (s)", "3", 4, + kFetchInputAttrs); + // Board selector. Custom-HTML-only param renders a