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..de2b9378 100644 --- a/include/config.h +++ b/include/config.h @@ -30,26 +30,24 @@ 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; 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/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/hardware/board.h b/include/hardware/board.h new file mode 100644 index 00000000..619501c5 --- /dev/null +++ b/include/hardware/board.h @@ -0,0 +1,66 @@ +#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 + // 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 (no touch). + {/*sclk*/ 4, /*mosi*/ 3, /*dc*/ 10, /*cs*/ 1, /*rst*/ 0, /*bl*/ -1, + /*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, + /*touch sda*/ 4, /*scl*/ 5, /*int*/ 0, /*rst*/ 1}, +}; + +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..9f8e81b4 100644 --- a/include/hardware/lgfx_config.hpp +++ b/include/hardware/lgfx_config.hpp @@ -4,33 +4,58 @@ #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; + lgfx::Touch_CST816S _touch; public: LGFX() { + applyBoard(hardware::board::pins(hardware::board::compileDefault())); + setPanel(&_panel); + } + + /** (Re)configure SPI bus, panel and touch 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; + cfg.dma_channel = SPI_DMA_CH_AUTO; // non-blocking frame pushes _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); + 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/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/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 5ab3bb29..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,4 +10,21 @@ 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 + * within tap range. + */ +int radarDisplayHitTest(int x, int y); + +/** + * 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 a95c5327..b0eefff7 100644 --- a/include/ui/radar_range.h +++ b/include/ui/radar_range.h @@ -43,11 +43,50 @@ 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" +}; + +/** 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, + kRoute, // departure/arrival airports (looked up on tap) + 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); + +/** 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. */ +const char* dialogFieldLabel(DialogField field); + 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/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/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/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/main.cpp b/src/main.cpp index 797aecc0..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" @@ -20,6 +21,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 +51,47 @@ 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; + 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(), @@ -75,7 +118,7 @@ void setup() { } services::location::init(); ui::radar::rangeInit(); - services::adsb::setPollFn(wifiLoop); + services::adsb::setPollFn(radarPoll); if (wifiSetupConnect()) { showRadarIfConnected(); @@ -84,6 +127,7 @@ void setup() { void loop() { handleBootButton(); + handleTouch(); wifiLoop(); if (WiFi.status() != WL_CONNECTED) { @@ -107,11 +151,15 @@ void loop() { } } else { g_wifi_down_since = 0; - 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 84be21bb..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 @@ -8,6 +9,7 @@ #include #include "config.h" +#include "data/airlines.h" namespace services::adsb { @@ -22,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(); @@ -189,6 +198,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)); } @@ -215,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; 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; } 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 2fa8b723..19f6bf59 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,88 @@ 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] = ""; +WiFiManagerParameter s_param_board(s_board_select_html); + +// Set when the board changes via the portal; wifiLoop reboots to re-init the +// display with the new pins after the HTTP response has been sent. +unsigned long s_reboot_at_ms = 0; + +void buildBoardSelectHtml() { + const uint8_t cur = static_cast(hardware::board::active()); + int n = snprintf(s_board_select_html, sizeof(s_board_select_html), + "
" + ""); + } +} + +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 buildDialogFieldsHtml() { + int n = snprintf(s_dialog_fields_html, sizeof(s_dialog_fields_html), + "

