diff --git a/README.md b/README.md index 0318b3f4f..97387a131 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,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) | +| **Show flight destination** | Adds a green destination line (e.g. `to LHR`) to aircraft tags (on by default) | After a reset, the device reboots and shows the setup screen immediately (no “Connecting” loop on stale credentials). @@ -82,6 +83,17 @@ Preset and miles/km choice persist across reboot (`planeradar` NVS namespace). As range decreases (or aircraft approach), targets move inward; beyond-ring dots become full symbols when they cross the outer ring. +### Flight destination + +ADS-B doesn't broadcast where a flight is going, so the firmware looks up each aircraft's callsign on [adsbdb.com](https://www.adsbdb.com/) (`https://api.adsbdb.com/v0/callsign/`, free, no key) and adds a fourth, green line to its tag: `to Amsterdam`. City names are shown when they fit (up to `kRouteMaxCityChars`, 15) and are folded to plain ASCII (`Zürich` → `Zurich`) because the tag font has no accented letters; otherwise it falls back to the airport code (`to AMS`; ICAO if there is no IATA). + +- Only aircraft **inside the outer ring** (the ones that get a tag) are looked up, nearest first, at most `kRouteLookupsPerCycle` (2) per ADS-B cycle, on the same background task as the ADS-B fetch. +- Results are cached (48 callsigns; found 2 h, unknown 30 min, errors 30 s), so a plane is looked up once, not every refresh. +- Not every callsign has a route (general aviation, military, charters, or flights missing from the database); those just show no destination line. +- In `config.h`: `kRouteUseCityNames = false` shows airport codes instead of city names, and `kRouteShowOrigin = true` adds a `from Thessaloniki` line above the destination. +- The serial log prints the full route and heap headroom, e.g. `route: TRA91T Thessaloniki to Amsterdam (SKG>AMS) [heap free 112000, lowest 98000]`. On a Super Mini, if "lowest" drops under ~30000, tell the maintainer: the extra TLS session is tight on the C3. +- Callsigns are sent to adsbdb.com. Untick **Show flight destination** in the setup portal to turn lookups off. + ### ADS-B - Source: `https://opendata.adsb.fi/api/v3/` @@ -101,6 +113,7 @@ Edit **`include/config.h`** for hardware and behavior: | Display SPI | pins, `kDisplayInvert`, `kDisplayRgbOrder`, `kDisplaySpiWriteHz` | | Default location | `kDefaultRadarLat`, `kDefaultRadarLon` (until portal overrides) | | ADS-B | `kAdsbFetchIntervalMs`, `kAdsbShowGroundAircraft` | +| Destination | `kRouteLookupsPerCycle`, `kRouteUseCityNames`, `kRouteMaxCityChars`, `kRouteShowOrigin` | Range presets: `include/ui/radar_range.h` (`kRangePresets`). @@ -125,6 +138,8 @@ include/ wifi_setup.h radar_location.h adsb_client.h + route_client.h + ascii_fold.h data/ ui_font.vlw — embedded smooth UI font (Noto Sans Bold) scripts/ diff --git a/include/config.h b/include/config.h index d084875e7..71ae12f1e 100644 --- a/include/config.h +++ b/include/config.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -56,6 +57,10 @@ constexpr unsigned long kAdsbFetchIntervalMs = 5000; constexpr unsigned long kRadarRedrawIntervalMs = 250; /** Legacy scale unused — fetch uses radar::fetchRadiusKm() to screen edge. */ constexpr float kAdsbFetchRadiusScale = 1.0f; +constexpr size_t kRouteLookupsPerCycle = 2; +constexpr bool kRouteUseCityNames = true; +constexpr size_t kRouteMaxCityChars = 15; +constexpr bool kRouteShowOrigin = false; /** false = hide aircraft with alt_baro "ground"; true = show them too. */ constexpr bool kAdsbShowGroundAircraft = false; diff --git a/include/services/adsb_client.h b/include/services/adsb_client.h index 94ce29a18..a5c7b01d0 100644 --- a/include/services/adsb_client.h +++ b/include/services/adsb_client.h @@ -15,6 +15,7 @@ struct Aircraft { * Added to the elapsed time when dead-reckoning so the drawn position * reflects the estimated current location, not the already-stale fix. */ uint32_t pos_age_ms; + bool has_flight_callsign; char callsign[9]; char type[5]; char alt[12]; diff --git a/include/services/ascii_fold.h b/include/services/ascii_fold.h new file mode 100644 index 000000000..3fdd89630 --- /dev/null +++ b/include/services/ascii_fold.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace services { + +bool foldToAscii(const char* in, char* out, size_t out_len); + +} // namespace services diff --git a/include/services/route_client.h b/include/services/route_client.h new file mode 100644 index 000000000..d440ad5f5 --- /dev/null +++ b/include/services/route_client.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace services::route { + +struct Airport { + char code[5]; + char city[24]; +}; + +struct Route { + Airport origin; + Airport destination; +}; + +void init(); + +bool lookup(const char* callsign, Route* out); + +size_t fetchPending(double center_lat, double center_lon, float max_dist_km, + size_t max_lookups); + +} // namespace services::route diff --git a/include/ui/radar_range.h b/include/ui/radar_range.h index a95c53275..a8f76f711 100644 --- a/include/ui/radar_range.h +++ b/include/ui/radar_range.h @@ -45,9 +45,11 @@ float fetchRadiusKm(); bool useMiles(); bool showRunways(); +bool showDestination(); /** WiFi portal checkbox: "T" = miles, otherwise km. */ void saveMilesFromPortal(const char* checkbox_value); void saveRunwaysFromPortal(const char* checkbox_value); +void saveDestinationFromPortal(const char* checkbox_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/include/ui/radar_theme.h b/include/ui/radar_theme.h index 2d763a638..5e4c28392 100644 --- a/include/ui/radar_theme.h +++ b/include/ui/radar_theme.h @@ -81,6 +81,9 @@ constexpr uint8_t kTagTypeB = 0; constexpr uint8_t kTagAltR = 90; constexpr uint8_t kTagAltG = 200; constexpr uint8_t kTagAltB = 255; +constexpr uint8_t kTagRouteR = 110; +constexpr uint8_t kTagRouteG = 230; +constexpr uint8_t kTagRouteB = 110; constexpr uint8_t kRunwayR = 56; constexpr uint8_t kRunwayG = 150; constexpr uint8_t kRunwayB = 170; @@ -97,6 +100,7 @@ extern uint16_t kColorAircraft; extern uint16_t kColorTrackVector; extern uint16_t kColorTagType; extern uint16_t kColorTagAltitude; +extern uint16_t kColorTagRoute; extern uint16_t kColorRunway; extern uint16_t kColorRunwayLabel; diff --git a/src/main.cpp b/src/main.cpp index 6e98c651b..6c52297f1 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_client.h" #include "services/wifi_setup.h" #include "ui/radar_display.h" #include "ui/radar_range.h" @@ -59,6 +60,11 @@ void adsbFetchTask(void*) { services::adsb::fetchUpdate(services::location::lat(), services::location::lon(), ui::radar::fetchRadiusKm()); + if (ui::radar::showDestination()) { + services::route::fetchPending( + services::location::lat(), services::location::lon(), + ui::radar::rangeCurrent().outer_km, config::kRouteLookupsPerCycle); + } } vTaskDelay(pdMS_TO_TICKS(config::kAdsbFetchIntervalMs)); } @@ -80,6 +86,7 @@ void setup() { services::location::init(); ui::radar::rangeInit(); services::adsb::init(); + services::route::init(); if (wifiSetupConnect()) { showRadarIfConnected(); diff --git a/src/services/adsb_client.cpp b/src/services/adsb_client.cpp index 6bfb3eb6a..1541d0b9c 100644 --- a/src/services/adsb_client.cpp +++ b/src/services/adsb_client.cpp @@ -233,6 +233,7 @@ 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)); + ac->has_flight_callsign = ac->callsign[0] != '\0'; if (ac->callsign[0] == '\0') { copyJsonStringTrimmed(plane, "hex", ac->callsign, sizeof(ac->callsign)); } diff --git a/src/services/ascii_fold.cpp b/src/services/ascii_fold.cpp new file mode 100644 index 000000000..21df26084 --- /dev/null +++ b/src/services/ascii_fold.cpp @@ -0,0 +1,83 @@ +#include "services/ascii_fold.h" + +#include + +namespace services { + +namespace { + +constexpr uint32_t kFoldFirst = 0xC0; +constexpr uint32_t kFoldLast = 0x17F; +const char* const kFoldTable[] = { + "A", "A", "A", "A", "A", "A", "AE", "C", + "E", "E", "E", "E", "I", "I", "I", "I", + "D", "N", "O", "O", "O", "O", "O", "", + "O", "U", "U", "U", "U", "Y", "Th", "ss", + "a", "a", "a", "a", "a", "a", "ae", "c", + "e", "e", "e", "e", "i", "i", "i", "i", + "d", "n", "o", "o", "o", "o", "o", "", + "o", "u", "u", "u", "u", "y", "th", "y", + "A", "a", "A", "a", "A", "a", "C", "c", + "C", "c", "C", "c", "C", "c", "D", "d", + "D", "d", "E", "e", "E", "e", "E", "e", + "E", "e", "E", "e", "G", "g", "G", "g", + "G", "g", "G", "g", "H", "h", "H", "h", + "I", "i", "I", "i", "I", "i", "I", "i", + "I", "i", "IJ", "ij", "J", "j", "K", "k", + "k", "L", "l", "L", "l", "L", "l", "L", + "l", "L", "l", "N", "n", "N", "n", "N", + "n", "n", "N", "n", "O", "o", "O", "o", + "O", "o", "OE", "oe", "R", "r", "R", "r", + "R", "r", "S", "s", "S", "s", "S", "s", + "S", "s", "T", "t", "T", "t", "T", "t", + "U", "u", "U", "u", "U", "u", "U", "u", + "U", "u", "U", "u", "W", "w", "Y", "y", + "Y", "Z", "z", "Z", "z", "Z", "z", "s", +}; + +} // namespace + +bool foldToAscii(const char* in, char* out, size_t out_len) { + if (out == nullptr || out_len == 0) { + return false; + } + out[0] = '\0'; + if (in == nullptr) { + return false; + } + + size_t o = 0; + const unsigned char* p = reinterpret_cast(in); + while (*p != '\0') { + uint32_t cp = 0; + if (*p < 0x80) { + cp = *p++; + } else if ((*p & 0xE0) == 0xC0 && (p[1] & 0xC0) == 0x80) { + cp = (static_cast(*p & 0x1F) << 6) | (p[1] & 0x3F); + p += 2; + } else { + out[0] = '\0'; + return false; + } + + char single[2] = {0, 0}; + const char* rep = nullptr; + if (cp >= 0x20 && cp < 0x7F) { + single[0] = static_cast(cp); + rep = single; + } else if (cp >= kFoldFirst && cp <= kFoldLast) { + rep = kFoldTable[cp - kFoldFirst]; + } + if (rep == nullptr || rep[0] == '\0') { + out[0] = '\0'; + return false; + } + for (; *rep != '\0' && o + 1 < out_len; ++rep) { + out[o++] = *rep; + } + } + out[o] = '\0'; + return o > 0; +} + +} // namespace services diff --git a/src/services/route_client.cpp b/src/services/route_client.cpp new file mode 100644 index 000000000..59599ee21 --- /dev/null +++ b/src/services/route_client.cpp @@ -0,0 +1,344 @@ +#include "services/route_client.h" + +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include + +#include "services/adsb_client.h" +#include "services/ascii_fold.h" + +namespace services::route { + +namespace { + +constexpr char kApiBase[] = "https://api.adsbdb.com/v0/callsign/"; +constexpr int kConnectTimeoutMs = 5000; +constexpr int kRequestTimeoutMs = 6000; +constexpr size_t kMaxBodyBytes = 8192; + +constexpr size_t kCacheSize = 48; + +constexpr unsigned long kFoundTtlMs = 2UL * 60UL * 60UL * 1000UL; +constexpr unsigned long kNotFoundTtlMs = 30UL * 60UL * 1000UL; +constexpr unsigned long kErrorTtlMs = 30UL * 1000UL; +constexpr unsigned long kRateLimitBackoffMs = 60UL * 1000UL; + +constexpr float kKmPerDeg = 111.0f; +constexpr float kDegToRad = 0.01745329252f; + +enum class State : uint8_t { kEmpty, kFound, kNotFound, kError }; +enum class FetchResult : uint8_t { kOk, kNotFound, kError, kRateLimited }; + +struct Entry { + char callsign[9]; + Airport origin; + Airport destination; + State state; + unsigned long fetched_ms; + unsigned long last_used_ms; +}; + +Entry s_cache[kCacheSize]; +SemaphoreHandle_t s_mutex = nullptr; +unsigned long s_backoff_until_ms = 0; +bool s_backoff_active = false; + +void lock() { + if (s_mutex != nullptr) { + xSemaphoreTake(s_mutex, portMAX_DELAY); + } +} + +void unlock() { + if (s_mutex != nullptr) { + xSemaphoreGive(s_mutex); + } +} + +unsigned long ttlFor(State state) { + switch (state) { + case State::kFound: + return kFoundTtlMs; + case State::kNotFound: + return kNotFoundTtlMs; + default: + return kErrorTtlMs; + } +} + +Entry* findLocked(const char* callsign) { + for (Entry& e : s_cache) { + if (e.state != State::kEmpty && strcmp(e.callsign, callsign) == 0) { + return &e; + } + } + return nullptr; +} + +Entry* allocLocked() { + Entry* victim = &s_cache[0]; + for (Entry& e : s_cache) { + if (e.state == State::kEmpty) { + return &e; + } + if (static_cast(victim->last_used_ms - e.last_used_ms) > 0) { + victim = &e; + } + } + return victim; +} + +void store(const char* callsign, State state, const Airport* origin, + const Airport* destination) { + const unsigned long now = millis(); + lock(); + Entry* e = findLocked(callsign); + if (e == nullptr) { + e = allocLocked(); + strncpy(e->callsign, callsign, sizeof(e->callsign) - 1); + e->callsign[sizeof(e->callsign) - 1] = '\0'; + } + e->state = state; + e->fetched_ms = now; + e->last_used_ms = now; + memset(&e->origin, 0, sizeof(e->origin)); + memset(&e->destination, 0, sizeof(e->destination)); + if (origin != nullptr) { + e->origin = *origin; + } + if (destination != nullptr) { + e->destination = *destination; + } + unlock(); +} + +bool needsFetch(const char* callsign) { + const unsigned long now = millis(); + lock(); + const Entry* e = findLocked(callsign); + const bool due = (e == nullptr) || (now - e->fetched_ms >= ttlFor(e->state)); + unlock(); + return due; +} + +bool validCallsign(const char* cs) { + const size_t n = strlen(cs); + if (n < 2 || n > 8) { + return false; + } + for (size_t i = 0; i < n; ++i) { + if (!isalnum(static_cast(cs[i]))) { + return false; + } + } + return true; +} + +void copyAirport(JsonObject airport, Airport* out) { + memset(out, 0, sizeof(*out)); + if (airport.isNull()) { + return; + } + for (const char* key : {"iata_code", "icao_code"}) { + if (airport[key].is()) { + const char* code = airport[key].as(); + if (code != nullptr && code[0] != '\0') { + strncpy(out->code, code, sizeof(out->code) - 1); + break; + } + } + } + if (airport["municipality"].is()) { + services::foldToAscii(airport["municipality"].as(), out->city, + sizeof(out->city)); + } +} + +const char* airportLabel(const Airport& a) { + if (a.city[0] != '\0') { + return a.city; + } + return a.code[0] != '\0' ? a.code : "?"; +} + +FetchResult fetchRoute(const char* callsign, Airport* origin, + Airport* destination) { + String url = kApiBase; + url += callsign; + + WiFiClientSecure client; + client.setInsecure(); + + HTTPClient http; + http.setConnectTimeout(kConnectTimeoutMs); + http.setTimeout(kRequestTimeoutMs); + http.useHTTP10(true); + if (!http.begin(client, url)) { + Serial.println("route: http.begin failed"); + return FetchResult::kError; + } + + const int code = http.GET(); + if (code == 404) { + http.end(); + return FetchResult::kNotFound; + } + if (code == 429) { + http.end(); + return FetchResult::kRateLimited; + } + if (code != HTTP_CODE_OK) { + Serial.printf("route: HTTP %d for %s\n", code, callsign); + http.end(); + return FetchResult::kError; + } + if (http.getSize() > static_cast(kMaxBodyBytes)) { + http.end(); + return FetchResult::kError; + } + + const String body = http.getString(); + http.end(); + + JsonDocument filter; + for (const char* side : {"origin", "destination"}) { + filter["response"]["flightroute"][side]["iata_code"] = true; + filter["response"]["flightroute"][side]["icao_code"] = true; + filter["response"]["flightroute"][side]["municipality"] = true; + } + + JsonDocument doc; + const DeserializationError err = + deserializeJson(doc, body, DeserializationOption::Filter(filter)); + if (err) { + Serial.printf("route: JSON parse error: %s\n", err.c_str()); + return FetchResult::kError; + } + + JsonObject route = doc["response"]["flightroute"].as(); + if (route.isNull()) { + return FetchResult::kNotFound; + } + + copyAirport(route["origin"].as(), origin); + copyAirport(route["destination"].as(), destination); + return destination->code[0] != '\0' ? FetchResult::kOk + : FetchResult::kNotFound; +} + +} // namespace + +void init() { + if (s_mutex == nullptr) { + s_mutex = xSemaphoreCreateMutex(); + } +} + +bool lookup(const char* callsign, Route* out) { + if (callsign == nullptr || callsign[0] == '\0') { + return false; + } + bool found = false; + lock(); + Entry* e = findLocked(callsign); + if (e != nullptr && e->state == State::kFound) { + if (out != nullptr) { + out->origin = e->origin; + out->destination = e->destination; + } + e->last_used_ms = millis(); + found = true; + } + unlock(); + return found; +} + +size_t fetchPending(double center_lat, double center_lon, float max_dist_km, + size_t max_lookups) { + if (max_lookups == 0) { + return 0; + } + if (s_backoff_active && + static_cast(millis() - s_backoff_until_ms) < 0) { + return 0; + } + s_backoff_active = false; + + static services::adsb::Aircraft planes[services::adsb::kMaxAircraft]; + static float dist_km[services::adsb::kMaxAircraft]; + bool tried[services::adsb::kMaxAircraft] = {}; + + unsigned long base_ms = 0; + const size_t n = services::adsb::snapshotAircraft( + planes, services::adsb::kMaxAircraft, &base_ms); + + const float cos_lat = + cosf(static_cast(center_lat) * kDegToRad); + for (size_t i = 0; i < n; ++i) { + const float dx = + static_cast(planes[i].lon - center_lon) * kKmPerDeg * cos_lat; + const float dy = static_cast(planes[i].lat - center_lat) * kKmPerDeg; + dist_km[i] = sqrtf(dx * dx + dy * dy); + } + + size_t requests = 0; + while (requests < max_lookups) { + int best = -1; + for (size_t i = 0; i < n; ++i) { + if (tried[i] || !planes[i].has_flight_callsign || + dist_km[i] > max_dist_km || !validCallsign(planes[i].callsign) || + !needsFetch(planes[i].callsign)) { + continue; + } + if (best < 0 || dist_km[i] < dist_km[best]) { + best = static_cast(i); + } + } + if (best < 0) { + break; + } + tried[best] = true; + + Airport origin = {}; + Airport destination = {}; + const FetchResult result = + fetchRoute(planes[best].callsign, &origin, &destination); + ++requests; + + switch (result) { + case FetchResult::kOk: + store(planes[best].callsign, State::kFound, &origin, &destination); + Serial.printf( + "route: %s %s to %s (%s>%s) [heap free %u, lowest %u]\n", + planes[best].callsign, airportLabel(origin), + airportLabel(destination), origin.code[0] ? origin.code : "?", + destination.code, static_cast(ESP.getFreeHeap()), + static_cast(ESP.getMinFreeHeap())); + break; + case FetchResult::kNotFound: + store(planes[best].callsign, State::kNotFound, nullptr, nullptr); + Serial.printf("route: %s no route found\n", planes[best].callsign); + break; + case FetchResult::kRateLimited: + Serial.println("route: rate limited, backing off"); + s_backoff_until_ms = millis() + kRateLimitBackoffMs; + s_backoff_active = true; + return requests; + case FetchResult::kError: + store(planes[best].callsign, State::kError, nullptr, nullptr); + break; + } + } + return requests; +} + +} // namespace services::route diff --git a/src/services/wifi_setup.cpp b/src/services/wifi_setup.cpp index e0fc4105a..64fdf1589 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); +char s_destination_checkbox_attrs[32] = "type=\"checkbox\""; +WiFiManagerParameter s_param_destination( + "show_dest", "Show flight destination (callsign lookup via adsbdb.com)", "T", 2, + s_destination_checkbox_attrs, WFM_LABEL_AFTER); + void refreshPortalParamValues() { char lat_buf[kCoordParamLen + 1]; char lon_buf[kCoordParamLen + 1]; @@ -98,6 +103,9 @@ void refreshPortalParamValues() { snprintf(s_runways_checkbox_attrs, sizeof(s_runways_checkbox_attrs), "type=\"checkbox\"%s", ui::radar::showRunways() ? " checked" : ""); s_param_runways.setValue("T", 2); + snprintf(s_destination_checkbox_attrs, sizeof(s_destination_checkbox_attrs), + "type=\"checkbox\"%s", ui::radar::showDestination() ? " checked" : ""); + s_param_destination.setValue("T", 2); } void onPortalParamsSaved() { @@ -107,6 +115,7 @@ void onPortalParamsSaved() { } ui::radar::saveMilesFromPortal(s_param_miles.getValue()); ui::radar::saveRunwaysFromPortal(s_param_runways.getValue()); + ui::radar::saveDestinationFromPortal(s_param_destination.getValue()); refreshPortalParamValues(); } @@ -117,6 +126,7 @@ void attachPortalParams(WiFiManager& wm) { wm.addParameter(&s_param_lon); wm.addParameter(&s_param_miles); wm.addParameter(&s_param_runways); + wm.addParameter(&s_param_destination); wm.setSaveParamsCallback(onPortalParamsSaved); } diff --git a/src/ui/radar_display.cpp b/src/ui/radar_display.cpp index 5dcc5764d..738f5a369 100644 --- a/src/ui/radar_display.cpp +++ b/src/ui/radar_display.cpp @@ -6,12 +6,14 @@ #include #include #include +#include #include "config.h" #include "hardware/display.h" #include "hardware/display_font.h" #include "services/adsb_client.h" #include "services/radar_location.h" +#include "services/route_client.h" #include "ui/radar_range.h" #include "ui/radar_theme.h" #include "ui/runway_overlay.h" @@ -27,6 +29,7 @@ uint16_t kColorAircraft = 0x001F; uint16_t kColorTrackVector = 0xFFFF; uint16_t kColorTagType = 0x5DFF; uint16_t kColorTagAltitude = 0xFFE0; +uint16_t kColorTagRoute = 0x7F6E; uint16_t kColorRunway = 0x4D5F; uint16_t kColorRunwayLabel = 0x7DFF; @@ -192,6 +195,8 @@ void initPalette() { tft.color565(radar::kTagTypeR, radar::kTagTypeG, radar::kTagTypeB); radar::kColorTagAltitude = tft.color565(radar::kTagAltR, radar::kTagAltG, radar::kTagAltB); + radar::kColorTagRoute = + tft.color565(radar::kTagRouteR, radar::kTagRouteG, radar::kTagRouteB); radar::kColorRunway = tft.color565(radar::kRunwayR, radar::kRunwayG, radar::kRunwayB); radar::kColorRunwayLabel = tft.color565(radar::kRunwayLabelR, radar::kRunwayLabelG, @@ -407,9 +412,50 @@ void applyTagStyle() { } } -int measureTagBlockWidth(const services::adsb::Aircraft& plane) { +struct RouteTagText { + char origin[28]; + char destination[28]; + int lines; +}; + +const char* routePlaceName(const services::route::Airport& a) { + if (config::kRouteUseCityNames && a.city[0] != '\0' && + strlen(a.city) <= config::kRouteMaxCityChars) { + return a.city; + } + return a.code; +} + +RouteTagText routeTagText(const services::adsb::Aircraft& plane) { + RouteTagText t = {}; + if (!radar::showDestination() || !plane.has_flight_callsign) { + return t; + } + services::route::Route route; + if (!services::route::lookup(plane.callsign, &route) || + route.destination.code[0] == '\0') { + return t; + } + if (config::kRouteShowOrigin && route.origin.code[0] != '\0') { + snprintf(t.origin, sizeof(t.origin), "from %s", + routePlaceName(route.origin)); + ++t.lines; + } + snprintf(t.destination, sizeof(t.destination), "to %s", + routePlaceName(route.destination)); + ++t.lines; + return t; +} + +int measureTagBlockWidth(const services::adsb::Aircraft& plane, + const RouteTagText& route) { applyTagStyle(); int max_w = 0; + for (const char* line : {route.origin, route.destination}) { + if (line[0] != '\0') { + max_w = std::max(max_w, static_cast(s_draw->textWidth(line))); + } + } if (plane.callsign[0] != '\0') { const int w = s_draw->textWidth(plane.callsign); if (w > max_w) { @@ -435,9 +481,11 @@ void drawAircraftTag(int x, int y, const services::adsb::Aircraft& plane) { initTagLabelMetrics(); applyTagStyle(); + const RouteTagText route = routeTagText(plane); + const int line_h = s_draw->fontHeight(); - const int block_w = measureTagBlockWidth(plane); - const int block_h = line_h * 3; + const int block_w = measureTagBlockWidth(plane, route); + const int block_h = line_h * (3 + route.lines); int ly = y - block_h / 2; const int symbol_half = @@ -472,6 +520,16 @@ void drawAircraftTag(int x, int y, const services::adsb::Aircraft& plane) { s_draw->setTextColor(radar::kColorTagAltitude, radar::kColorBackground); s_draw->drawString(plane.alt, anchor_x, ly); } + ly += line_h; + + if (route.lines > 0) { + s_draw->setTextColor(radar::kColorTagRoute, radar::kColorBackground); + if (route.origin[0] != '\0') { + s_draw->drawString(route.origin, anchor_x, ly); + ly += line_h; + } + s_draw->drawString(route.destination, anchor_x, ly); + } } struct AircraftDrawItem { diff --git a/src/ui/radar_range.cpp b/src/ui/radar_range.cpp index d8bf002e2..ccf3fa56b 100644 --- a/src/ui/radar_range.cpp +++ b/src/ui/radar_range.cpp @@ -15,6 +15,7 @@ constexpr char kPrefsNamespace[] = "planeradar"; constexpr char kPrefsRangeKey[] = "rangeIdx"; constexpr char kPrefsMilesKey[] = "useMiles"; constexpr char kPrefsRunwaysKey[] = "showRwys"; +constexpr char kPrefsDestinationKey[] = "showDest"; constexpr uint8_t kDefaultRangeIndex = 1; // 10 km ring constexpr float kKmPerMile = 1.609344f; @@ -22,6 +23,7 @@ Preferences s_prefs; uint8_t s_range_index = kDefaultRangeIndex; bool s_use_miles = false; bool s_show_runways = true; +bool s_show_destination = true; void saveRangeIndex() { if (!s_prefs.begin(kPrefsNamespace, false)) { @@ -47,6 +49,14 @@ void saveShowRunways() { s_prefs.end(); } +void saveShowDestination() { + if (!s_prefs.begin(kPrefsNamespace, false)) { + return; + } + s_prefs.putBool(kPrefsDestinationKey, s_show_destination); + s_prefs.end(); +} + bool portalCheckboxChecked(const char* value) { if (value == nullptr || value[0] == '\0') { return false; @@ -70,6 +80,7 @@ void rangeInit() { (saved < kRangePresetCount) ? saved : kDefaultRangeIndex; s_use_miles = s_prefs.getBool(kPrefsMilesKey, false); s_show_runways = s_prefs.getBool(kPrefsRunwaysKey, true); + s_show_destination = s_prefs.getBool(kPrefsDestinationKey, true); s_prefs.end(); } @@ -93,6 +104,8 @@ bool useMiles() { return s_use_miles; } bool showRunways() { return s_show_runways; } +bool showDestination() { return s_show_destination; } + void saveMilesFromPortal(const char* checkbox_value) { s_use_miles = portalCheckboxChecked(checkbox_value); saveUseMiles(); @@ -105,6 +118,12 @@ void saveRunwaysFromPortal(const char* checkbox_value) { Serial.printf("Runway overlay: %s\n", s_show_runways ? "on" : "off"); } +void saveDestinationFromPortal(const char* checkbox_value) { + s_show_destination = portalCheckboxChecked(checkbox_value); + saveShowDestination(); + Serial.printf("Flight destination: %s\n", s_show_destination ? "on" : "off"); +} + 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 +141,11 @@ void formatCurrentRing3Label(char* buf, size_t len) { void unitsReset() { s_use_miles = false; s_show_runways = true; + s_show_destination = true; if (s_prefs.begin(kPrefsNamespace, false)) { s_prefs.remove(kPrefsMilesKey); s_prefs.remove(kPrefsRunwaysKey); + s_prefs.remove(kPrefsDestinationKey); s_prefs.end(); } }