"); + for (uint8_t i = 0; + i < static_cast(ui::radar::DialogField::kCount) && n > 0 && + n < static_cast(sizeof(s_dialog_fields_html)); + ++i) { + const auto field = static_cast(i); + n += snprintf(s_dialog_fields_html + n, sizeof(s_dialog_fields_html) - n, + " %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]; @@ -97,6 +180,37 @@ 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(); + buildDialogFieldsHtml(); + char scale_buf[8]; + snprintf(scale_buf, sizeof(scale_buf), "%.1f", ui::radar::dialogTextScale()); + s_param_dlg_scale.setValue(scale_buf, 6); + char fetch_buf[6]; + snprintf(fetch_buf, sizeof(fetch_buf), "%d", ui::radar::adsbFetchIntervalSec()); + s_param_fetch.setValue(fetch_buf, 4); + 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 +220,22 @@ 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()); + 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()); + ui::radar::saveFetchIntervalFromPortal(s_param_fetch.getValue()); + saveBoardFromPortal(); } void attachPortalParams(WiFiManager& wm) { @@ -114,6 +244,11 @@ 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_dialog_fields); + wm.addParameter(&s_param_dlg_scale); + wm.addParameter(&s_param_fetch); + wm.addParameter(&s_param_board); wm.setSaveParamsCallback(onPortalParamsSaved); } @@ -414,6 +549,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..56d7e845 100644 --- a/src/ui/radar_display.cpp +++ b/src/ui/radar_display.cpp @@ -4,9 +4,11 @@ #include #include +#include #include #include "config.h" +#include "hardware/board.h" #include "hardware/display.h" #include "hardware/display_font.h" #include "services/adsb_client.h" @@ -52,6 +54,10 @@ int s_scale_label_max_w = 0; int s_scale_label_h = 0; lgfx::LovyanGFX* s_draw = &tft; +// Single 16-bit compositing buffer: grid + aircraft + sweep are drawn here every +// frame and pushed to the panel in one pass (flicker-free). A second cache +// buffer won't fit alongside the TLS connection on this chip, so the grid is +// re-rendered each frame; doing it every frame keeps the cadence uniform. LGFX_Sprite s_frame(&tft); bool s_frame_ready = false; @@ -180,7 +186,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 { @@ -378,37 +384,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 = @@ -427,21 +453,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; } } @@ -453,11 +468,63 @@ 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; + +// --- Radar sweep animation --- +// A green wedge, full intensity at the leading edge fading to 0 over the span, +// rotating clockwise. Colour #8BF688. +constexpr int kSweepR = 0x8B; +constexpr int kSweepG = 0xF6; +constexpr int kSweepB = 0x88; +constexpr float kSweepSpanDeg = 25.0f; // bright front -> 0 over this many deg +constexpr float kSweepStepDeg = 0.5f; // radial line spacing +constexpr float kSweepDegPerSec = 90.0f; // rotation speed (~4 s per revolution) +constexpr unsigned long kSweepFrameMs = 50; // ~20 fps cap + +float s_sweep_deg = 0.0f; +unsigned long s_last_sweep_ms = 0; + +uint16_t sweepColorForIntensity(float t) { + const int r = radar::kBgR + static_cast(lroundf((kSweepR - radar::kBgR) * t)); + const int g = radar::kBgG + static_cast(lroundf((kSweepG - radar::kBgG) * t)); + const int b = radar::kBgB + static_cast(lroundf((kSweepB - radar::kBgB) * t)); + // Match the panel's colour order (same rule as the aircraft colour). + if (hardware::board::activePins().rgb_order) { + return tft.color565(b, g, r); + } + return tft.color565(r, g, b); +} + +// Draw the sweep into the current draw target (the compositing buffer). +void drawSweep(float lead_deg) { + const int cx = radar::kCenterX; + const int cy = radar::kCenterY; + const int radius = radar::kGridOuterRadius; + constexpr float kDegToRad = 0.0174532925f; + // Far (faint) to near (bright) so the bright leading edge is drawn last. + for (float a = kSweepSpanDeg; a >= 0.0f; a -= kSweepStepDeg) { + const float t = 1.0f - a / kSweepSpanDeg; // 0 trailing, 1 leading + const uint16_t color = sweepColorForIntensity(t); + const float rad = (lead_deg - a) * kDegToRad; + const int ex = cx + static_cast(lroundf(sinf(rad) * radius)); + const int ey = cy - static_cast(lroundf(cosf(rad) * radius)); + s_draw->drawLine(cx, cy, ex, ey, color); + } +} + void sortDrawItemsFarFirst(AircraftDrawItem* items, size_t count) { for (size_t i = 1; i < count; ++i) { const AircraftDrawItem key = items[i]; @@ -492,6 +559,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; @@ -508,6 +576,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; } @@ -517,10 +586,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); @@ -669,14 +740,28 @@ bool ensureFrameSprite() { return true; } -// Double-buffered frame: composite the grid AND aircraft into the off-screen -// sprite, then blit it to the panel in a single pushSprite. Because the panel -// is updated in one pass, labels never show an erase/redraw gap — no flicker. -void renderFrame() { +// Composite grid + aircraft (+ optional sweep) into s_frame and push once, so +// the panel updates in a single pass (no flicker). +void composeFrame(bool with_sweep) { + if (!ensureFrameSprite()) { + // No buffer: draw straight to the panel (may flicker, last resort). + const DrawScope scope(tft); + drawStaticGrid(tft); + drawAircraft(); + if (with_sweep) { + drawSweep(s_sweep_deg); + } + tft.setTextDatum(textdatum_t::top_left); + return; + } + drawStaticGrid(s_frame); // opens its own DrawScope(s_frame) { const DrawScope scope(s_frame); drawAircraft(); + if (with_sweep) { + drawSweep(s_sweep_deg); + } } s_frame.pushSprite(0, 0); tft.setTextDatum(textdatum_t::top_left); @@ -687,28 +772,163 @@ void renderFrame() { void radarDisplayDraw() { initPalette(); initLabelMetrics(); + composeFrame(true); +} + +void radarDisplayRefreshAircraft() { + initPalette(); + composeFrame(true); +} + +void radarDisplayAnimate() { + const unsigned long now = millis(); + if (s_last_sweep_ms != 0 && now - s_last_sweep_ms < kSweepFrameMs) { + return; // frame-rate cap + } + const float dt = + (s_last_sweep_ms == 0) ? 0.05f : (now - s_last_sweep_ms) / 1000.0f; + s_last_sweep_ms = now; + s_sweep_deg += kSweepDegPerSec * dt; + while (s_sweep_deg >= 360.0f) { + s_sweep_deg -= 360.0f; + } + + // One composited, flicker-free frame: cached grid + aircraft + sweep. + composeFrame(true); +} + +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; +} - if (ensureFrameSprite()) { - renderFrame(); +void radarDisplayDrawDialog(int aircraft_index, + const services::route::RouteInfo* route) { + 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)]; - // Fallback when the sprite can't be allocated: draw straight to the panel. - const DrawScope scope(tft); - drawStaticGrid(tft); - drawAircraft(); - tft.setTextDatum(textdatum_t::top_left); -} + 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[10][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::kRoute) && route != nullptr && + route->valid) { + if (route->origin_city[0] != '\0') { + snprintf(lines[nl++], sizeof(lines[0]), "%s %s", route->origin_code, + route->origin_city); + } else { + snprintf(lines[nl++], sizeof(lines[0]), "%s", route->origin_code); + } + if (route->dest_city[0] != '\0') { + snprintf(lines[nl++], sizeof(lines[0]), "-> %s %s", route->dest_code, + route->dest_city); + } else { + snprintf(lines[nl++], sizeof(lines[0]), "-> %s", route->dest_code); + } + } + 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); + } -void radarDisplayRefreshAircraft() { + // 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); - if (ensureFrameSprite()) { - renderFrame(); - return; + 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; } - radarDisplayDraw(); + tft.setTextDatum(textdatum_t::top_left); } } // namespace ui diff --git a/src/ui/radar_range.cpp b/src/ui/radar_range.cpp index d8bf002e..a065b366 100644 --- a/src/ui/radar_range.cpp +++ b/src/ui/radar_range.cpp @@ -1,10 +1,12 @@ #include "ui/radar_range.h" +#include "config.h" #include "ui/radar_theme.h" #include #include #include +#include #include namespace ui::radar { @@ -15,13 +17,26 @@ constexpr char kPrefsNamespace[] = "planeradar"; 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 char kPrefsFetchSecKey[] = "fetchSec"; 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; +uint8_t s_fetch_interval_sec = config::kAdsbFetchIntervalDefaultSec; void saveRangeIndex() { if (!s_prefs.begin(kPrefsNamespace, false)) { @@ -47,6 +62,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 +93,20 @@ 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_dialog_fields = s_prefs.getUShort(kPrefsDialogKey, kDialogFieldsAll); + s_dialog_text_scale = s_prefs.getFloat(kPrefsDialogScaleKey, 1.0f); + const uint8_t fetch_sec = s_prefs.getUChar( + kPrefsFetchSecKey, config::kAdsbFetchIntervalDefaultSec); + s_fetch_interval_sec = + (fetch_sec < config::kAdsbFetchIntervalMinSec) ? config::kAdsbFetchIntervalMinSec + : (fetch_sec > config::kAdsbFetchIntervalMaxSec) + ? config::kAdsbFetchIntervalMaxSec + : fetch_sec; s_prefs.end(); } @@ -93,6 +130,91 @@ bool useMiles() { return s_use_miles; } 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"; + case DialogField::kRoute: return "dlg_route"; + 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"; + case DialogField::kRoute: return "Departure / arrival"; + default: return ""; + } +} + +float dialogTextScale() { return s_dialog_text_scale; } + +unsigned long adsbFetchIntervalMs() { + return static_cast(s_fetch_interval_sec) * 1000UL; +} + +int adsbFetchIntervalSec() { return s_fetch_interval_sec; } + +void saveFetchIntervalFromPortal(const char* value) { + uint8_t sec = config::kAdsbFetchIntervalDefaultSec; + if (value != nullptr && value[0] != '\0') { + const long v = strtol(value, nullptr, 10); + if (v >= config::kAdsbFetchIntervalMinSec && + v <= config::kAdsbFetchIntervalMaxSec) { + sec = static_cast(v); + } + } + s_fetch_interval_sec = sec; + if (s_prefs.begin(kPrefsNamespace, false)) { + s_prefs.putUChar(kPrefsFetchSecKey, s_fetch_interval_sec); + s_prefs.end(); + } + Serial.printf("ADS-B fetch interval: %u s\n", s_fetch_interval_sec); +} + +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(); @@ -105,6 +227,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 +258,17 @@ void formatCurrentRing3Label(char* buf, size_t len) { 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; + s_fetch_interval_sec = config::kAdsbFetchIntervalDefaultSec; 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.remove(kPrefsFetchSecKey); s_prefs.end(); } }