├── .github ├── FUNDING.yaml └── workflows │ └── docker-hub.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── distance ├── LatLng.hpp ├── MathUtil.hpp ├── PolyUtil.hpp ├── SphericalUtil.hpp └── distance.cpp ├── docker-compose.yaml ├── pyproject.toml ├── requirements.in ├── requirements.txt ├── src └── adsb_api │ ├── __init__.py │ ├── app.py │ └── utils │ ├── api_routes.py │ ├── api_tar.py │ ├── api_v2.py │ ├── browser2.py │ ├── dependencies.py │ ├── models.py │ ├── plausible.py │ ├── provider.py │ ├── reapi.py │ └── settings.py ├── static ├── .gitkeep └── favicon.ico ├── templates ├── .gitkeep └── mylocalip.html └── tests ├── conftest.py ├── test_api.py └── test_integration.py /.github/FUNDING.yaml: -------------------------------------------------------------------------------- 1 | github: unresolv 2 | -------------------------------------------------------------------------------- /.github/workflows/docker-hub.yaml: -------------------------------------------------------------------------------- 1 | name: docker build 2 | 3 | on: 4 | schedule: 5 | - cron: "0 10 * * *" 6 | push: 7 | branches: 8 | - "**" 9 | tags: 10 | - "v*.*.*" 11 | pull_request: 12 | branches: 13 | - "main" 14 | 15 | jobs: 16 | docker: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Set variables useful for later 20 | id: useful_vars 21 | run: |- 22 | echo "::set-output name=timestamp::$(date +%s)" 23 | echo "::set-output name=short_sha::${GITHUB_SHA::8}" 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | - name: Docker meta 27 | id: docker_meta 28 | uses: docker/metadata-action@v4 29 | with: 30 | images: ghcr.io/${{ github.repository }} 31 | tags: | 32 | type=schedule 33 | type=ref,event=branch 34 | type=ref,event=pr 35 | type=semver,pattern={{version}} 36 | type=semver,pattern={{major}}.{{minor}} 37 | type=semver,pattern={{major}} 38 | type=sha,prefix=,format=long,event=tag 39 | type=sha 40 | type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} 41 | type=raw,value=${{ github.ref_name }}-${{ steps.useful_vars.outputs.short_sha }}-${{ steps.useful_vars.outputs.timestamp }},enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} 42 | - name: Set up QEMU 43 | uses: docker/setup-qemu-action@v2 44 | - name: Set up Docker Buildx 45 | uses: docker/setup-buildx-action@v2 46 | - name: Login to GHCR 47 | if: github.event_name != 'pull_request' 48 | uses: docker/login-action@v2 49 | with: 50 | registry: ghcr.io 51 | username: ${{ github.repository_owner }} 52 | password: ${{ secrets.GITHUB_TOKEN }} 53 | - name: Cache Docker layers 54 | uses: actions/cache@v3 55 | with: 56 | path: /tmp/.buildx-cache 57 | key: ${{ runner.os }}-buildx-${{ github.sha }} 58 | restore-keys: | 59 | ${{ runner.os }}-buildx- 60 | - name: Build and push 61 | uses: docker/build-push-action@v4 62 | with: 63 | context: . 64 | push: ${{ github.event_name != 'pull_request' }} 65 | tags: ${{ steps.docker_meta.outputs.tags }} 66 | labels: ${{ steps.docker_meta.outputs.labels }} 67 | platforms: linux/amd64,linux/arm64 68 | cache-from: type=local,src=/tmp/.buildx-cache 69 | cache-to: type=local,dest=/tmp/.buildx-cache,mode=max 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | 4 | # IDE 5 | .vscode/ 6 | *.code-workspace 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/charliermarsh/ruff-pre-commit 3 | # Ruff version. 4 | rev: 'v0.0.257' 5 | hooks: 6 | - id: ruff 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /app 4 | COPY ./requirements.txt /app 5 | COPY ./distance /app/distance 6 | 7 | # "Installing this module requires OpenSSL python bindings" 8 | RUN BUILD_DEPS="libssl-dev cargo gcc g++ libffi-dev build-essential" && \ 9 | apt-get update && apt-get install -y $BUILD_DEPS && \ 10 | PYOPENSSL=$(grep 'pyopenssl=' requirements.txt) && \ 11 | pip install --no-cache-dir $PYOPENSSL && \ 12 | pip install --no-cache-dir -r /app/requirements.txt && \ 13 | cd /app/distance && g++ -std=c++14 -O3 -o /usr/local/bin/distance distance.cpp && \ 14 | apt-get purge -y --auto-remove $BUILD_DEPS && \ 15 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 16 | 17 | COPY . /app 18 | RUN pip install -e . 19 | CMD uvicorn src.adsb_api.app:app --host 0.0.0.0 --port 80 20 | 21 | ENV PYTHONUNBUFFERED=1 22 | ENV PYTHONDONTWRITEBYTECODE=1 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Katia Esposito 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include static 2 | recursive-include templates 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # api 2 | 3 | This is the source code for the [adsb.lol](https://adsb.lol) API. 4 | 5 | It runs in Kubernetes and is written in Python / asyncio / aiohttp. 6 | 7 | **This API is compatible with the ADSBExchange Rapid API. It is a drop-in replacement.** 8 | 9 | ## Documentation 10 | 11 | Interactive documentation for the API lives at [api.adsb.lol/docs](https://api.adsb.lol/docs) 12 | 13 | ## Rate limits 14 | 15 | Currently, there are no rate limits on the API. If you are using the API in a production environment, let me know so I don't break your app in case the API changes. 16 | 17 | In the future, I may add rate limits to the API. 18 | 19 | In the future, you will require an API key which you can obtain by [feeding adsb.lol](https://adsb.lol/feed). 20 | 21 | This will be a way to ensure that the API is being used responsibly and by people who are willing to contribute to the project. 22 | -------------------------------------------------------------------------------- /distance/LatLng.hpp: -------------------------------------------------------------------------------- 1 | //****************************************************************************** 2 | // Copyright 2013 Google Inc. 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // This software and the related documents are provided as is, with no express 10 | // or implied warranties, other than those that are expressly stated in the 11 | // License. 12 | //****************************************************************************** 13 | 14 | #ifndef GEOMETRY_LIBRARY_LATLNG 15 | #define GEOMETRY_LIBRARY_LATLNG 16 | 17 | #include 18 | 19 | class LatLng { 20 | public: 21 | double lat; // The latitude of this location 22 | double lng; // The longitude of this location 23 | 24 | /** 25 | * Constructs a location with a latitude/longitude pair. 26 | * 27 | * @param lat The latitude of this location. 28 | * @param lng The longitude of this location. 29 | */ 30 | LatLng(double lat, double lng) 31 | : lat(lat), lng(lng) {} 32 | 33 | LatLng(const LatLng & point) = default; 34 | 35 | LatLng& operator=(const LatLng & other) = default; 36 | 37 | bool operator==(const LatLng & other) const { 38 | return isCoordinateEqual(lat, other.lat) && 39 | isCoordinateEqual(lng, other.lng); 40 | } 41 | 42 | 43 | private: 44 | bool isCoordinateEqual(double first, double second) const { 45 | return std::fabs(first - second) < 1e-12; 46 | } 47 | }; 48 | 49 | #endif // GEOMETRY_LIBRARY_LATLNG 50 | -------------------------------------------------------------------------------- /distance/MathUtil.hpp: -------------------------------------------------------------------------------- 1 | //****************************************************************************** 2 | // Copyright 2013 Google Inc. 3 | // https://github.com/googlemaps/android-maps-utils/blob/master/library/src/main/java/com/google/maps/android/MathUtil.java 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // This software and the related documents are provided as is, with no express 11 | // or implied warranties, other than those that are expressly stated in the 12 | // License. 13 | //****************************************************************************** 14 | 15 | #ifndef GEOMETRY_LIBRARY_MATH_UTIL 16 | #define GEOMETRY_LIBRARY_MATH_UTIL 17 | 18 | #include 19 | #include 20 | 21 | #define M_PI 3.14159265358979323846 22 | 23 | inline double deg2rad(double degrees) { 24 | return degrees * M_PI / 180.0; 25 | } 26 | 27 | inline double rad2deg(double angle) { 28 | return angle * 180.0 / M_PI; 29 | } 30 | 31 | class MathUtil { 32 | public: 33 | /** 34 | * The earth's radius, in meters. 35 | * Mean radius as defined by IUGG. 36 | */ 37 | static constexpr double EARTH_RADIUS = 6371009.0; 38 | 39 | /** 40 | * Restrict x to the range [low, high]. 41 | */ 42 | static inline double clamp(double x, double low, double high) { 43 | return x < low ? low : (x > high ? high : x); 44 | } 45 | 46 | /** 47 | * Wraps the given value into the inclusive-exclusive interval between min and max. 48 | * @param n The value to wrap. 49 | * @param min The minimum. 50 | * @param max The maximum. 51 | */ 52 | static inline double wrap(double n, double min, double max) { 53 | return (n >= min && n < max) ? n : (MathUtil::mod(n - min, max - min) + min); 54 | } 55 | 56 | /** 57 | * Returns the non-negative remainder of x / m. 58 | * @param x The operand. 59 | * @param m The modulus. 60 | */ 61 | static inline double mod(double x, double m) { 62 | return remainder(remainder(x, m) + m, m); 63 | } 64 | 65 | /** 66 | * Returns mercator Y corresponding to latitude. 67 | * See http://en.wikipedia.org/wiki/Mercator_projection . 68 | */ 69 | static inline double mercator(double lat) { 70 | return log(tan(lat * 0.5 + M_PI / 4.0)); 71 | } 72 | 73 | /** 74 | * Returns latitude from mercator Y. 75 | */ 76 | static inline double inverseMercator(double y) { 77 | return 2.0 * atan(exp(y)) - M_PI / 2.0; 78 | } 79 | 80 | /** 81 | * Returns haversine(angle-in-radians). 82 | * hav(x) == (1 - cos(x)) / 2 == sin(x / 2)^2. 83 | */ 84 | static inline double hav(double x) { 85 | double sinHalf = sin(x * 0.5); 86 | return sinHalf * sinHalf; 87 | } 88 | 89 | /** 90 | * Computes inverse haversine. Has good numerical stability around 0. 91 | * arcHav(x) == acos(1 - 2 * x) == 2 * asin(sqrt(x)). 92 | * The argument must be in [0, 1], and the result is positive. 93 | */ 94 | static inline double arcHav(double x) { 95 | return 2.0 * asin(sqrt(x)); 96 | } 97 | 98 | // Given h==hav(x), returns sin(abs(x)). 99 | static inline double sinFromHav(double h) { 100 | return 2.0 * sqrt(h * (1.0 - h)); 101 | } 102 | 103 | // Returns hav(asin(x)). 104 | static inline double havFromSin(double x) { 105 | double x2 = x * x; 106 | return x2 / (1.0 + sqrt(1.0 - x2)) * 0.5; 107 | } 108 | 109 | // Returns sin(arcHav(x) + arcHav(y)). 110 | static inline double sinSumFromHav(double x, double y) { 111 | double a = sqrt(x * (1 - x)); 112 | double b = sqrt(y * (1 - y)); 113 | return 2.0 * (a + b - 2 * (a * y + b * x)); 114 | } 115 | 116 | /** 117 | * Returns hav() of distance from (lat1, lng1) to (lat2, lng2) on the unit sphere. 118 | */ 119 | static inline double havDistance(double lat1, double lat2, double dLng) { 120 | return MathUtil::hav(lat1 - lat2) + MathUtil::hav(dLng) * cos(lat1) * cos(lat2); 121 | } 122 | }; 123 | 124 | #endif // GEOMETRY_LIBRARY_MATH_UTIL 125 | -------------------------------------------------------------------------------- /distance/PolyUtil.hpp: -------------------------------------------------------------------------------- 1 | //****************************************************************************** 2 | // Copyright 2013 Google Inc. 3 | // https://github.com/googlemaps/android-maps-utils/blob/master/library/src/main/java/com/google/maps/android/PolyUtil.java 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // This software and the related documents are provided as is, with no express 11 | // or implied warranties, other than those that are expressly stated in the 12 | // License. 13 | //****************************************************************************** 14 | 15 | #ifndef GEOMETRY_LIBRARY_POLY_UTIL 16 | #define GEOMETRY_LIBRARY_POLY_UTIL 17 | 18 | #include "MathUtil.hpp" 19 | #include "SphericalUtil.hpp" 20 | 21 | 22 | class PolyUtil { 23 | public: 24 | static constexpr double DEFAULT_TOLERANCE = 0.1; // meters 25 | 26 | /** 27 | * Computes whether the given point lies inside the specified polygon. 28 | * The polygon is always cosidered closed, regardless of whether the last point equals 29 | * the first or not. 30 | * Inside is defined as not containing the South Pole -- the South Pole is always outside. 31 | * The polygon is formed of great circle segments if geodesic is true, and of rhumb 32 | * (loxodromic) segments otherwise. 33 | */ 34 | template 35 | static inline bool containsLocation(const LatLng& point, const LatLngList& polygon, bool geodesic = false) { 36 | size_t size = polygon.size(); 37 | 38 | if (size == 0) { 39 | return false; 40 | } 41 | double lat3 = deg2rad(point.lat); 42 | double lng3 = deg2rad(point.lng); 43 | LatLng prev = polygon[size - 1]; 44 | double lat1 = deg2rad(prev.lat); 45 | double lng1 = deg2rad(prev.lng); 46 | 47 | size_t nIntersect = 0; 48 | 49 | for (auto val : polygon) { 50 | double dLng3 = MathUtil::wrap(lng3 - lng1, -M_PI, M_PI); 51 | // Special case: point equal to vertex is inside. 52 | if (lat3 == lat1 && dLng3 == 0) { 53 | return true; 54 | } 55 | 56 | double lat2 = deg2rad(val.lat); 57 | double lng2 = deg2rad(val.lng); 58 | 59 | // Offset longitudes by -lng1. 60 | if (PolyUtil::intersects(lat1, lat2, MathUtil::wrap(lng2 - lng1, -M_PI, M_PI), lat3, dLng3, geodesic)) { 61 | ++nIntersect; 62 | } 63 | lat1 = lat2; 64 | lng1 = lng2; 65 | } 66 | return (nIntersect & 1) != 0; 67 | } 68 | 69 | 70 | /** 71 | * Computes whether the given point lies on or near the edge of a polygon, within a specified 72 | * tolerance in meters. The polygon edge is composed of great circle segments if geodesic 73 | * is true, and of Rhumb segments otherwise. The polygon edge is implicitly closed -- the 74 | * closing segment between the first point and the last point is included. 75 | */ 76 | template 77 | static inline bool isLocationOnEdge(const LatLng& point, const LatLngList& polygon, double tolerance = PolyUtil::DEFAULT_TOLERANCE, bool geodesic = true) { 78 | return PolyUtil::isLocationOnEdgeOrPath(point, polygon, true, geodesic, tolerance); 79 | } 80 | 81 | 82 | /** 83 | * Computes whether the given point lies on or near a polyline, within a specified 84 | * tolerance in meters. The polyline is composed of great circle segments if geodesic 85 | * is true, and of Rhumb segments otherwise. The polyline is not closed -- the closing 86 | * segment between the first point and the last point is not included. 87 | */ 88 | template 89 | static inline bool isLocationOnPath(const LatLng& point, const LatLngList& polyline, double tolerance = PolyUtil::DEFAULT_TOLERANCE, bool geodesic = true) { 90 | return PolyUtil::isLocationOnEdgeOrPath(point, polyline, false, geodesic, tolerance); 91 | } 92 | 93 | /** 94 | * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. 95 | * If closed, the closing segment between the last and first points of the polyline is not considered. 96 | * 97 | * @param point our needle 98 | * @param poly our haystack 99 | * @param closed whether the polyline should be considered closed by a segment connecting the last point back to the first one 100 | * @param geodesic the polyline is composed of great circle segments if geodesic 101 | * is true, and of Rhumb segments otherwise 102 | * @param toleranceEarth tolerance (in meters) 103 | * @return -1 if point does not lie on or near the polyline. 104 | * 0 if point is between poly[0] and poly[1] (inclusive), 105 | * 1 if between poly[1] and poly[2], 106 | * ..., 107 | * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] 108 | */ 109 | template 110 | static inline bool isLocationOnEdgeOrPath(const LatLng& point, const LatLngList& poly, bool closed, bool geodesic, double toleranceEarth) { 111 | size_t size = poly.size(); 112 | 113 | if (size == 0U) { 114 | return false; 115 | } 116 | 117 | double tolerance = toleranceEarth / MathUtil::EARTH_RADIUS; 118 | double havTolerance = MathUtil::hav(tolerance); 119 | double lat3 = deg2rad(point.lat); 120 | double lng3 = deg2rad(point.lng); 121 | LatLng prev = poly[closed ? size - 1 : 0]; 122 | double lat1 = deg2rad(prev.lat); 123 | double lng1 = deg2rad(prev.lng); 124 | 125 | if (geodesic) { 126 | for (auto val : poly) { 127 | double lat2 = deg2rad(val.lat); 128 | double lng2 = deg2rad(val.lng); 129 | if (PolyUtil::isOnSegmentGC(lat1, lng1, lat2, lng2, lat3, lng3, havTolerance)) { 130 | return true; 131 | } 132 | lat1 = lat2; 133 | lng1 = lng2; 134 | } 135 | }else { 136 | // We project the points to mercator space, where the Rhumb segment is a straight line, 137 | // and compute the geodesic distance between point3 and the closest point on the 138 | // segment. This method is an approximation, because it uses "closest" in mercator 139 | // space which is not "closest" on the sphere -- but the error is small because 140 | // "tolerance" is small. 141 | double minAcceptable = lat3 - tolerance; 142 | double maxAcceptable = lat3 + tolerance; 143 | double y1 = MathUtil::mercator(lat1); 144 | double y3 = MathUtil::mercator(lat3); 145 | double xTry[3]; 146 | for (auto val : poly) { 147 | double lat2 = deg2rad(val.lat); 148 | double y2 = MathUtil::mercator(lat2); 149 | double lng2 = deg2rad(val.lng); 150 | if (std::max(lat1, lat2) >= minAcceptable && std::min(lat1, lat2) <= maxAcceptable) { 151 | // We offset longitudes by -lng1; the implicit x1 is 0. 152 | double x2 = MathUtil::wrap(lng2 - lng1, -M_PI, M_PI); 153 | double x3Base = MathUtil::wrap(lng3 - lng1, -M_PI, M_PI); 154 | xTry[0] = x3Base; 155 | // Also explore wrapping of x3Base around the world in both directions. 156 | xTry[1] = x3Base + 2 * M_PI; 157 | xTry[2] = x3Base - 2 * M_PI; 158 | 159 | for (auto x3 : xTry) { 160 | double dy = y2 - y1; 161 | double len2 = x2 * x2 + dy * dy; 162 | double t = len2 <= 0 ? 0 : MathUtil::clamp((x3 * x2 + (y3 - y1) * dy) / len2, 0, 1); 163 | double xClosest = t * x2; 164 | double yClosest = y1 + t * dy; 165 | double latClosest = MathUtil::inverseMercator(yClosest); 166 | double havDist = MathUtil::havDistance(lat3, latClosest, x3 - xClosest); 167 | if (havDist < havTolerance) { 168 | return true; 169 | } 170 | } 171 | } 172 | lat1 = lat2; 173 | lng1 = lng2; 174 | y1 = y2; 175 | } 176 | } 177 | return false; 178 | } 179 | 180 | /** 181 | * Computes the distance on the sphere between the point p and the line segment start to end. 182 | * 183 | * @param p the point to be measured 184 | * @param start the beginning of the line segment 185 | * @param end the end of the line segment 186 | * @return the distance in meters (assuming spherical earth) 187 | */ 188 | static inline double distanceToLine(const LatLng& p, const LatLng& start, const LatLng& end) { 189 | if (start == end) { 190 | return SphericalUtil::computeDistanceBetween(end, p); 191 | } 192 | double s0lat = deg2rad(p.lat); 193 | double s0lng = deg2rad(p.lng); 194 | double s1lat = deg2rad(start.lat); 195 | double s1lng = deg2rad(start.lng); 196 | double s2lat = deg2rad(end.lat); 197 | double s2lng = deg2rad(end.lng); 198 | double s2s1lat = s2lat - s1lat; 199 | double s2s1lng = s2lng - s1lng; 200 | double u = ((s0lat - s1lat) * s2s1lat + (s0lng - s1lng) * s2s1lng) 201 | / (s2s1lat * s2s1lat + s2s1lng * s2s1lng); 202 | if (u <= 0) { 203 | return SphericalUtil::computeDistanceBetween(p, start); 204 | } 205 | if (u >= 1) { 206 | return SphericalUtil::computeDistanceBetween(p, end); 207 | } 208 | LatLng su(start.lat + u * (end.lat - start.lat), start.lng + u * (end.lng - start.lng)); 209 | return SphericalUtil::computeDistanceBetween(p, su); 210 | } 211 | 212 | 213 | private: 214 | /** 215 | * Returns tan(latitude-at-lng3) on the great circle (lat1, lng1) to (lat2, lng2). lng1==0. 216 | * See http://williams.best.vwh.net/avform.htm . 217 | */ 218 | static inline double tanLatGC(double lat1, double lat2, double lng2, double lng3) { 219 | return (tan(lat1) * sin(lng2 - lng3) + tan(lat2) * sin(lng3)) / sin(lng2); 220 | } 221 | 222 | /** 223 | * Returns mercator(latitude-at-lng3) on the Rhumb line (lat1, lng1) to (lat2, lng2). lng1==0. 224 | */ 225 | static inline double mercatorLatRhumb(double lat1, double lat2, double lng2, double lng3) { 226 | return (MathUtil::mercator(lat1) * (lng2 - lng3) + MathUtil::mercator(lat2) * lng3) / lng2; 227 | } 228 | 229 | /** 230 | * Computes whether the vertical segment (lat3, lng3) to South Pole intersects the segment 231 | * (lat1, lng1) to (lat2, lng2). 232 | * Longitudes are offset by -lng1; the implicit lng1 becomes 0. 233 | */ 234 | static inline double intersects(double lat1, double lat2, double lng2, double lat3, double lng3, bool geodesic) { 235 | // Both ends on the same side of lng3. 236 | if ((lng3 >= 0 && lng3 >= lng2) || (lng3 < 0 && lng3 < lng2)) { 237 | return false; 238 | } 239 | // Point is South Pole. 240 | if (lat3 <= -M_PI / 2) { 241 | return false; 242 | } 243 | // Any segment end is a pole. 244 | if (lat1 <= -M_PI / 2 || lat2 <= -M_PI / 2 || lat1 >= M_PI / 2 || lat2 >= M_PI / 2) { 245 | return false; 246 | } 247 | if (lng2 <= -M_PI) { 248 | return false; 249 | } 250 | double linearLat = (lat1 * (lng2 - lng3) + lat2 * lng3) / lng2; 251 | // Northern hemisphere and point under lat-lng line. 252 | if (lat1 >= 0 && lat2 >= 0 && lat3 < linearLat) { 253 | return false; 254 | } 255 | // Southern hemisphere and point above lat-lng line. 256 | if (lat1 <= 0 && lat2 <= 0 && lat3 >= linearLat) { 257 | return true; 258 | } 259 | // North Pole. 260 | if (lat3 >= M_PI / 2) { 261 | return true; 262 | } 263 | // Compare lat3 with latitude on the GC/Rhumb segment corresponding to lng3. 264 | // Compare through a strictly-increasing function (tan() or mercator()) as convenient. 265 | return geodesic ? 266 | tan(lat3) >= PolyUtil::tanLatGC(lat1, lat2, lng2, lng3) : 267 | MathUtil::mercator(lat3) >= PolyUtil::mercatorLatRhumb(lat1, lat2, lng2, lng3); 268 | } 269 | 270 | /** 271 | * Returns sin(initial bearing from (lat1,lng1) to (lat3,lng3) minus initial bearing 272 | * from (lat1, lng1) to (lat2,lng2)). 273 | */ 274 | static inline double sinDeltaBearing(double lat1, double lng1, double lat2, double lng2, double lat3, double lng3) { 275 | double sinLat1 = sin(lat1); 276 | double cosLat2 = cos(lat2); 277 | double cosLat3 = cos(lat3); 278 | double lat31 = lat3 - lat1; 279 | double lng31 = lng3 - lng1; 280 | double lat21 = lat2 - lat1; 281 | double lng21 = lng2 - lng1; 282 | double a = sin(lng31) * cosLat3; 283 | double c = sin(lng21) * cosLat2; 284 | double b = sin(lat31) + 2 * sinLat1 * cosLat3 * MathUtil::hav(lng31); 285 | double d = sin(lat21) + 2 * sinLat1 * cosLat2 * MathUtil::hav(lng21); 286 | double denom = (a * a + b * b) * (c * c + d * d); 287 | return denom <= 0 ? 1 : (a * d - b * c) / sqrt(denom); 288 | } 289 | 290 | static inline bool isOnSegmentGC(double lat1, double lng1, double lat2, double lng2, double lat3, double lng3, double havTolerance) { 291 | double havDist13 = MathUtil::havDistance(lat1, lat3, lng1 - lng3); 292 | if (havDist13 <= havTolerance) { 293 | return true; 294 | } 295 | double havDist23 = MathUtil::havDistance(lat2, lat3, lng2 - lng3); 296 | if (havDist23 <= havTolerance) { 297 | return true; 298 | } 299 | double sinBearing = PolyUtil::sinDeltaBearing(lat1, lng1, lat2, lng2, lat3, lng3); 300 | double sinDist13 = MathUtil::sinFromHav(havDist13); 301 | double havCrossTrack = MathUtil::havFromSin(sinDist13 * sinBearing); 302 | if (havCrossTrack > havTolerance) { 303 | return false; 304 | } 305 | double havDist12 = MathUtil::havDistance(lat1, lat2, lng1 - lng2); 306 | double term = havDist12 + havCrossTrack * (1 - 2 * havDist12); 307 | if (havDist13 > term || havDist23 > term) { 308 | return false; 309 | } 310 | if (havDist12 < 0.74) { 311 | return true; 312 | } 313 | double cosCrossTrack = 1 - 2 * havCrossTrack; 314 | double havAlongTrack13 = (havDist13 - havCrossTrack) / cosCrossTrack; 315 | double havAlongTrack23 = (havDist23 - havCrossTrack) / cosCrossTrack; 316 | double sinSumAlongTrack = MathUtil::sinSumFromHav(havAlongTrack13, havAlongTrack23); 317 | return sinSumAlongTrack > 0; // Compare with half-circle == PI using sign of sin(). 318 | } 319 | }; 320 | 321 | #endif // GEOMETRY_LIBRARY_POLY_UTIL 322 | -------------------------------------------------------------------------------- /distance/SphericalUtil.hpp: -------------------------------------------------------------------------------- 1 | //****************************************************************************** 2 | // Copyright 2013 Google Inc. 3 | // https://github.com/googlemaps/android-maps-utils/blob/master/library/src/main/java/com/google/maps/android/SphericalUtil.java 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // This software and the related documents are provided as is, with no express 11 | // or implied warranties, other than those that are expressly stated in the 12 | // License. 13 | //****************************************************************************** 14 | 15 | #ifndef GEOMETRY_LIBRARY_SPHERICAL_UTIL 16 | #define GEOMETRY_LIBRARY_SPHERICAL_UTIL 17 | 18 | #include "MathUtil.hpp" 19 | #include "LatLng.hpp" 20 | 21 | class SphericalUtil { 22 | public: 23 | /** 24 | * Returns the heading from one LatLng to another LatLng. Headings are 25 | * expressed in degrees clockwise from North within the range [-180,180). 26 | * 27 | * @return The heading in degrees clockwise from north. 28 | */ 29 | inline static double computeHeading(const LatLng& from, const LatLng& to) { 30 | // http://williams.best.vwh.net/avform.htm#Crs 31 | double fromLat = deg2rad(from.lat); 32 | double fromLng = deg2rad(from.lng); 33 | double toLat = deg2rad(to.lat); 34 | double toLng = deg2rad(to.lng); 35 | double dLng = toLng - fromLng; 36 | double heading = atan2( 37 | sin(dLng) * cos(toLat), 38 | cos(fromLat) * sin(toLat) - sin(fromLat) * cos(toLat) * cos(dLng)); 39 | 40 | return MathUtil::wrap(rad2deg(heading), -180, 180); 41 | } 42 | 43 | 44 | /** 45 | * Returns the LatLng resulting from moving a distance from an origin 46 | * in the specified heading (expressed in degrees clockwise from north). 47 | * 48 | * @param from The LatLng from which to start. 49 | * @param distance The distance to travel. 50 | * @param heading The heading in degrees clockwise from north. 51 | */ 52 | inline static LatLng computeOffset(const LatLng& from, double distance, double heading) { 53 | distance /= MathUtil::EARTH_RADIUS; 54 | heading = deg2rad(heading); 55 | // http://williams.best.vwh.net/avform.htm#LL 56 | double fromLat = deg2rad(from.lat); 57 | double fromLng = deg2rad(from.lng); 58 | double cosDistance = cos(distance); 59 | double sinDistance = sin(distance); 60 | double sinFromLat = sin(fromLat); 61 | double cosFromLat = cos(fromLat); 62 | double sinLat = cosDistance * sinFromLat + sinDistance * cosFromLat * cos(heading); 63 | double dLng = atan2( 64 | sinDistance * cosFromLat * sin(heading), 65 | cosDistance - sinFromLat * sinLat); 66 | 67 | return LatLng(rad2deg(asin(sinLat)), rad2deg(fromLng + dLng)); 68 | } 69 | 70 | 71 | 72 | /** 73 | * Returns the location of origin when provided with a LatLng destination, 74 | * meters travelled and original heading. Headings are expressed in degrees 75 | * clockwise from North. This function returns null when no solution is 76 | * available. 77 | * 78 | * @param to The destination LatLng. 79 | * @param distance The distance travelled, in meters. 80 | * @param heading The heading in degrees clockwise from north. 81 | */ 82 | inline static LatLng computeOffsetOrigin(const LatLng& to, double distance, double heading) { 83 | heading = deg2rad(heading); 84 | distance /= MathUtil::EARTH_RADIUS; 85 | // http://lists.maptools.org/pipermail/proj/2008-October/003939.html 86 | double n1 = cos(distance); 87 | double n2 = sin(distance) * cos(heading); 88 | double n3 = sin(distance) * sin(heading); 89 | double n4 = sin(deg2rad(to.lat)); 90 | // There are two solutions for b. b = n2 * n4 +/- sqrt(), one solution results 91 | // in the latitude outside the [-90, 90] range. We first try one solution and 92 | // back off to the other if we are outside that range. 93 | double n12 = n1 * n1; 94 | double discriminant = n2 * n2 * n12 + n12 * n12 - n12 * n4 * n4; 95 | 96 | // TODO: No real solution which would make sense in LatLng-space. 97 | // if (discriminant < 0) return null; 98 | 99 | double b = n2 * n4 + sqrt(discriminant); 100 | b /= n1 * n1 + n2 * n2; 101 | double a = (n4 - n2 * b) / n1; 102 | double fromLatRadians = atan2(a, b); 103 | if (fromLatRadians < -M_PI / 2 || fromLatRadians > M_PI / 2) { 104 | b = n2 * n4 - sqrt(discriminant); 105 | b /= n1 * n1 + n2 * n2; 106 | fromLatRadians = atan2(a, b); 107 | } 108 | 109 | // TODO: No solution which would make sense in LatLng-space. 110 | // if (fromLatRadians < -M_PI / 2 || fromLatRadians > M_PI / 2) return null; 111 | 112 | double fromLngRadians = rad2deg(to.lng) - atan2(n3, n1 * cos(fromLatRadians) - n2 * sin(fromLatRadians)); 113 | return LatLng(rad2deg(fromLatRadians), rad2deg(fromLngRadians)); 114 | } 115 | 116 | 117 | 118 | /** 119 | * Returns the LatLng which lies the given fraction of the way between the 120 | * origin LatLng and the destination LatLng. 121 | * 122 | * @param from The LatLng from which to start. 123 | * @param to The LatLng toward which to travel. 124 | * @param fraction A fraction of the distance to travel. 125 | * @return The interpolated LatLng. 126 | */ 127 | inline static LatLng interpolate(const LatLng& from, const LatLng& to, double fraction) { 128 | // http://en.wikipedia.org/wiki/Slerp 129 | double fromLat = deg2rad(from.lat); 130 | double fromLng = deg2rad(from.lng); 131 | double toLat = deg2rad(to.lat); 132 | double toLng = deg2rad(to.lng); 133 | double cosFromLat = cos(fromLat); 134 | double cosToLat = cos(toLat); 135 | // Computes Spherical interpolation coefficients. 136 | double angle = SphericalUtil::computeAngleBetween(from, to); 137 | double sinAngle = sin(angle); 138 | if (sinAngle < 1e-6) { 139 | return from; 140 | } 141 | double a = sin((1 - fraction) * angle) / sinAngle; 142 | double b = sin(fraction * angle) / sinAngle; 143 | // Converts from polar to vector and interpolate. 144 | double x = a * cosFromLat * cos(fromLng) + b * cosToLat * cos(toLng); 145 | double y = a * cosFromLat * sin(fromLng) + b * cosToLat * sin(toLng); 146 | double z = a * sin(fromLat) + b * sin(toLat); 147 | // Converts interpolated vector back to polar. 148 | double lat = atan2(z, sqrt(x * x + y * y)); 149 | double lng = atan2(y, x); 150 | return LatLng(rad2deg(lat), rad2deg(lng)); 151 | } 152 | 153 | /** 154 | * Returns the angle between two LatLngs, in radians. This is the same as the distance 155 | * on the unit sphere. 156 | */ 157 | inline static double computeAngleBetween(const LatLng& from, const LatLng& to) { 158 | return SphericalUtil::distanceRadians(deg2rad(from.lat), deg2rad(from.lng), deg2rad(to.lat), deg2rad(to.lng)); 159 | } 160 | 161 | /** 162 | * Returns the distance between two LatLngs, in meters. 163 | */ 164 | inline static double computeDistanceBetween(const LatLng& from, const LatLng& to) { 165 | return SphericalUtil::computeAngleBetween(from, to) * MathUtil::EARTH_RADIUS; 166 | } 167 | 168 | /** 169 | * Returns the length of the given path, in meters, on Earth. 170 | */ 171 | template 172 | inline static double computeLength(const LatLngList& path) { 173 | if (path.size() < 2U) { 174 | return 0; 175 | } 176 | double length = 0; 177 | LatLng prev = path[0]; 178 | double prevLat = deg2rad(prev.lat); 179 | double prevLng = deg2rad(prev.lng); 180 | for (auto point : path) { 181 | double lat = deg2rad(point.lat); 182 | double lng = deg2rad(point.lng); 183 | length += SphericalUtil::distanceRadians(prevLat, prevLng, lat, lng); 184 | prevLat = lat; 185 | prevLng = lng; 186 | } 187 | return length * MathUtil::EARTH_RADIUS; 188 | } 189 | 190 | /** 191 | * Returns the area of a closed path on Earth. 192 | * 193 | * @param path A closed path. 194 | * @return The path's area in square meters. 195 | */ 196 | template 197 | inline static double computeArea(const LatLngList& path) { 198 | return abs(SphericalUtil::computeSignedArea(path)); 199 | } 200 | 201 | /** 202 | * Returns the signed area of a closed path on Earth. The sign of the area may be used to 203 | * determine the orientation of the path. 204 | * "inside" is the surface that does not contain the South Pole. 205 | * 206 | * @param path A closed path. 207 | * @return The loop's area in square meters. 208 | */ 209 | template 210 | inline static double computeSignedArea(const LatLngList& path) { 211 | return SphericalUtil::computeSignedAreaP(path, MathUtil::EARTH_RADIUS); 212 | } 213 | 214 | 215 | private: 216 | /** 217 | * Returns distance on the unit sphere; the arguments are in radians. 218 | */ 219 | inline static double distanceRadians(double lat1, double lng1, double lat2, double lng2) { 220 | return MathUtil::arcHav(MathUtil::havDistance(lat1, lat2, lng1 - lng2)); 221 | } 222 | 223 | /** 224 | * Returns the signed area of a closed path on a sphere of given radius. 225 | * The computed area uses the same units as the radius squared. 226 | * Used by SphericalUtilTest. 227 | */ 228 | template 229 | inline static double computeSignedAreaP(const LatLngList& path, double radius) { 230 | size_t size = path.size(); 231 | if (size < 3U) { return 0; } 232 | double total = 0; 233 | LatLng prev = path[size - 1]; 234 | double prevTanLat = tan((M_PI / 2 - deg2rad(prev.lat)) / 2); 235 | double prevLng = deg2rad(prev.lng); 236 | // For each edge, accumulate the signed area of the triangle formed by the North Pole 237 | // and that edge ("polar triangle"). 238 | for (auto point : path) { 239 | double tanLat = tan((M_PI / 2 - deg2rad(point.lat)) / 2); 240 | double lng = deg2rad(point.lng); 241 | total += SphericalUtil::polarTriangleArea(tanLat, lng, prevTanLat, prevLng); 242 | prevTanLat = tanLat; 243 | prevLng = lng; 244 | } 245 | return total * (radius * radius); 246 | } 247 | 248 | /** 249 | * Returns the signed area of a triangle which has North Pole as a vertex. 250 | * Formula derived from "Area of a spherical triangle given two edges and the included angle" 251 | * as per "Spherical Trigonometry" by Todhunter, page 71, section 103, point 2. 252 | * See http://books.google.com/books?id=3uBHAAAAIAAJ&pg=PA71 253 | * The arguments named "tan" are tan((pi/2 - latitude)/2). 254 | */ 255 | inline static double polarTriangleArea(double tan1, double lng1, double tan2, double lng2) { 256 | double deltaLng = lng1 - lng2; 257 | double t = tan1 * tan2; 258 | return 2 * atan2(t * sin(deltaLng), 1 + t * cos(deltaLng)); 259 | } 260 | }; 261 | 262 | #endif // GEOMETRY_LIBRARY_SPHERICAL_UTIL 263 | -------------------------------------------------------------------------------- /distance/distance.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include "SphericalUtil.hpp" 6 | #include "PolyUtil.hpp" 7 | 8 | void usage() { 9 | std::cout << "Usage:\n\n\tdistance latP lngP latA lngA latB lngB absoluteDelta relativeDelta\n\n\tcheck if a point P is within the given thresholdsa (in meters or percentage of the distance A-B) of the great circle route between A and B.\n"; 10 | } 11 | 12 | int main(int argc, char** argv) { 13 | if (argc != 9) { 14 | usage(); 15 | exit(1); 16 | } 17 | LatLng p = { std::stod(argv[1]), std::stod(argv[2]) }; 18 | LatLng a = { std::stod(argv[3]), std::stod(argv[4]) }; 19 | LatLng b = { std::stod(argv[5]), std::stod(argv[6]) }; 20 | double distThreshold = std::stod(argv[7]) * 1852; 21 | double distPercentage = std::stod(argv[8]); 22 | 23 | double distPA = SphericalUtil::computeDistanceBetween(p, a); 24 | double distPB = SphericalUtil::computeDistanceBetween(p, b); 25 | double distAB = SphericalUtil::computeDistanceBetween(a, b); 26 | 27 | // calculate if the point is within the given tolerance of the route 28 | std::vector route = { a, b}; 29 | double threshold = std::max(distThreshold, distPercentage * distAB / 100.0); 30 | bool withinThreshold = PolyUtil::isLocationOnPath(p, route, threshold); 31 | 32 | std::cout << "{\"distPA\": " << distPA / 1852.0; 33 | std::cout << ",\"distPB\": " << distPB / 1852.0; 34 | std::cout << ",\"distAB\": " << distAB / 1852.0; 35 | std::cout << ",\"withinThreshold\": " << withinThreshold << "}"; 36 | 37 | return 0; 38 | } 39 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | app: 4 | build: . 5 | command: 6 | - uvicorn 7 | - src.adsb_api.app:app 8 | - --host=0.0.0.0 9 | - --port=80 10 | - --reload 11 | volumes: 12 | - .:/app 13 | environment: 14 | - INSECURE=1 15 | - ADSBLOL_ENABLED_BG_TASKS= 16 | ports: 17 | - 8089:80 18 | redis: 19 | image: redis:alpine 20 | ports: 21 | - 6379:6379 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "adsb_api" 7 | version = "0.6.0" 8 | authors = [] 9 | description = "ADSB.lol API - Live air traffic (ADS-B / MLAT / UAT)" 10 | license = {file = "LICENSE"} 11 | requires-python = ">=3.7" 12 | dynamic = ["readme", "dependencies"] 13 | classifiers = [ 14 | "Programming Language :: Python :: 3", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | ] 18 | 19 | [project.urls] 20 | "Homepage" = "https://github.com/adsblol/api" 21 | "Bug Tracker" = "https://github.com/adsblol/api/issues" 22 | 23 | [tool.setuptools.dynamic] 24 | readme = {file = "README.md"} 25 | dependencies = {file = "requirements.txt"} 26 | 27 | [project.optional-dependencies] 28 | dev = [ 29 | "mypy", 30 | "black", 31 | "ruff", 32 | ] 33 | test = [ 34 | "pytest", 35 | "pytest-asyncio", 36 | "aioresponses", 37 | ] 38 | 39 | [tool.ruff] 40 | # Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. 41 | select = ["E", "F"] 42 | ignore = [] 43 | 44 | # Allow autofix for all enabled rules (when `--fix`) is provided. 45 | fixable = ["A", "B", "C", "D", "E", "F"] 46 | unfixable = [] 47 | 48 | # Exclude a variety of commonly ignored directories. 49 | exclude = [ 50 | ".bzr", 51 | ".direnv", 52 | ".eggs", 53 | ".git", 54 | ".hg", 55 | ".mypy_cache", 56 | ".nox", 57 | ".pants.d", 58 | ".pytype", 59 | ".ruff_cache", 60 | ".svn", 61 | ".tox", 62 | ".venv", 63 | "__pypackages__", 64 | "_build", 65 | "buck-out", 66 | "build", 67 | "dist", 68 | "node_modules", 69 | "venv", 70 | ] 71 | 72 | # Same as Black. 73 | line-length = 88 74 | 75 | # Allow unused variables when underscore-prefixed. 76 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 77 | 78 | # Assume Python 3.10. 79 | target-version = "py310" 80 | 81 | [tool.ruff.mccabe] 82 | # Unlike Flake8, default to a complexity level of 10. 83 | max-complexity = 10 84 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | jinja2 2 | aiohttp_jinja2 3 | pyopenssl 4 | humanize 5 | fastapi 6 | uvicorn[standard] 7 | fastapi-cache2[redis] 8 | orjson 9 | aioredis 10 | aiodns 11 | humanhash3 12 | playwright 13 | backoff 14 | async-timeout 15 | async_lru 16 | h3>=4.0.0b2 17 | pendulum>=3.0.0b1 18 | aiohttp>=3.9.0b0 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile 6 | # 7 | aiodns==3.4.0 8 | # via -r requirements.in 9 | aiohappyeyeballs==2.6.1 10 | # via aiohttp 11 | aiohttp==3.11.18 12 | # via 13 | # -r requirements.in 14 | # aiohttp-jinja2 15 | aiohttp-jinja2==1.6 16 | # via -r requirements.in 17 | aioredis==2.0.1 18 | # via -r requirements.in 19 | aiosignal==1.3.2 20 | # via aiohttp 21 | annotated-types==0.7.0 22 | # via pydantic 23 | anyio==4.9.0 24 | # via 25 | # starlette 26 | # watchfiles 27 | async-lru==2.0.5 28 | # via -r requirements.in 29 | async-timeout==5.0.1 30 | # via 31 | # -r requirements.in 32 | # aioredis 33 | attrs==25.3.0 34 | # via aiohttp 35 | backoff==2.2.1 36 | # via -r requirements.in 37 | cffi==1.17.1 38 | # via 39 | # cryptography 40 | # pycares 41 | click==8.2.0 42 | # via uvicorn 43 | cryptography==45.0.2 44 | # via pyopenssl 45 | fastapi==0.115.12 46 | # via 47 | # -r requirements.in 48 | # fastapi-cache2 49 | fastapi-cache2[redis]==0.2.2 50 | # via -r requirements.in 51 | frozenlist==1.6.0 52 | # via 53 | # aiohttp 54 | # aiosignal 55 | greenlet==3.2.2 56 | # via playwright 57 | h11==0.16.0 58 | # via uvicorn 59 | h3==4.2.2 60 | # via -r requirements.in 61 | httptools==0.6.4 62 | # via uvicorn 63 | humanhash3==0.0.6 64 | # via -r requirements.in 65 | humanize==4.12.3 66 | # via -r requirements.in 67 | idna==3.10 68 | # via 69 | # anyio 70 | # yarl 71 | jinja2==3.1.6 72 | # via 73 | # -r requirements.in 74 | # aiohttp-jinja2 75 | markupsafe==3.0.2 76 | # via jinja2 77 | multidict==6.4.3 78 | # via 79 | # aiohttp 80 | # yarl 81 | orjson==3.10.18 82 | # via -r requirements.in 83 | pendulum==3.1.0 84 | # via 85 | # -r requirements.in 86 | # fastapi-cache2 87 | playwright==1.52.0 88 | # via -r requirements.in 89 | propcache==0.3.1 90 | # via 91 | # aiohttp 92 | # yarl 93 | pycares==4.8.0 94 | # via aiodns 95 | pycparser==2.22 96 | # via cffi 97 | pydantic==2.11.4 98 | # via fastapi 99 | pydantic-core==2.33.2 100 | # via pydantic 101 | pyee==13.0.0 102 | # via playwright 103 | pyopenssl==25.1.0 104 | # via -r requirements.in 105 | python-dateutil==2.9.0.post0 106 | # via pendulum 107 | python-dotenv==1.1.0 108 | # via uvicorn 109 | pyyaml==6.0.2 110 | # via uvicorn 111 | redis==4.6.0 112 | # via fastapi-cache2 113 | six==1.17.0 114 | # via python-dateutil 115 | sniffio==1.3.1 116 | # via anyio 117 | starlette==0.46.2 118 | # via fastapi 119 | typing-extensions==4.13.2 120 | # via 121 | # aioredis 122 | # anyio 123 | # fastapi 124 | # fastapi-cache2 125 | # pydantic 126 | # pydantic-core 127 | # pyee 128 | # pyopenssl 129 | # typing-inspection 130 | typing-inspection==0.4.0 131 | # via pydantic 132 | tzdata==2025.2 133 | # via pendulum 134 | uvicorn[standard]==0.34.2 135 | # via 136 | # -r requirements.in 137 | # fastapi-cache2 138 | uvloop==0.21.0 139 | # via uvicorn 140 | watchfiles==1.0.5 141 | # via uvicorn 142 | websockets==15.0.1 143 | # via uvicorn 144 | yarl==1.20.0 145 | # via aiohttp 146 | -------------------------------------------------------------------------------- /src/adsb_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adsblol/api/9ea8630552befa96871e81a4f6c52b7410341768/src/adsb_api/__init__.py -------------------------------------------------------------------------------- /src/adsb_api/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import ipaddress 3 | import pathlib 4 | import random 5 | import secrets 6 | import time 7 | import traceback 8 | import uuid 9 | from collections import defaultdict 10 | 11 | import aiohttp 12 | import h3 13 | import orjson 14 | from fastapi import FastAPI, Header, Request 15 | from fastapi.openapi.docs import get_swagger_ui_html 16 | from fastapi.responses import FileResponse, RedirectResponse, Response 17 | from fastapi.staticfiles import StaticFiles 18 | from fastapi.templating import Jinja2Templates 19 | from fastapi_cache import FastAPICache 20 | from fastapi_cache.backends.redis import RedisBackend 21 | from redis import asyncio as aioredis 22 | 23 | from adsb_api.utils.api_routes import router as routes_router 24 | from adsb_api.utils.api_tar import router as tar_router 25 | from adsb_api.utils.api_v2 import router as v2_router 26 | from adsb_api.utils.dependencies import browser, feederData, provider, redisVRS 27 | from adsb_api.utils.models import ApiUuidRequest, PrettyJSONResponse 28 | from adsb_api.utils.settings import (INSECURE, REDIS_HOST, SALT_BEAST, 29 | SALT_MLAT, SALT_MY) 30 | 31 | PROJECT_PATH = pathlib.Path(__file__).parent.parent.parent 32 | 33 | description = """ 34 | The adsb.lol API is a free and open source 35 | API for the [adsb.lol](https://adsb.lol) project. 36 | 37 | ## Usage 38 | You can use the API by sending a GET request 39 | to the endpoint you want to use. 40 | The API will return a JSON response. 41 | 42 | ## Feeders 43 | 44 | By sending data to adsb.lol, you get access to the 45 | [direct readsb re-api](https://www.adsb.lol/docs/feeders-only/re-api/) 46 | and 47 | [our raw aggregated data](https://www.adsb.lol/docs/feeders-only/beast-mlat-out/). :) 48 | 49 | ## Terms of Service 50 | You can use the API for free. 51 | 52 | In the future, you will require an API key 53 | which you can get by feeding to adsb.lol. 54 | 55 | If you want to use the API for production purposes, 56 | please contact me so I do not break your application by accident. 57 | 58 | ## License 59 | 60 | The license for the API as well as all data ADSB.lol 61 | makes public is [ODbL](https://opendatacommons.org/licenses/odbl/summary/). 62 | 63 | This is the same license 64 | [OpenStreetMap](https://www.openstreetmap.org/copyright) uses. 65 | """ 66 | 67 | app = FastAPI( 68 | title="adsb.lol API", 69 | description=description, 70 | version="0.0.2", 71 | docs_url=None, 72 | redoc_url=None, 73 | openapi_url="/api/openapi.json", 74 | license_info={ 75 | "name": "Open Data Commons Open Database License (ODbL) v1.0", 76 | "url": "https://opendatacommons.org/licenses/odbl/1-0/", 77 | }, 78 | ) 79 | 80 | app.include_router(v2_router) 81 | app.include_router(routes_router) 82 | app.include_router(tar_router) 83 | 84 | app.mount("/static", StaticFiles(directory="static"), name="static") 85 | templates = Jinja2Templates(directory=PROJECT_PATH / "templates") 86 | 87 | 88 | @app.get("/favicon.ico", include_in_schema=False) 89 | async def favicon(): 90 | return FileResponse("static/favicon.ico") 91 | 92 | 93 | @app.get("/docs", include_in_schema=False) 94 | def docs_override(): 95 | return get_swagger_ui_html( 96 | openapi_url="/api/openapi.json", 97 | title="adsb.lol API", 98 | swagger_favicon_url="/favicon.ico", 99 | ) 100 | 101 | 102 | def ensure_uuid_security(): 103 | # Each UUID should be at least 128 characters long 104 | # and should be unique. 105 | # If no UUIDs are set, generate some. 106 | if INSECURE: 107 | time.sleep(0.5) 108 | print("WARNING: INSECURE MODE IS ENABLED") 109 | print("WARNING: UUIDS WILL BE GENERATED ON EACH STARTUP!") 110 | time.sleep(0.5) 111 | salts = {"my": SALT_MY, "mlat": SALT_MLAT, "beast": SALT_BEAST} 112 | for name, salt in salts.items(): 113 | if salt is None or len(salt) < 128: 114 | print(f"WARNING: {name} salt is not secure") 115 | print("WARNING: Overriding with random salt") 116 | salts[name] = secrets.token_hex(128) 117 | # print first chars of salt 118 | print("WARNING: First 10 chars of salt: " + salts[name][:10]) 119 | 120 | 121 | @app.on_event("startup") 122 | async def startup_event(): 123 | redis = aioredis.from_url(REDIS_HOST, encoding="utf8", decode_responses=True) 124 | FastAPICache.init(RedisBackend(redis), prefix="api") 125 | for i in (redisVRS, provider, feederData): 126 | i.redis_connection_string = REDIS_HOST 127 | await provider.startup() 128 | await redisVRS.connect() 129 | await feederData.connect() 130 | await asyncio.sleep(1) 131 | await redisVRS.dispatch_background_task() 132 | await feederData.dispatch_background_task() 133 | try: 134 | await browser.start() 135 | except: 136 | traceback.print_exc() 137 | 138 | ensure_uuid_security() 139 | 140 | 141 | @app.on_event("shutdown") 142 | async def shutdown_event(): 143 | await provider.shutdown() 144 | await redisVRS.shutdown() 145 | await browser.shutdown() 146 | 147 | 148 | @app.get( 149 | "/api/0/mlat-server/{server}/sync.json", 150 | response_class=PrettyJSONResponse, 151 | include_in_schema=False, 152 | ) 153 | async def mlat_receivers( 154 | server: str, 155 | host: str | None = Header(default=None, include_in_schema=False), 156 | ): 157 | # if the host is not mlat.adsb.lol, 158 | # return a 404 159 | if host != "mlat.adsb.lol": 160 | print(f"failed mlat_sync host={host}, server={server} (not mlat.adsb.lol)") 161 | return {"error": "not found"} 162 | 163 | if server not in provider.mlat_sync_json.keys(): 164 | print(f"failed mlat_sync host={host}, server={server} (not in {provider.mlat_sync_json.keys()})") 165 | return {"error": "not found"} 166 | 167 | return provider.mlat_sync_json[server] 168 | 169 | 170 | @app.get( 171 | "/api/0/mlat-server/totalcount.json", 172 | response_class=PrettyJSONResponse, 173 | include_in_schema=False, 174 | ) 175 | async def mlat_totalcount_json(): 176 | return provider.mlat_totalcount_json 177 | 178 | 179 | @app.get("/metrics", include_in_schema=False) 180 | async def metrics(): 181 | """ 182 | Return metrics for Prometheus 183 | """ 184 | metrics = [ 185 | "adsb_api_beast_total_receivers {}".format(len(provider.beast_receivers)), 186 | "adsb_api_beast_total_clients {}".format(len(provider.beast_clients)), 187 | # "adsb_api_mlat_total {}".format(len(provider.mlat_sync_json)), 188 | # new format is {'0a': {clients}, '0b': {clients}} 189 | # so let's make tag for each server 190 | *[ 191 | 'adsb_api_mlat_total{{server="{0}"}} {1}'.format(server, len(clients)) 192 | for server, clients in provider.mlat_clients.items() 193 | ], 194 | "adsb_api_aircraft_total {}".format(provider.aircraft_totalcount), 195 | ] 196 | return Response(content="\n".join(metrics), media_type="text/plain") 197 | 198 | 199 | @app.get( 200 | "/0/me", 201 | response_class=PrettyJSONResponse, 202 | tags=["v0"], 203 | summary="Information about your receiver and global stats", 204 | ) 205 | async def api_me(request: Request): 206 | client_ip = request.client.host 207 | my_beast_clients = provider.get_clients_per_client_ip(client_ip) 208 | mlat_clients = provider.mlat_clients_to_list(client_ip) 209 | 210 | # count all items as mlat_clients format is {'0a': {clients}, '0b': {clients}} 211 | all_mlat_clients = sum([len(i) for i in provider.mlat_clients.values()]) 212 | response = { 213 | "_motd": [], 214 | "clients": { 215 | "beast": my_beast_clients, 216 | "mlat": mlat_clients, 217 | }, 218 | "global": { 219 | "beast": len(provider.beast_clients), 220 | "mlat": all_mlat_clients, 221 | "aircraft": provider.aircraft_totalcount, 222 | }, 223 | } 224 | 225 | # If any of the clients.beast.ms = -1, they PROBABLY do not use beast_reduce_plus_out 226 | # so add a WARNING 227 | if any([i["ms"] == -1 for i in my_beast_clients]): 228 | response["_motd"].append( 229 | "WARNING: You are probably not using beast_reduce_plus_out. Please use it instead of beast_reduce_out." 230 | ) 231 | # If there's any mlat client, and bad sync timeout is >0 for any of them, add a WARNING 232 | if any([i["bad_sync_timeout"] > 0 for i in mlat_clients]): 233 | response["_motd"].append( 234 | "WARNING: Some of your mlat clients have bad sync timeout. Please check your mlat configuration." 235 | ) 236 | # If any bad 237 | return response 238 | 239 | @app.get("/0/my", tags=["v0"], summary="My Map redirect based on IP") 240 | @app.get("/api/0/my", tags=["v0"], summary="My Map redirect based on IP", include_in_schema=False) 241 | async def api_my(request: Request): 242 | client_ip = request.client.host 243 | my_beast_clients = provider.get_clients_per_client_ip(client_ip) 244 | uids = [] 245 | if len(my_beast_clients) == 0: 246 | return RedirectResponse( 247 | url="https://adsb.lol#sorry-but-i-could-not-find-your-receiver?" 248 | ) 249 | for client in my_beast_clients: 250 | uids.append(client["adsblol_my_url"].split("https://")[1].split(".")[0]) 251 | # redirect to 252 | # uid1_uid2.my.adsb.lol 253 | host = "https://" + "_".join(uids) + ".my.adsb.lol" 254 | return RedirectResponse(url=host) 255 | 256 | 257 | @app.get( 258 | "/data/receiver.json", response_class=PrettyJSONResponse, include_in_schema=False 259 | ) 260 | async def receiver_json( 261 | host: str | None = Header(default=None, include_in_schema=False) 262 | ): 263 | ret = { 264 | "readsb": True, 265 | "version": "adsb.lol", 266 | "refresh": 1000, 267 | } 268 | # add feederData.additional_receiver_params 269 | # ret.update(feederData.additional_receiver_params) 270 | 271 | uids = host.split(".")[0].split("_") 272 | 273 | for uid in uids: 274 | # try getting receiver for uid 275 | receiver = await feederData.redis.get(f"my:{uid}") 276 | if receiver: 277 | receiver = receiver.decode()[:18] 278 | else: 279 | continue 280 | 281 | rdata = await feederData.redis.get(f"receiver:{receiver}") 282 | if rdata: 283 | rdata = orjson.loads(rdata.decode()) 284 | ret["lat"], ret["lon"] = round(rdata[8], 1), round(rdata[9], 1) 285 | break 286 | return ret 287 | 288 | 289 | @app.get( 290 | "/data/aircraft.json", 291 | response_class=PrettyJSONResponse, 292 | include_in_schema=False, 293 | ) 294 | async def aircraft_json( 295 | host: str | None = Header(default=None, include_in_schema=False) 296 | ): 297 | uids = host.split(".")[0].split("_") 298 | ac = [] 299 | for uid in uids: 300 | # we need to find the name of a redis key by its value! 301 | receiver = await feederData.redis.get(f"my:{uid}") 302 | if receiver: 303 | receiver = receiver.decode()[:18] 304 | else: 305 | continue 306 | 307 | if receiver: 308 | data = await feederData.get_aircraft(receiver) 309 | if data is not None: 310 | # remove key recentReceiverIds if it exists 311 | for aircraft in data: 312 | try: 313 | del aircraft["recentReceiverIds"] 314 | except KeyError: 315 | pass 316 | ac.extend(data) 317 | return { 318 | "now": int(time.time()), 319 | "messages": 0, 320 | "aircraft": ac, 321 | } 322 | 323 | 324 | # An API 1:1 caching https://api.planespotters.net/pub/photos/hex/ for 1h 325 | # Watch out! It passes also ?icaoType and ?reg to the API 326 | 327 | 328 | @app.get( 329 | "/0/planespotters_net/hex/{hex}", 330 | response_class=PrettyJSONResponse, 331 | include_in_schema=False, 332 | tags=["v0"], 333 | ) 334 | async def planespotters_net_hex( 335 | hex: str, 336 | reg: str = "", 337 | icaoType: str = "", 338 | ): 339 | # make a params out of the query params 340 | params = {"icaoType": icaoType, "reg": reg} 341 | redis_key = f"planespotters_net_hex:{hex}:{icaoType}:{reg}" 342 | # check if we have a cached response 343 | 344 | if cache := await redisVRS.redis.get(redis_key): 345 | # return the cached response 346 | return orjson.loads(cache) 347 | # if not, query the API 348 | async with aiohttp.ClientSession() as session: 349 | async with session.get( 350 | f"https://api.planespotters.net/pub/photos/hex/{hex}", 351 | params=params, 352 | ) as response: 353 | if response.status == 200: 354 | # cache the response for 1h 355 | data = await response.json() 356 | await redisVRS.redis.setex(redis_key, 3600, orjson.dumps(data)) 357 | res = data 358 | else: 359 | res = {"error": "not found"} 360 | return PrettyJSONResponse( 361 | content=res, 362 | headers={ 363 | "Access-Control-Allow-Origin": "*", 364 | }, 365 | ) 366 | 367 | 368 | @app.options("/0/planespotters_net/hex/{hex}", include_in_schema=False) 369 | async def planespotters_net_hex_options(): 370 | return Response( 371 | status_code=200, 372 | headers={ 373 | "Access-Control-Allow-Origin": "*", 374 | "Access-Control-Allow-Methods": "GET, POST, OPTIONS", 375 | "Access-Control-Allow-Headers": "Content-Type", 376 | }, 377 | ) 378 | 379 | @app.get( 380 | "/0/h3_latency", 381 | include_in_schema=False, 382 | tags=["v0"], 383 | ) 384 | async def h3_latency(): 385 | _h3 = defaultdict(list) 386 | for receiverId, lat, lon in provider.beast_receivers: 387 | for client in provider.beast_clients: 388 | if not client["_uuid"].startswith(receiverId) or client.get("ms", -1) < 0: 389 | continue 390 | _h3[h3.latlng_to_cell(lat, lon, 1)].append(client["ms"]) 391 | ret = defaultdict(dict) 392 | for key, value in _h3.items(): 393 | # calculate median 394 | value.sort() 395 | ret[key]["median"] = value[len(value) // 2] 396 | # calculate average, limit to 2 decimals 397 | ret[key]["average"] = round(sum(value) / len(value), 2) 398 | # calculate min 399 | ret[key]["min"] = min(value) 400 | # calculate max 401 | ret[key]["max"] = max(value) 402 | # calculate count 403 | ret[key]["count"] = len(value) 404 | # if count < 2, remove the key 405 | ret = {key: value for key, value in ret.items() if value["count"] > 1} 406 | # sort by median 407 | ret = dict(sorted(ret.items(), key=lambda item: item[1]["median"])) 408 | return Response(orjson.dumps(ret), media_type="application/json") 409 | 410 | if __name__ == "__main__": 411 | print("Run with:") 412 | print("uvicorn app:app --host 0.0.0.0 --port 80") 413 | print("or for development:") 414 | print("uvicorn app:app --host 0.0.0.0 --port 80 --reload") 415 | -------------------------------------------------------------------------------- /src/adsb_api/utils/api_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Response 2 | from adsb_api.utils.models import PrettyJSONResponse 3 | from adsb_api.utils.dependencies import redisVRS 4 | from adsb_api.utils.models import PlaneList 5 | from adsb_api.utils.plausible import plausible 6 | import asyncio 7 | 8 | CORS_HEADERS = { 9 | "Access-Control-Allow-Origin": "*", 10 | "Access-Control-Allow-Methods": "POST, OPTIONS", 11 | "Access-Control-Allow-Headers": "access-control-allow-origin,content-type", 12 | } 13 | 14 | 15 | router = APIRouter( 16 | prefix="/api", 17 | tags=["v0"], 18 | ) 19 | 20 | 21 | @router.get( 22 | "/0/airport/{icao}", 23 | response_class=PrettyJSONResponse, 24 | tags=["v0"], 25 | summary="Airports by ICAO", 26 | description="Data by https://github.com/vradarserver/standing-data/", 27 | ) 28 | async def api_airport(icao: str): 29 | """ 30 | Return information about an airport. 31 | """ 32 | return await redisVRS.get_airport(icao) 33 | 34 | 35 | async def get_route_for_callsign_lat_lng(callsign: str, lat: str, lng: str): 36 | route = await redisVRS.get_cached_route(callsign) 37 | if route: 38 | return route 39 | 40 | route = await redisVRS.get_route(callsign) 41 | 42 | if route["airport_codes"] == "unknown": 43 | return route 44 | 45 | is_plausible = False 46 | # print(f"==> {callsign}:", end=" ") 47 | for a in range(len(route["_airports"]) - 1): 48 | b = a + 1 49 | airportA = route["_airports"][a] 50 | airportB = route["_airports"][b] 51 | # print(f"checking {airportA['iata']}-{airportB['iata']}", end=" ") 52 | is_plausible, _ = plausible( 53 | lat, 54 | lng, 55 | f"{airportA['lat']:.5f}", 56 | f"{airportA['lon']:.5f}", 57 | f"{airportB['lat']:.5f}", 58 | f"{airportB['lon']:.5f}", 59 | ) 60 | if is_plausible: 61 | break 62 | 63 | print(f"==> {callsign} plausible: {is_plausible} {type(is_plausible)}") 64 | route["plausible"] = is_plausible 65 | await redisVRS.cache_route(callsign, is_plausible, route) 66 | return route 67 | 68 | 69 | @router.get( 70 | "/0/route/{callsign}/{lat}/{lng}", 71 | response_class=PrettyJSONResponse, 72 | tags=["v0"], 73 | summary="Route plus plausible flag for a specific callsign and position", 74 | description="Data by https://github.com/vradarserver/standing-data/", 75 | include_in_schema=False, 76 | ) 77 | async def api_route3( 78 | callsign: str, 79 | lat: str = None, 80 | lng: str = None, 81 | ): 82 | """ 83 | Return information about a route and plane position. 84 | Return value includes a guess whether 85 | this is a plausible route,given plane position. 86 | """ 87 | route = await get_route_for_callsign_lat_lng(callsign, lat, lng) 88 | return PrettyJSONResponse(content=route, headers=CORS_HEADERS) 89 | 90 | 91 | @router.get( 92 | "/0/route/{callsign}", 93 | response_class=PrettyJSONResponse, 94 | tags=["v0"], 95 | summary="Route for a specific callsign", 96 | description="Data by https://github.com/vradarserver/standing-data/", 97 | include_in_schema=False, 98 | ) 99 | async def api_route( 100 | callsign: str, 101 | ): 102 | """ 103 | Return information about a route. 104 | """ 105 | new_url = f"https://vrs-standing-data.adsb.lol/routes/{callsign[0:2]}/{callsign}.json#this-API-has-been-deprecated-please-use-this-new-URL-directly" 106 | await asyncio.sleep(5) 107 | return Response(status_code=302, headers={"Location": new_url}) 108 | 109 | 110 | @router.post( 111 | "/0/routeset", 112 | response_class=PrettyJSONResponse, 113 | tags=["v0"], 114 | summary="Routes for a list of aircraft callsigns", 115 | description="""Look up routes for multiple planes at once. 116 | Data by https://github.com/vradarserver/standing-data/""", 117 | ) 118 | async def api_routeset(planeList: PlaneList): 119 | """ 120 | Return route information on a list of planes / positions 121 | """ 122 | # print(planeList) 123 | response = [] 124 | if len(planeList.planes) > 100: 125 | return Response(status_code=400) 126 | tasks = [] 127 | for plane in planeList.planes: 128 | tasks.append( 129 | get_route_for_callsign_lat_lng(plane.callsign, plane.lat, plane.lng) 130 | ) 131 | response = [x for x in await asyncio.gather(*tasks)] 132 | return PrettyJSONResponse(content=response, headers=CORS_HEADERS) 133 | 134 | 135 | @router.options("/0/routeset", include_in_schema=False) 136 | async def api_routeset_options(): 137 | return Response(status_code=200, headers=CORS_HEADERS) 138 | -------------------------------------------------------------------------------- /src/adsb_api/utils/api_tar.py: -------------------------------------------------------------------------------- 1 | # Playwright API to get 256x256 screenshots of the ICAO 2 | # boom! 3 | 4 | import asyncio 5 | import base64 6 | import time 7 | import traceback 8 | 9 | import aiohttp 10 | from async_timeout import timeout 11 | from fastapi import APIRouter, Request 12 | from fastapi.responses import FileResponse, Response 13 | from fastapi_cache.decorator import cache 14 | from playwright.async_api import async_playwright 15 | 16 | from adsb_api.utils.dependencies import browser, redisVRS 17 | from adsb_api.utils.settings import REAPI_ENDPOINT 18 | 19 | router = APIRouter( 20 | prefix="/0", 21 | tags=["v0"], 22 | ) 23 | 24 | 25 | @router.get( 26 | "/screenshot/", 27 | responses={200: {"content": {"image/png": {}}}}, 28 | response_class=Response, 29 | include_in_schema=False, 30 | ) 31 | @router.get( 32 | "/screenshot/{icao}", 33 | responses={200: {"content": {"image/png": {}}}}, 34 | response_class=Response, 35 | include_in_schema=False, 36 | ) 37 | async def get_new_screenshot( 38 | icao: str, 39 | trace: bool = False, 40 | ) -> Response: 41 | icaos = icao.lower().split(",") 42 | for icao in icaos: 43 | if len(icao) == 6: 44 | if all(c in "0123456789abcdef" for c in icao): 45 | continue 46 | elif len(icao) == 7: 47 | if icao[0] != "~": 48 | return Response(status_code=400) 49 | if all(c in "0123456789abcdef" for c in icao[1:]): 50 | continue 51 | return Response(status_code=400) 52 | 53 | min_lat, min_lon, max_lat, max_lon = False, False, False, False 54 | # get the min and max lat/lon from re-api 55 | async with aiohttp.ClientSession() as session: 56 | async with session.get( 57 | f"{REAPI_ENDPOINT}/?find_hex={','.join(icaos)}" 58 | ) as response: 59 | data = await response.json() 60 | for aircraft in data["aircraft"]: 61 | if not aircraft.get("lat") or not aircraft.get("lon"): 62 | continue 63 | min_lat = min(min_lat, aircraft["lat"]) if min_lat else aircraft["lat"] 64 | min_lon = min(min_lon, aircraft["lon"]) if min_lon else aircraft["lon"] 65 | max_lat = max(max_lat, aircraft["lat"]) if max_lat else aircraft["lat"] 66 | max_lon = max(max_lon, aircraft["lon"]) if max_lon else aircraft["lon"] 67 | # if they are still not set, return 404. we can't get a fix, so we can't get a screenshot. sorry. 68 | if not min_lat or not min_lon or not max_lat or not max_lon: 69 | return Response(status_code=404) 70 | # make sure, in case of 1 aircraft, that we have a 1km box 71 | if len(icaos) == 1: 72 | min_lat, min_lon = min_lat - 0.005, min_lon - 0.005 73 | max_lat, max_lon = max_lat + 0.005, max_lon + 0.005 74 | 75 | cache_key = f"screenshot:{':'.join(icaos)}" 76 | 77 | if not trace: 78 | if cached_screenshot := await redisVRS.redis.get(cache_key): 79 | print(f"cached! {icao}") 80 | cached_screenshot = base64.b64decode(cached_screenshot) 81 | return Response(cached_screenshot, media_type="image/png") 82 | 83 | slept = 0 84 | 85 | while slept < 60: 86 | lock = await redisVRS.redis.setnx(f"{cache_key}:lock", 1) 87 | if lock: 88 | # set expiry 89 | await redisVRS.redis.expire(f"{cache_key}:lock", 10) 90 | break 91 | 92 | screen = await redisVRS.redis.get(cache_key) 93 | 94 | if screen: 95 | break 96 | else: 97 | slept += 1 98 | print(f"waiting for lock or screenshot {icaos} {slept}") 99 | await asyncio.sleep(1) 100 | 101 | if screen := await redisVRS.redis.get(cache_key): 102 | print(f"cached! {icao}") 103 | screen = base64.b64decode(screen) 104 | return Response(screen, media_type="image/png") 105 | 106 | # otherwise, let's get to work 107 | print(f"locked! {icao} {trace}") 108 | 109 | # run this in asyncio-timeout context 110 | try: 111 | async with browser.get_tab() as tab: 112 | async with timeout(10): 113 | if trace: 114 | await tab.context.tracing.start(screenshots=True, snapshots=True) 115 | try: 116 | start_js = "window._alol_mapcentered = false;window._alol_maploaded = false;window._alol_viewadjusted = false;window._are_tiles_loaded = false;window._alol_loading = 0; window._alol_loaded = 0;window._alol_viewadjusted=false;" 117 | other_planes_js = "".join( 118 | [ 119 | f'selectPlaneByHex("{icao}", {{noDeselect: true}});' 120 | for icao in icaos 121 | ] 122 | ) 123 | if min_lat and min_lon and max_lat and max_lon: 124 | # function adjustViewSelectedPlanes(maxLat, maxLon, minLat, minLon) { 125 | other_planes_js += f""" 126 | window.__alol_adjustViewSelectedPlanes = function() {{ 127 | let maxLat = {max_lat}; let maxLon = {max_lon}; let minLat = {min_lat}; let minLon = {min_lon}; 128 | let topRight = ol.proj.fromLonLat([maxLon, maxLat]); 129 | let bottomLeft = ol.proj.fromLonLat([minLon, minLat]); 130 | let newCenter = [(topRight[0] + bottomLeft[0]) / 2, (topRight[1] + bottomLeft[1]) / 2]; 131 | let longerSide = Math.max(Math.abs(topRight[0] - bottomLeft[0]), Math.abs(topRight[1] - bottomLeft[1])); 132 | longerSide = Math.max(longerSide, 60 * 1000); 133 | let newZoom = Math.floor(Math.log2(6e7 / longerSide)); 134 | console.log('newCenter: ' + newCenter); 135 | console.log('newZoom: ' + newZoom); 136 | if(newZoom > 13) newZoom = 13; 137 | OLMap.getView().setCenter(newCenter); 138 | OLMap.getView().setZoom(newZoom); 139 | window._alol_viewadjusted = true; 140 | }}; 141 | window.__alol_adjustViewSelectedPlanes(); 142 | """ 143 | print(f"js: {start_js + other_planes_js}") 144 | await tab.evaluate(start_js + other_planes_js) 145 | 146 | # wait ... 147 | 148 | try: 149 | await tab.wait_for_function( 150 | """ 151 | window._alol_maploaded === true && 152 | window._alol_mapcentered === true && 153 | window._are_tiles_loaded === true && 154 | window._alol_viewadjusted === true && 155 | SelPlanes.length > 0 && 156 | window.planesAreGood() 157 | """, 158 | timeout=10000, 159 | polling=25, 160 | ) 161 | 162 | except Exception as e: 163 | traceback.print_exc() 164 | print(f"{icao} waiting: {e}") 165 | except Exception as e: 166 | traceback.print_exc() 167 | print(f"{icao} inner: {e}") 168 | screenshot = await tab.screenshot(type="png") 169 | screenshot_b64 = base64.b64encode(screenshot).decode() 170 | if not trace: 171 | await redisVRS.redis.set(cache_key, screenshot_b64, ex=20) 172 | await redisVRS.redis.delete(f"{cache_key}:lock") 173 | return Response(screenshot, media_type="image/png") 174 | else: 175 | await tab.context.tracing.stop(path=f"/tmp/trace-{icao}.zip") 176 | return FileResponse( 177 | f"/tmp/trace-{icao}.zip", media_type="application/zip" 178 | ) 179 | 180 | except Exception as e: 181 | traceback.print_exc() 182 | print(f"{icao} outer: {e}") 183 | await redisVRS.redis.delete(f"{cache_key}:lock") 184 | return Response("sorry, no screenshots", media_type="text/plain") 185 | -------------------------------------------------------------------------------- /src/adsb_api/utils/api_v2.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request, Path 2 | from fastapi.responses import Response 3 | from fastapi_cache.decorator import cache 4 | 5 | from adsb_api.utils.dependencies import provider 6 | from adsb_api.utils.models import V2Response_Model 7 | from adsb_api.utils.settings import REDIS_TTL 8 | 9 | router = APIRouter( 10 | prefix="/v2", 11 | tags=["v2"], 12 | responses={200: {"model": V2Response_Model}}, 13 | ) 14 | 15 | 16 | @router.get( 17 | "/pia", 18 | summary="Aircrafts with PIA addresses (Privacy ICAO Address)", 19 | description="Returns all aircraft with [PIA](https://nbaa.org/aircraft-operations/security/privacy/privacy-icao-address-pia/) addresses.", 20 | ) 21 | async def v2_pia(request: Request) -> Response: 22 | params = ["all", "filter_pia"] 23 | 24 | res = await provider.ReAPI.request(params=params, client_ip=request.client.host) 25 | return Response(res, media_type="application/json") 26 | 27 | 28 | @router.get( 29 | "/mil", 30 | summary="Military registered aircrafts", 31 | description="Returns all military registered aircraft.", 32 | ) 33 | async def v2_mil(request: Request) -> Response: 34 | params = ["all", "filter_mil"] 35 | 36 | res = await provider.ReAPI.request(params=params, client_ip=request.client.host) 37 | return Response(res, media_type="application/json") 38 | 39 | 40 | @router.get( 41 | "/ladd", 42 | summary="Aircrafts on LADD (Limiting Aircraft Data Displayed)", 43 | description="Returns all aircrafts on [LADD](https://www.faa.gov/pilots/ladd) filter.", 44 | ) 45 | async def v2_ladd( 46 | request: Request, 47 | ) -> Response: 48 | params = ["all", "filter_ladd"] 49 | 50 | res = await provider.ReAPI.request(params=params, client_ip=request.client.host) 51 | return Response(res, media_type="application/json") 52 | 53 | 54 | @router.get( 55 | "/squawk/{squawk}", 56 | summary="Aircrafts with specific squawk (1200, 7700, etc.)", 57 | description='Returns aircraft filtered by "squawk" [transponder code](https://en.wikipedia.org/wiki/List_of_transponder_codes).', 58 | ) 59 | @router.get( 60 | "/sqk/{squawk}", 61 | summary="Aircrafts with specific squawk (1200, 7700, etc.)", 62 | description='Returns aircraft filtered by "squawk" [transponder code](https://en.wikipedia.org/wiki/List_of_transponder_codes).', 63 | ) 64 | async def v2_squawk_filter( 65 | # Allow custom examples 66 | request: Request, 67 | squawk: str = Path(default=..., example="1200"), 68 | ) -> Response: 69 | params = ["all", f"filter_squawk={squawk}"] 70 | 71 | res = await provider.ReAPI.request(params=params, client_ip=request.client.host) 72 | return Response(res, media_type="application/json") 73 | 74 | 75 | @router.get( 76 | "/type/{aircraft_type}", 77 | summary="Aircrafts of specific type (A320, B738)", 78 | description="Returns aircraft filtered by [aircraft type designator code](https://en.wikipedia.org/wiki/List_of_aircraft_type_designators).", 79 | ) 80 | async def v2_type_filter( 81 | request: Request, 82 | aircraft_type: str = Path(default=..., example="A320"), 83 | ) -> Response: 84 | params = [f"find_type={aircraft_type}"] 85 | 86 | res = await provider.ReAPI.request(params=params, client_ip=request.client.host) 87 | return Response(res, media_type="application/json") 88 | 89 | 90 | @router.get( 91 | "/registration/{registration}", 92 | summary="Aircrafts with specific registration (G-KELS)", 93 | description="Returns aircraft filtered by [aircarft registration code](https://en.wikipedia.org/wiki/Aircraft_registration).", 94 | ) 95 | @router.get( 96 | "/reg/{registration}", 97 | summary="Aircrafts with specific registration (G-KELS)", 98 | description="Returns aircraft filtered by [aircarft registration code](https://en.wikipedia.org/wiki/Aircraft_registration).", 99 | ) 100 | async def v2_reg_filter( 101 | request: Request, 102 | registration: str = Path(default=..., example="G-KELS"), 103 | ) -> Response: 104 | params = [f"find_reg={registration}"] 105 | 106 | res = await provider.ReAPI.request(params=params, client_ip=request.client.host) 107 | return Response(res, media_type="application/json") 108 | 109 | 110 | @router.get( 111 | "/hex/{icao_hex}", 112 | summary="Aircrafts with specific transponder hex code (4CA87C)", 113 | description="Returns aircraft filtered by [transponder hex code](https://en.wikipedia.org/wiki/Aviation_transponder_interrogation_modes#ICAO_24-bit_address).", 114 | ) 115 | @router.get( 116 | "/icao/{icao_hex}", 117 | summary="Aircrafts with specific transponder hex code (4CA87C)", 118 | description="Returns aircraft filtered by [transponder hex code](https://en.wikipedia.org/wiki/Aviation_transponder_interrogation_modes#ICAO_24-bit_address).", 119 | ) 120 | async def v2_hex_filter( 121 | request: Request, 122 | icao_hex: str = Path(default=..., example="4CA87C"), 123 | ) -> Response: 124 | params = [f"find_hex={icao_hex}"] 125 | 126 | res = await provider.ReAPI.request(params=params, client_ip=request.client.host) 127 | return Response(res, media_type="application/json") 128 | 129 | 130 | @router.get( 131 | "/callsign/{callsign}", 132 | summary="Aircrafts with specific callsign (JBU1942)", 133 | description="Returns aircraft filtered by [callsign](https://en.wikipedia.org/wiki/Aviation_call_signs).", 134 | ) 135 | async def v2_callsign_filter( 136 | request: Request, 137 | callsign: str = Path(default=..., example="JBU1942"), 138 | ) -> Response: 139 | params = [f"find_callsign={callsign}"] 140 | 141 | res = await provider.ReAPI.request(params=params, client_ip=request.client.host) 142 | return Response(res, media_type="application/json") 143 | 144 | 145 | @router.get( 146 | "/point/{lat}/{lon}/{radius}", 147 | summary="Aircrafts surrounding a point (lat, lon) up to 250nm", 148 | description="Returns aircraft located in a circle described by the latitude and longtidude of its center and its radius.", 149 | ) 150 | @router.get( 151 | "/lat/{lat}/lon/{lon}/dist/{radius}", 152 | summary="Aircrafts surrounding a point (lat, lon) up to 250nm", 153 | description="Returns aircraft located in a circle described by the latitude and longtidude of its center and its radius.", 154 | ) 155 | async def v2_point( 156 | request: Request, 157 | lat: float = Path(..., example=51.89508, ge=-90, le=90), 158 | lon: float = Path(..., example=2.79437, ge=-180, le=180), 159 | radius: int = Path(..., example=250, ge=0, le=250), 160 | ) -> Response: 161 | radius = min(radius, 250) 162 | 163 | res = await provider.ReAPI.request( 164 | params=[f"circle={lat},{lon},{radius}"], client_ip=request.client.host 165 | ) 166 | return Response(res, media_type="application/json") 167 | 168 | 169 | # closest 170 | @router.get( 171 | "/closest/{lat}/{lon}/{radius}", 172 | summary="Single aircraft closest to a point (lat, lon)", 173 | description="Returns the closest aircraft to a point described by the latitude and longtidude within a radius up to 250nm.", 174 | ) 175 | async def v2_closest( 176 | request: Request, 177 | lat: float = Path(..., example=51.89508, ge=-90, le=90), 178 | lon: float = Path(..., example=2.79437, ge=-180, le=180), 179 | radius: int = Path(..., example=250, ge=0, le=250), 180 | ) -> Response: 181 | res = await provider.ReAPI.request( 182 | params=[f"closest={lat},{lon},{radius}"], client_ip=request.client.host 183 | ) 184 | return Response(res, media_type="application/json") 185 | -------------------------------------------------------------------------------- /src/adsb_api/utils/browser2.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import traceback 4 | from contextlib import asynccontextmanager 5 | from os import getenv 6 | from typing import Optional 7 | 8 | import backoff 9 | from async_timeout import timeout 10 | from playwright.async_api import Page, async_playwright 11 | 12 | SCREEN_SIZE = {"width": 256, "height": 256} 13 | 14 | # SCREEN_SIZE override from env var 15 | SCREEN_SIZE = { 16 | "width": int(getenv("BROWSER2_SCREEN_WIDTH", SCREEN_SIZE["width"])), 17 | "height": int(getenv("BROWSER2_SCREEN_HEIGHT", SCREEN_SIZE["height"])), 18 | } 19 | 20 | class BrowserTabPool: 21 | def __init__( 22 | self, 23 | url: str, 24 | min_tabs: int = 2, 25 | max_tabs: int = 4, 26 | tab_ttl: int = 600, 27 | tab_max_uses: int = 200, 28 | before_add_to_pool_cb=None, 29 | before_return_to_pool_cb=None, 30 | ): 31 | self.p = None 32 | self.browser = None 33 | self.url = url 34 | self.min_tabs = min_tabs 35 | self.max_tabs = max_tabs 36 | self.tab_ttl = tab_ttl 37 | self.tab_max_uses = tab_max_uses 38 | self.pool = asyncio.Queue() 39 | self._active_tabs = set() 40 | self.before_add_to_pool_cb = before_add_to_pool_cb 41 | self.before_return_to_pool_cb = before_return_to_pool_cb 42 | self._background_task = None 43 | self._total_tabs = ( 44 | 0 # Tracks the total number of tabs being created, in the pool, and active 45 | ) 46 | self.logger = logging.getLogger(__name__) 47 | logging.basicConfig(level=logging.INFO) # Change the log level as necessary 48 | 49 | @backoff.on_exception(backoff.expo, Exception) 50 | async def initialize(self): 51 | self.logger.info("Initializing browser...") 52 | 53 | if not self.p: 54 | self.p = await async_playwright().__aenter__() 55 | self.logger.info("Playwright object created.") 56 | if self.browser: 57 | # Old browser detected. Clean up... 58 | self.logger.info("Old browser detected. Cleaning up...") 59 | try: 60 | self._active_tabs = set() 61 | self.pool = asyncio.Queue() 62 | self._total_tabs = 0 63 | await self.browser.close() 64 | except Exception as e: 65 | self.logger.error("Error while closing old browser: %s", e) 66 | 67 | self.browser = await self.p.chromium.connect_over_cdp( 68 | "ws://localhost:3000/?timeout=12000000" 69 | ) 70 | for _ in range(self.min_tabs): 71 | try: 72 | await self._add_tab_to_pool() 73 | except Exception as e: 74 | self.logger.error("Error while adding tab to pool: %s", e) 75 | 76 | async def _add_tab_to_pool(self): 77 | # Check if browser is healthy, if not, return 78 | if not self.browser.is_connected(): 79 | self.logger.error("Browser is not connected. Not adding tab to pool...") 80 | # Edge Case: Prevent the creation of more tabs than max_tabs 81 | if self._total_tabs >= self.max_tabs: 82 | return 83 | self._total_tabs += 1 # Increment _total_tabs when a new tab is being created 84 | self.logger.info("Total number of tabs after addition: %s", self._total_tabs) 85 | context = await self.browser.new_context( 86 | base_url=self.url, 87 | viewport=SCREEN_SIZE, 88 | screen=SCREEN_SIZE, 89 | ) 90 | tab = await context.new_page() 91 | tab.__use_count = 0 92 | tab.__created_at = asyncio.get_event_loop().time() 93 | await tab.goto(self.url) 94 | 95 | if self.before_add_to_pool_cb: 96 | is_tab_good_to_go = await self.before_add_to_pool_cb(tab) 97 | if not is_tab_good_to_go: 98 | self.logger.error("Tab is not good to go. Removing tab from pool...") 99 | await self._remove_tab(tab) 100 | return 101 | if self.before_return_to_pool_cb: 102 | await self.before_return_to_pool_cb(tab) 103 | 104 | self.logger.info( 105 | "Tab added to pool. Total number of tabs: %s / min: %s, max: %s", 106 | self._total_tabs, 107 | self.min_tabs, 108 | self.max_tabs, 109 | ) 110 | 111 | self.pool.put_nowait(tab) 112 | self._active_tabs.add(tab) 113 | 114 | async def _remove_tab(self, tab, reason=None): 115 | self.logger.info("Removing tab from pool... Reason: %s", reason or "Unknown") 116 | 117 | if tab and not tab.is_closed(): 118 | self.logger.info("Tab is open, proceeding to close it.") 119 | await tab.close() 120 | if tab in self._active_tabs: 121 | self._active_tabs.remove(tab) 122 | self._total_tabs -= 1 123 | 124 | async def is_tab_healthy(self, tab: Page): 125 | # Checks if the browser is connected and the tab is not closed 126 | return self.browser.is_connected() and not tab.is_closed() 127 | 128 | async def release_tab(self, tab): 129 | self.logger.info("Tab use count before release: %s", tab.__use_count) 130 | self.logger.info("Releasing tab back to pool...") 131 | if self.before_return_to_pool_cb: 132 | await self.before_return_to_pool_cb(tab) 133 | tab.__use_count += 1 134 | self.pool.put_nowait(tab) 135 | 136 | async def reconcile_pool(self): 137 | # Edge Case: Ensures that the pool always has at least min_tabs and not more than max_tabs 138 | if not self.browser or not self.browser.is_connected(): 139 | self.logger.error("Browser is not connected. Not reconciling pool...") 140 | return 141 | while self._total_tabs < self.max_tabs: 142 | self.logger.info( 143 | "Current pool size: %s, total tabs: %s", 144 | self.pool.qsize(), 145 | self._total_tabs, 146 | ) 147 | await self._add_tab_to_pool() 148 | 149 | async def enforce_tab_limits(self): 150 | # Edge Case: Closes tabs after a certain number of uses 151 | for tab in list(self._active_tabs): 152 | if tab.__use_count >= self.tab_max_uses: 153 | await self._remove_tab(tab, "maximum uses") 154 | if tab.__created_at + self.tab_ttl < asyncio.get_event_loop().time(): 155 | await self._remove_tab(tab, "maximum age") 156 | 157 | @asynccontextmanager 158 | async def get_tab(self) -> Optional: 159 | self.logger.info("Retrieving tab from pool...") 160 | 161 | while True: 162 | self.logger.info("Waiting for tab...") 163 | tab = await self.pool.get() 164 | if await self.is_tab_healthy(tab): 165 | self.logger.info("Tab retrieved from pool!") 166 | break 167 | else: 168 | await self._remove_tab(tab, "Unhealthy hot") 169 | 170 | try: 171 | yield tab 172 | finally: 173 | if await self.is_tab_healthy(tab): 174 | await self.release_tab(tab) 175 | else: 176 | await self._remove_tab(tab, "Unhealthy after use") 177 | 178 | # Handle browser launch errors 179 | @backoff.on_exception(backoff.expo, Exception, max_tries=10) 180 | async def reconcile_browser(self): 181 | if not self.p or not self.browser or not self.browser.is_connected(): 182 | # clean up existing tabs before creating new browser 183 | for tab in list( 184 | self._active_tabs 185 | ): # make a copy of the set to avoid RuntimeError during iteration 186 | await self._remove_tab(tab) 187 | try: 188 | if not self.p: 189 | self.p = await async_playwright().__aenter__() 190 | if not self.browser or not self.browser.is_connected(): 191 | await self.initialize() 192 | except Exception as e: 193 | self.logger.error("Failed to reconcile browser. Reason: %s", e) 194 | self.browser = None 195 | self.p = None 196 | await self.initialize() 197 | 198 | async def _background_task_fn(self): 199 | try: 200 | while True: 201 | self.logger.info("Running background task...") 202 | try: 203 | await asyncio.gather( 204 | self.reconcile_pool(), 205 | self.enforce_tab_limits(), 206 | self.reconcile_browser(), 207 | ) 208 | except Exception as e: 209 | traceback.print_exc() 210 | self.logger.error("Error during background task. Reason: %s", e) 211 | await asyncio.sleep(2) 212 | except asyncio.CancelledError: 213 | self.logger.info("Background task cancelled...") 214 | 215 | async def start(self): 216 | # Edge Case: Avoids multiple background tasks 217 | self.logger.info("Running background task...") 218 | if self._background_task: 219 | return 220 | self._background_task = asyncio.create_task(self._background_task_fn()) 221 | 222 | async def stop(self): 223 | self.logger.info("Stopping background task...") 224 | 225 | # Edge Case: Gracefully handles stopping of the background task 226 | if self._background_task: 227 | self._background_task.cancel() 228 | self._background_task = None 229 | 230 | # Graceful shutdown 231 | async def shutdown(self): 232 | self.logger.info("Shutting down...") 233 | if self._background_task: 234 | self._background_task.cancel() 235 | for tab in self._active_tabs: 236 | await self._remove_tab(tab) 237 | await self.browser.close() 238 | 239 | 240 | async def before_add_to_pool_cb(page): 241 | tasks = [ 242 | page.route("**/api/0/routeset", lambda route: route.abort()), 243 | page.route("**/globeRates.json", lambda route: route.abort()), 244 | page.route("https://api.planespotters.net/*", lambda route: route.abort()), 245 | page.set_viewport_size(SCREEN_SIZE), 246 | page.goto("?screenshot&zoom=6&hideButtons&hideSidebar&lat=82&lon=-5&nowebGL"), 247 | ] 248 | try: 249 | with timeout(5): 250 | await asyncio.gather(*tasks) 251 | except asyncio.TimeoutError: 252 | traceback.print_exc() 253 | return False 254 | 255 | infoblock = page.locator("#selected_infoblock") 256 | 257 | tasks = [ 258 | infoblock.wait_for(state="hidden", timeout=5000), 259 | page.wait_for_function("typeof deselectAllPlanes === 'function'", timeout=5000), 260 | page.wait_for_function("typeof OLMap === 'object'", timeout=5000), 261 | ] 262 | try: 263 | with timeout(10): 264 | await asyncio.gather(*tasks) 265 | except asyncio.TimeoutError: 266 | traceback.print_exc() 267 | return False 268 | js_magic = """ 269 | // Initialize the global variables 270 | window._are_tiles_loaded = false;window._alol_loading = 0;window._alol_loaded = 0; 271 | 272 | function attachEventHandlers(layer) { 273 | if (layer.getSource && typeof layer.getSource === 'function') { 274 | let source = layer.getSource(); 275 | if (source) { 276 | source.on('tileloadstart', function() { 277 | ++window._alol_loading; 278 | window._are_tiles_loaded = false; 279 | console.log(`Loading tiles: ${window._alol_loading}`); 280 | }); 281 | 282 | source.on(['tileloadend', 'tileloaderror'], updateLoadingStatus); 283 | } 284 | else { 285 | console.log(`Layer has no tileloadstart, tileloadend or tileloaderror event: ${layer.get('title')}`); 286 | } 287 | } 288 | else { 289 | console.log(`Layer has no source: ${layer.get('title')}`); 290 | } 291 | } 292 | 293 | function handleLayers(layers) { 294 | layers.forEach(layer => { 295 | layer instanceof ol.layer.Group 296 | ? handleLayers(layer.getLayers().getArray()) 297 | : attachEventHandlers(layer); 298 | }); 299 | } 300 | 301 | function updateLoadingStatus() { 302 | setTimeout(() => { 303 | ++window._alol_loaded; 304 | console.log(`Loaded tiles: ${window._alol_loaded}`); 305 | if (window._alol_loading === window._alol_loaded) { 306 | console.log('All tiles loaded'); 307 | window._alol_loading = 0; 308 | window._alol_loaded = 0; 309 | window._are_tiles_loaded = true; 310 | } 311 | }, 100); 312 | } 313 | 314 | // Start processing layers 315 | handleLayers(OLMap.getLayers().getArray()); 316 | window.planesAreGood = function() { 317 | for(plane in SelPlanes) { 318 | if(SelPlanes[plane].trace == null) return false; 319 | } 320 | return true; 321 | } 322 | 323 | window.adjustViewSelectedPlanes = function () { 324 | if (SelPlanes.length < 1) { return; } 325 | let maxLat, maxLon, minLat, minLon = null; 326 | 327 | console.log('minLon: ' + minLon); 328 | 329 | for (let i in SelPlanes) { 330 | let plane = SelPlanes[i]; 331 | 332 | if (!plane.position) { continue; } 333 | const lat = plane.position[1]; 334 | const lon = plane.position[0]; 335 | console.log('pos: ' + lat + ', ' + lon); 336 | if (minLon == null) { 337 | maxLat = minLat = lat; 338 | maxLon = minLon = lon; 339 | continue; 340 | } 341 | if (lat > maxLat) { maxLat = lat; }; 342 | if (lon > maxLon) { maxLon = lon; }; 343 | 344 | if (lat < minLat) { minLat = lat; }; 345 | if (lon < minLon) { minLon = lon; }; 346 | } 347 | if (minLon == null) { return; } 348 | 349 | console.log('max: ' + maxLat + ', ' + maxLon); 350 | console.log('min: ' + minLat + ', ' + minLon); 351 | 352 | let topRight = ol.proj.fromLonLat([maxLon, maxLat]); 353 | let bottomLeft = ol.proj.fromLonLat([minLon, minLat]); 354 | 355 | let newCenter = [(topRight[0] + bottomLeft[0]) / 2, (topRight[1] + bottomLeft[1]) / 2]; 356 | let longerSide = Math.max(Math.abs(topRight[0] - bottomLeft[0]), Math.abs(topRight[1] - bottomLeft[1])); 357 | 358 | let newZoom = Math.floor(Math.log2(6e7 / longerSide)); 359 | console.log('newCenter: ' + newCenter); 360 | console.log('newZoom: ' + newZoom); 361 | window._alol_maploaded = false; 362 | OLMap.getView().setCenter(newCenter); 363 | OLMap.getView().setZoom(newZoom); 364 | window._alol_viewadjusted = true; 365 | } 366 | 367 | 368 | 369 | """ 370 | tasks = [ 371 | page.evaluate( 372 | """$('#selected_infoblock')[0].remove(); $('.ol-zoom').remove(); $('.layer-switcher').remove(); function adjustInfoBlock(){}; toggleIsolation("on"); toggleMultiSelect("on"); reaper('all');""" 373 | ), 374 | page.evaluate( 375 | """ 376 | planespottersAPI=false; useRouteAPI=false; setPictureVisibility(); 377 | OLMap.addEventListener("moveend", () => {window._alol_mapcentered = true;}); 378 | OLMap.addEventListener("rendercomplete", () => {window._alol_maploaded = true;}); 379 | """ 380 | ), 381 | page.evaluate(js_magic), 382 | ] 383 | try: 384 | with timeout(10): 385 | await asyncio.gather(*tasks) 386 | except asyncio.TimeoutError: 387 | traceback.print_exc() 388 | return False 389 | return True 390 | 391 | 392 | async def before_return_to_pool_cb(page): 393 | await page.evaluate( 394 | """ 395 | reaper('all'); 396 | window._alol_mapcentered = false; 397 | window._alol_maploaded = false; 398 | window._alol_viewadjusted = false; 399 | window._are_tiles_loaded = false; 400 | window._alol_loading = 0; window._alol_loaded = 0; 401 | """ 402 | ) 403 | -------------------------------------------------------------------------------- /src/adsb_api/utils/dependencies.py: -------------------------------------------------------------------------------- 1 | from adsb_api.utils.provider import Provider 2 | from adsb_api.utils.provider import RedisVRS 3 | from adsb_api.utils.provider import FeederData 4 | from adsb_api.utils.settings import ENABLED_BG_TASKS 5 | from adsb_api.utils.browser2 import ( 6 | BrowserTabPool, 7 | before_add_to_pool_cb, 8 | before_return_to_pool_cb, 9 | ) 10 | 11 | provider = Provider(enabled_bg_tasks=ENABLED_BG_TASKS) 12 | redisVRS = RedisVRS() 13 | feederData = FeederData() 14 | browser = BrowserTabPool( 15 | url="https://adsb.lol/", 16 | before_add_to_pool_cb=before_add_to_pool_cb, 17 | before_return_to_pool_cb=before_return_to_pool_cb, 18 | ) 19 | -------------------------------------------------------------------------------- /src/adsb_api/utils/models.py: -------------------------------------------------------------------------------- 1 | import orjson 2 | import typing 3 | 4 | from fastapi.responses import Response 5 | from pydantic import BaseModel 6 | from typing import List, Optional, Union 7 | 8 | 9 | class ApiUuidRequest(BaseModel): 10 | version: str 11 | 12 | 13 | class PrettyJSONResponse(Response): 14 | media_type = "application/json" 15 | 16 | def render(self, content: typing.Any) -> bytes: 17 | return orjson.dumps( 18 | content, 19 | option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2, 20 | ) 21 | 22 | 23 | class V2Response_LastPosition(BaseModel): 24 | lat: float 25 | lon: float 26 | nic: int 27 | rc: int 28 | seen_pos: float 29 | 30 | 31 | class V2Response_AcItem(BaseModel): 32 | alert: Optional[int] = None 33 | alt_baro: Optional[Union[int, str]] = None 34 | alt_geom: Optional[int] = None 35 | baro_rate: Optional[int] = None 36 | category: Optional[str] = None 37 | emergency: Optional[str] = None 38 | flight: Optional[str] = None 39 | gs: Optional[float] = None 40 | gva: Optional[int] = None 41 | hex: str 42 | lat: Optional[float] = None 43 | lon: Optional[float] = None 44 | messages: int 45 | mlat: List[str] 46 | nac_p: Optional[int] = None 47 | nac_v: Optional[int] = None 48 | nav_altitude_mcp: Optional[int] = None 49 | nav_heading: Optional[float] = None 50 | nav_qnh: Optional[float] = None 51 | nic: Optional[int] = None 52 | nic_baro: Optional[int] = None 53 | r: Optional[str] = None 54 | rc: Optional[int] = None 55 | rssi: float 56 | sda: Optional[int] = None 57 | seen: float 58 | seen_pos: Optional[float] = None 59 | sil: Optional[int] = None 60 | sil_type: Optional[str] = None 61 | spi: Optional[int] = None 62 | squawk: Optional[str] = None 63 | t: Optional[str] = None 64 | tisb: List[str] 65 | track: Optional[float] = None 66 | type: str 67 | version: Optional[int] = None 68 | geom_rate: Optional[int] = None 69 | dbFlags: Optional[int] = None 70 | nav_modes: Optional[List[str]] = None 71 | true_heading: Optional[float] = None 72 | ias: Optional[int] = None 73 | mach: Optional[float] = None 74 | mag_heading: Optional[float] = None 75 | oat: Optional[int] = None 76 | roll: Optional[float] = None 77 | tas: Optional[int] = None 78 | tat: Optional[int] = None 79 | track_rate: Optional[float] = None 80 | wd: Optional[int] = None 81 | ws: Optional[int] = None 82 | gpsOkBefore: Optional[float] = None 83 | gpsOkLat: Optional[float] = None 84 | gpsOkLon: Optional[float] = None 85 | lastPosition: Optional[V2Response_LastPosition] = None 86 | rr_lat: Optional[float] = None 87 | rr_lon: Optional[float] = None 88 | calc_track: Optional[int] = None 89 | nav_altitude_fms: Optional[int] = None 90 | 91 | 92 | class V2Response_Model(BaseModel): 93 | ac: List[V2Response_AcItem] 94 | ctime: int 95 | msg: str 96 | now: int 97 | ptime: int 98 | total: int 99 | 100 | 101 | class PlaneInstance(BaseModel): 102 | callsign: str 103 | lat: float 104 | lng: float 105 | 106 | 107 | class PlaneList(BaseModel): 108 | planes: list[PlaneInstance] | None = None 109 | -------------------------------------------------------------------------------- /src/adsb_api/utils/plausible.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import orjson 3 | 4 | 5 | def plausible( 6 | posLat: float, 7 | posLng: float, 8 | airportALat: str, 9 | airportALon: str, 10 | airportBLat: str, 11 | airportBLon: str, 12 | ): 13 | # turn the lat/lng into strings 14 | posLat, posLng = str(posLat), str(posLng) 15 | # check if the position is within 50nm or 10% of the total distance of the great circle route 16 | distanceResult = subprocess.run( 17 | [ 18 | "/usr/local/bin/distance", 19 | posLat, 20 | posLng, 21 | airportALat, 22 | airportALon, 23 | airportBLat, 24 | airportBLon, 25 | "50", 26 | "20", 27 | ], 28 | capture_output=True, 29 | ) 30 | distance = orjson.loads(distanceResult.stdout) 31 | return distance["withinThreshold"], distance["distAB"] 32 | -------------------------------------------------------------------------------- /src/adsb_api/utils/provider.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import csv 3 | import gzip 4 | import hashlib 5 | import re 6 | import traceback 7 | import uuid 8 | from datetime import datetime 9 | from functools import lru_cache 10 | from socket import gethostname 11 | from email.utils import parsedate_to_datetime 12 | 13 | import aiodns 14 | import aiohttp 15 | import humanhash 16 | import orjson 17 | import redis.asyncio as redis 18 | 19 | from adsb_api.utils.reapi import ReAPI 20 | from adsb_api.utils.settings import ( 21 | INGEST_DNS, 22 | INGEST_HTTP_PORT, 23 | MLAT_SERVERS, 24 | REAPI_ENDPOINT, 25 | SALT_MLAT, 26 | SALT_MY, 27 | STATS_URL, 28 | ) 29 | 30 | 31 | class Base: ... 32 | 33 | 34 | class Provider(Base): 35 | def __init__(self, enabled_bg_tasks): 36 | self.aircrafts = {} 37 | self.beast_clients = list() 38 | self.beast_receivers = [] 39 | self.mlat_sync_json = {} 40 | self.mlat_totalcount_json = {} 41 | self.mlat_clients = {} 42 | self.aircraft_totalcount = 0 43 | self.ReAPI = ReAPI(REAPI_ENDPOINT) 44 | self.resolver = None 45 | self.redis = None 46 | self.redis_connection_string = None 47 | self.bg_tasks = [ 48 | {"name": "fetch_hub_stats", "task": self.fetch_hub_stats, "instance": None}, 49 | {"name": "fetch_ingest", "task": self.fetch_ingest, "instance": None}, 50 | {"name": "fetch_mlat", "task": self.fetch_mlat, "instance": None}, 51 | ] 52 | self.enabled_bg_tasks = enabled_bg_tasks 53 | 54 | async def startup(self): 55 | self.redis = await redis.from_url(self.redis_connection_string) 56 | self.client_session = aiohttp.ClientSession( 57 | raise_for_status=True, 58 | timeout=aiohttp.ClientTimeout(total=5.0, connect=1.0, sock_connect=1.0), 59 | ) 60 | self.resolver = aiodns.DNSResolver() 61 | for task in self.bg_tasks: 62 | if task["name"] not in self.enabled_bg_tasks: 63 | continue 64 | task["instance"] = asyncio.create_task(task["task"]()) 65 | print(f"Started background task {task['name']}") 66 | 67 | async def shutdown(self): 68 | for task in self.bg_tasks: 69 | if task["instance"] is not None: 70 | task["instance"].cancel() 71 | await task["instance"] 72 | 73 | await self.client_session.close() 74 | 75 | # since there are multiple instances of the same API, we lock certain redis operations 76 | # we lock it to the hostname, because it's unique 77 | # we expire it after 10 seconds, because we don't want to lock it forever 78 | # each lock has a name (hub_stats, ingest, mlat, ...) 79 | # the lock returns True if it was able to lock it OR if it is locked by the same hostname 80 | # the lock returns False if it is locked by another hostname 81 | 82 | async def fetch_hub_stats(self): 83 | try: 84 | while True: 85 | try: 86 | async with self.client_session.get(STATS_URL) as resp: 87 | data = await resp.json() 88 | self.aircraft_totalcount = data["aircraft_with_pos"] 89 | await asyncio.sleep(10) 90 | except Exception as e: 91 | traceback.print_exc() 92 | print("Error fetching stats, retry in 10s:", e) 93 | await asyncio.sleep(10) 94 | except asyncio.CancelledError: 95 | print("Background task cancelled") 96 | 97 | async def fetch_ingest(self): 98 | try: 99 | while True: 100 | try: 101 | ips = [ 102 | record.host 103 | for record in (await self.resolver.query(INGEST_DNS, "A")) 104 | ] 105 | clients, receivers = [], [] 106 | # beast update 107 | for ip in ips: 108 | url = f"http://{ip}:{INGEST_HTTP_PORT}/" 109 | 110 | async with self.client_session.get( 111 | url + "clients.json" 112 | ) as resp: 113 | data = await resp.json() 114 | clients += data["clients"] 115 | # print(len(clients), "clients") 116 | 117 | async with self.client_session.get( 118 | url + "receivers.json" 119 | ) as resp: 120 | data = await resp.json() 121 | pipeline = self.redis.pipeline() 122 | for receiver in data["receivers"]: 123 | key = f"receiver:{receiver[0]}" 124 | pipeline = pipeline.set( 125 | key, orjson.dumps(receiver), ex=60 126 | ) 127 | # set the humanhashy of salted my uuid 128 | my_humanhashy = self._humanhashy(receiver[0], SALT_MY) 129 | pipeline = pipeline.set( 130 | f"my:{my_humanhashy}", receiver[0], ex=60 131 | ) 132 | lat, lon = round(receiver[8], 1), round(receiver[9], 1) 133 | receivers.append([receiver[0], lat, lon]) 134 | await pipeline.execute() 135 | 136 | await self.set_beast_clients(clients) 137 | self.beast_receivers = receivers 138 | 139 | await asyncio.sleep(5) 140 | except Exception as e: 141 | traceback.print_exc() 142 | print("Error fetching ingest, retry in 10s:", e) 143 | await asyncio.sleep(10) 144 | except asyncio.CancelledError: 145 | print("Background task cancelled") 146 | 147 | async def fetch_mlat(self): 148 | try: 149 | while True: 150 | try: 151 | data_per_server = {} 152 | updated_at_per_server = {} 153 | for server in MLAT_SERVERS: 154 | # server is "mlat-mlat-server-0a" 155 | # let's take just 0a and make it uppercase 156 | this = server.split("-")[-1].upper() 157 | async with self.client_session.get( 158 | f"http://{server}:150/sync.json" 159 | ) as resp: 160 | data_per_server[this] = self.anonymize_mlat_data( 161 | await resp.json() 162 | ) 163 | if modified := resp.headers.get("Last-Modified"): 164 | updated_at_per_server[this] = parsedate_to_datetime( 165 | modified 166 | ).timestamp() 167 | self.mlat_totalcount_json = { 168 | "UPDATED": datetime.now().strftime("%a %b %d %H:%M:%S UTC %Y"), 169 | } 170 | for this, data in data_per_server.items(): 171 | updated_at = updated_at_per_server.get(this, 0) 172 | self.mlat_sync_json[this] = data 173 | self.mlat_totalcount_json[this] = [len(data), 1337, updated_at] 174 | 175 | # now, we take care of the clients 176 | SENSITIVE_clients = {} 177 | # we put for each server the clients 178 | for server in MLAT_SERVERS: 179 | this = server.split("-")[-1].upper() 180 | async with self.client_session.get( 181 | f"http://{server}:150/clients.json" 182 | ) as resp: 183 | data = await resp.json() 184 | SENSITIVE_clients[this] = data 185 | self.mlat_clients = SENSITIVE_clients 186 | 187 | await asyncio.sleep(5) 188 | except Exception as e: 189 | traceback.print_exc() 190 | print("Error in fetching mlat, retry in 10s:", e) 191 | await asyncio.sleep(10) 192 | except asyncio.CancelledError: 193 | print("Background task cancelled") 194 | 195 | async def set_beast_clients(self, client_rows): 196 | """Deduplicating setter.""" 197 | clients = {} 198 | 199 | for client in client_rows: 200 | my_url = ( 201 | "https://" + self._humanhashy(client[0][:18], SALT_MY) + ".my.adsb.lol" 202 | ) 203 | clients[(client[0], client[1].split()[1])] = { # deduplicate by hex and ip 204 | # "adsblol_beast_id": self.salty_uuid(client[0], SALT_BEAST), 205 | # "adsblol_beast_hash": self._humanhashy(client[0], SALT_BEAST), 206 | "uuid": client[0][:13] + "-...", 207 | "_uuid": client[0], 208 | "adsblol_my_url": my_url, 209 | "ip": client[1].split()[1], 210 | "kbps": client[2], 211 | "connected_seconds": client[3], 212 | "messages_per_second": client[4], 213 | "positions_per_second": client[5], 214 | "positions": client[8], 215 | "ms": client[7], 216 | } 217 | 218 | self.beast_clients = clients.values() 219 | 220 | # def try_updating_redis_entry(self, key, value, salt, expiry=60): 221 | # """ 222 | # Try to update redis entry with key and salt. 223 | # """ 224 | # try: 225 | # key = key + ":" + value 226 | # self.redis.set(key, self.salty_uuid(key, salt), ex=expiry) 227 | # except Exception as e: 228 | # print("Error updating redis entry:", e) 229 | 230 | def mlat_clients_to_list(self, ip=None): 231 | """ 232 | Return mlat clients with specified ip. 233 | """ 234 | clients_list = [] 235 | keys_to_copy = [ 236 | "user", 237 | "privacy", 238 | "connection", 239 | "peer_count", 240 | "bad_sync_timeout", 241 | "outlier_percent", 242 | ] 243 | # format of mlat_clients: 244 | # { "0A": {"user": {data}}, "0B": {"user": {data}} 245 | for server, data in self.mlat_clients.items(): 246 | # for name, client in self.mlat_clients.items(): 247 | for name, client in data.items(): 248 | if ip is not None and client["source_ip"] == ip: 249 | clients_list.append( 250 | {key: client[key] for key in keys_to_copy if key in client} 251 | ) 252 | # for uuid, special handle because it's a list OR a string. 253 | try: 254 | if isinstance(client["uuid"], list): 255 | clients_list[-1]["uuid"] = ( 256 | client["uuid"][0][:13] + "-..." 257 | if client["uuid"] 258 | else None 259 | ) 260 | elif isinstance(client["uuid"], str): 261 | clients_list[-1]["uuid"] = ( 262 | client["uuid"][:13] + "-..." if client["uuid"] else None 263 | ) 264 | else: 265 | clients_list[-1]["uuid"] = None 266 | except: 267 | clients_list[-1]["uuid"] = None 268 | return clients_list 269 | 270 | def anonymize_mlat_data(self, data): 271 | sanitized_data = {} 272 | for name, value in data.items(): 273 | sanitised_peers = {} 274 | for peer, peer_value in value["peers"].items(): 275 | sanitised_peers[self.maybe_salty_uuid(peer, SALT_MLAT)] = peer_value 276 | 277 | sanitized_data[self.maybe_salty_uuid(name, SALT_MLAT)] = { 278 | "lat": value["lat"], 279 | "lon": value["lon"], 280 | "bad_syncs": value.get("bad_syncs", -1), 281 | "peers": sanitised_peers, 282 | } 283 | 284 | return sanitized_data 285 | 286 | def get_clients_per_client_ip(self, ip: str, hide: bool = True) -> list: 287 | """ 288 | Return Beast clients with specified ip. 289 | :param ip: IP address to filter on. 290 | :param hide: Whether to keys that starts with _. 291 | """ 292 | ret = [client for client in self.beast_clients if client["ip"] == ip] 293 | if hide: 294 | ret = [ 295 | {k: v for k, v in client.items() if not k.startswith("_")} 296 | for client in ret 297 | ] 298 | # sort by uuid 299 | ret.sort(key=lambda x: x["uuid"]) 300 | return ret 301 | 302 | def get_clients_per_key_name(self, key_name: str, value: str) -> list: 303 | """ 304 | Return Beast clients with specified key name. 305 | """ 306 | return [client for client in self.beast_clients if client[key_name] == value] 307 | 308 | @lru_cache(maxsize=1024) 309 | def salty_uuid(self, original_uuid: str, salt: str) -> str: 310 | salted_bytes = original_uuid.encode() + salt.encode() 311 | hashed_bytes = hashlib.sha3_256(salted_bytes).digest() 312 | return str(uuid.UUID(bytes=hashed_bytes[:16])) 313 | 314 | def maybe_salty_uuid(self, string_that_might_contain_uuid: str, salt: str) -> str: 315 | # If the string is a UUID, return a salty UUID 316 | # If the string contains an UUID, return a salty UUID of the UUID, but with the rest of the string 317 | try: 318 | return self.salty_uuid(str(uuid.UUID(string_that_might_contain_uuid)), salt) 319 | except: 320 | try: 321 | return re.sub( 322 | r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", 323 | lambda match: self.salty_uuid(str(uuid.UUID(match.group(1))), salt), 324 | string_that_might_contain_uuid, 325 | ) 326 | except: 327 | return string_that_might_contain_uuid 328 | 329 | @lru_cache(maxsize=1024) 330 | def _humanhashy(self, original_uuid: str, salt: str = None, words: int = 4) -> str: 331 | """ 332 | Return a human readable hash of a UUID. The salt is optional. 333 | """ 334 | if salt: 335 | original_uuid = self.salty_uuid(original_uuid, salt) 336 | # print("humanhashy", original_uuid, salt) 337 | return humanhash.humanize(original_uuid.replace("-", ""), words=words) 338 | 339 | 340 | class RedisVRS(Base): 341 | def __init__(self, redis=None): 342 | self.redis_connection_string = redis 343 | self.redis = None 344 | self.background_task = None 345 | 346 | async def shutdown(self): 347 | self.background_task.cancel() 348 | await self.background_task 349 | 350 | async def download_csv_to_import(self): 351 | print("vrsx download_csv_to_import") 352 | CSVS = { 353 | "route": "https://vrs-standing-data.adsb.lol/routes.csv.gz", 354 | "airport": "https://vrs-standing-data.adsb.lol/airports.csv.gz", 355 | } 356 | async with aiohttp.ClientSession() as session: 357 | for name, url in CSVS.items(): 358 | print("vrsx", name) 359 | # Download CSV 360 | async with session.get(url) as resp: 361 | if resp.status != 200: 362 | raise Exception(f"Unable to download {url}") 363 | # Decompress 364 | data = await resp.read() 365 | data = gzip.decompress(data).decode("utf-8") 366 | # Import to Redis! 367 | # upsert. key= name:column0, value=full row 368 | # make redis transaction 369 | pipeline = self.redis.pipeline() 370 | 371 | for row in data.splitlines(): 372 | key = f"vrs:{name}:{row.split(',')[0]}" 373 | pipeline = pipeline.set(key, row) 374 | print("vrsx y", len(pipeline)) 375 | await pipeline.execute() 376 | 377 | async def _background_task(self): 378 | try: 379 | while True: 380 | try: 381 | await self.download_csv_to_import() 382 | await asyncio.sleep(3600) 383 | except Exception as e: 384 | print("Error in background task, retry in 1800s:", e) 385 | await asyncio.sleep(1800) 386 | except asyncio.CancelledError: 387 | print("VRS Background task cancelled") 388 | 389 | async def dispatch_background_task(self): 390 | self.background_task = asyncio.create_task(self._background_task()) 391 | 392 | async def connect(self): 393 | print(self.redis_connection_string) 394 | self.redis = await redis.from_url(self.redis_connection_string) 395 | 396 | async def get_route(self, callsign): 397 | vrsroute = await self.redis.get(f"vrs:route:{callsign}") 398 | if vrsroute is None: 399 | # print("vrsx didn't have data on", callsign) 400 | ret = { 401 | "callsign": callsign, 402 | "number": "unknown", 403 | "airline_code": "unknown", 404 | "airport_codes": "unknown", 405 | "_airport_codes_iata": "unknown", 406 | "_airports": [], 407 | } 408 | return ret 409 | 410 | data = vrsroute.decode() 411 | # print("vrsx", callsign, data) 412 | _, code, number, airlinecode, airportcodes = data.split(",") 413 | ret = { 414 | "callsign": callsign, 415 | "number": number, 416 | "airline_code": airlinecode, 417 | "airport_codes": airportcodes, 418 | "_airport_codes_iata": airportcodes, 419 | "_airports": [], 420 | } 421 | # _airport_codes_iata converts ICAO to IATA if possible. 422 | for airport in ret["airport_codes"].split("-"): 423 | airport_data = await self.get_airport(airport) 424 | if not airport_data: 425 | continue 426 | if len(airport) == 4: 427 | # Get IATA if exists 428 | if len(airport_data["iata"]) == 3: 429 | ret["_airport_codes_iata"] = ret["_airport_codes_iata"].replace( 430 | airport, airport_data["iata"] 431 | ) 432 | ret["_airports"].append(airport_data) 433 | 434 | return ret 435 | 436 | async def get_airport(self, icao): 437 | data = await self.redis.get(f"vrs:airport:{icao}") 438 | if data is None: 439 | return None 440 | data = data.decode() 441 | # print("vrsx", icao, data) 442 | try: 443 | __, name, _, iata, location, countryiso2, lat, lon, alt_feet = list( 444 | csv.reader([data]) 445 | )[0] 446 | ret = { 447 | "name": name, 448 | "icao": icao, 449 | "iata": iata, 450 | "location": location, 451 | "countryiso2": countryiso2, 452 | "lat": float(lat), 453 | "lon": float(lon), 454 | "alt_feet": float(alt_feet), 455 | "alt_meters": float(round(int(alt_feet) * 0.3048, 2)), 456 | } 457 | except: 458 | # print(f"CSV-parsing: exception for {data}") 459 | ret = None 460 | return ret 461 | 462 | # Add callsign to cache 463 | async def cache_route(self, callsign: str, plausible, route): 464 | expiry = 1200 if plausible else 60 465 | value = orjson.dumps(route) 466 | await self.redis.set(f"vrs:routecache:{callsign}", value, ex=expiry) 467 | 468 | async def get_cached_route(self, callsign): 469 | value = await self.redis.get(f"vrs:routecache:{callsign}") 470 | if value: 471 | return orjson.loads(value) 472 | else: 473 | return None 474 | 475 | 476 | class FeederData(Base): 477 | def __init__(self, redis=None): 478 | self.redis_connection_string = redis 479 | self.redis = None 480 | self.background_task = None 481 | self.resolver = None 482 | self.client_session = None 483 | self.redis_aircrafts_updated_at = 0 484 | self.receivers_ingests_updated_at = 0 485 | self.ingest_aircrafts = {} 486 | 487 | async def connect(self): 488 | self.redis = await redis.from_url(self.redis_connection_string) 489 | self.resolver = aiodns.DNSResolver() 490 | self.client_session = aiohttp.ClientSession( 491 | raise_for_status=True, 492 | timeout=aiohttp.ClientTimeout(total=5.0, connect=1.0, sock_connect=1.0), 493 | ) 494 | 495 | async def shutdown(self): 496 | self.background_task.cancel() 497 | await self.background_task 498 | await self.client_session.close() 499 | 500 | async def _update_redis_aircrafts(self, ip): 501 | self.redis_aircrafts_updated_at = datetime.now().timestamp() 502 | pipeline = self.redis.pipeline() 503 | aircrafts = self.ingest_aircrafts[ip]["aircraft"] 504 | print(f"xxx updating redis aircrafts {ip} {len(aircrafts)}") 505 | for aircraft in aircrafts: 506 | pipeline = pipeline.set( 507 | f"ac:{ip}:{aircraft['hex']}", 508 | orjson.dumps(aircraft), 509 | ex=10, 510 | ) 511 | await pipeline.execute() 512 | 513 | async def _update_aircrafts(self, ip): 514 | try: 515 | # Update aircrafts list 516 | # If redis_aircrafts_updated_at is more than 1s ago, update it 517 | url = f"http://{ip}:{INGEST_HTTP_PORT}/" 518 | async with self.client_session.get(url + "aircraft.json") as resp: 519 | data = await resp.json() 520 | self.ingest_aircrafts[ip] = data 521 | 522 | # If redis_aircrafts_updated_at is more than 1s ago, update it 523 | # We do this by exiting here if it has been updated recently 524 | if datetime.now().timestamp() - self.redis_aircrafts_updated_at > 0.5: 525 | self.redis_aircrafts_updated_at = datetime.now().timestamp() 526 | # print("xxx trying to update redis aircrafts") 527 | await self._update_redis_aircrafts(ip) 528 | except: 529 | print("Error in _update_aircrafts", ip) 530 | traceback.print_exc() 531 | 532 | async def _background_task(self): 533 | # asnycio timeout, so it only runs for 10 secs then cancels 534 | while True: 535 | try: 536 | async with asyncio.timeout(10): 537 | await self._background_task_exc() 538 | except asyncio.CancelledError: 539 | print("FeederData background task cancelled") 540 | except Exception as e: 541 | print("FeederData background task error:", e) 542 | traceback.print_exc() 543 | await asyncio.sleep(5) 544 | 545 | async def _background_task_exc(self): 546 | ips = [record.host for record in (await self.resolver.query(INGEST_DNS, "A"))] 547 | receivers = 0 548 | receivers_ingests = {} 549 | for ip in list(self.ingest_aircrafts.keys()): 550 | if ip not in ips: 551 | del self.ingest_aircrafts[ip] 552 | for ip in ips: 553 | await self._update_aircrafts(ip) 554 | data = self.ingest_aircrafts[ip] 555 | pipeline = self.redis.pipeline() 556 | for aircraft in data["aircraft"]: 557 | for receiver in aircraft.get("recentReceiverIds", []): 558 | receivers += 1 559 | receivers_ingests[receiver] = ip 560 | # zadd to key with score=now, 561 | key = f"receiver_ac:{receiver}" 562 | pipeline = pipeline.zadd( 563 | key, 564 | {aircraft["hex"]: int(datetime.now().timestamp())}, 565 | ) 566 | pipeline = self._try_updating_receivers_ingests(pipeline, receivers_ingests) 567 | print("Pipeline: ", pipeline) 568 | await pipeline.execute() 569 | 570 | print("FeederData: Got data from", receivers, "receivers") 571 | 572 | await asyncio.sleep(0.1) 573 | 574 | def _try_updating_receivers_ingests(self, pipeline, receivers_ingests): 575 | if datetime.now().timestamp() - self.receivers_ingests_updated_at < 0.2: 576 | # ^ if it has been updated recently, don't update it 577 | return pipeline 578 | self.receivers_ingests_updated_at = datetime.now().timestamp() 579 | for receiver, ip in receivers_ingests.items(): 580 | pipeline = pipeline.zremrangebyscore( 581 | f"receiver_ac:{receiver}", 582 | -1, 583 | int(datetime.now().timestamp() - 60), 584 | ) 585 | pipeline = pipeline.expire(f"receiver_ac:{receiver}", 30) 586 | pipeline = pipeline.set( 587 | "receiver_ingest:" + receiver, 588 | ip, 589 | ex=30, 590 | ) 591 | return pipeline 592 | 593 | async def dispatch_background_task(self): 594 | self.background_task = asyncio.create_task(self._background_task()) 595 | 596 | async def get_aircraft(self, receiver): 597 | # get only last 10 seconds 598 | data = await self.redis.zrange( 599 | f"receiver_ac:{receiver}", 600 | -1, 601 | int(datetime.now().timestamp()), 602 | byscore=True, 603 | ) 604 | # print("get_aircraft", receiver, data) 605 | if not data: 606 | return None 607 | ret = [] 608 | if ingest := await self.redis.get("receiver_ingest:" + receiver): 609 | ingest = ingest.decode() 610 | else: 611 | print("error ingest not found") 612 | return ret 613 | 614 | for ac in data: 615 | ac = ac.decode() 616 | ac_data = await self.redis.get(f"ac:{ingest}:{ac}") 617 | if ac_data is None: 618 | continue 619 | ret.append(orjson.loads(ac_data.decode())) 620 | return ret 621 | -------------------------------------------------------------------------------- /src/adsb_api/utils/reapi.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import aiohttp 4 | import orjson 5 | 6 | 7 | class ReAPI: 8 | def __init__(self, host): 9 | self.host = host 10 | 11 | # allow alphanumeric + , + = + _ + . 12 | self.allowed = re.compile(r"^[a-zA-Z0-9,=_\.-]+$") 13 | 14 | def are_params_valid(self, params): 15 | for param in params: 16 | if not self.allowed.match(param): 17 | return False 18 | return True 19 | 20 | async def request(self, params, client_ip=None): 21 | if not self.are_params_valid(params): 22 | return {"error": "invalid params"} 23 | 24 | params.append("jv2") 25 | 26 | url = self.host + "?" + "&".join(params) 27 | log = {"ip": client_ip, "params": params, "url": url, "type": "reapi"} 28 | print(log) 29 | 30 | timeout = aiohttp.ClientTimeout(total=5.0, connect=1.0, sock_connect=1.0) 31 | async with aiohttp.ClientSession(timeout=timeout) as session: 32 | async with session.get(url) as response: 33 | return await response.text() 34 | 35 | 36 | if __name__ == "__main__": 37 | import asyncio 38 | 39 | async def main(): 40 | reapi = ReAPI("https://re-api.adsb.lol/re-api/") 41 | params = ["all", "jv2"] 42 | response = await reapi.request(params) 43 | print(response) 44 | 45 | asyncio.run(main()) 46 | -------------------------------------------------------------------------------- /src/adsb_api/utils/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | SALT_MY = os.environ.get("ADSBLOL_API_SALT_MY") 4 | SALT_MLAT = os.environ.get("ADSBLOL_API_SALT_MLAT") 5 | SALT_BEAST = os.environ.get("ADSBLOL_API_SALT_BEAST") 6 | INSECURE = os.getenv("ADSBLOL_INSECURE") is not None 7 | ENDPOINTS = os.getenv("ADSBLOL_ENDPOINTS", "").split(",") 8 | REDIS_HOST = os.getenv("ADSBLOL_REDIS_HOST", "redis://redis") 9 | REDIS_TTL = int(os.getenv("ADSBLOL_REDIS_TTL", "5")) 10 | REAPI_ENDPOINT = os.getenv( 11 | "ADSBLOL_REAPI_ENDPOINT", "http://reapi-readsb:30152/re-api/" 12 | ) 13 | INGEST_DNS = os.getenv( 14 | "ADSBLOL_INGEST_DNS", "ingest-readsb-headless.adsblol.svc.cluster.local" 15 | ) 16 | INGEST_HTTP_PORT = os.getenv("ADSBLOL_INGEST_HTTP_PORT", "150") 17 | STATS_URL = os.getenv("ADSBLOL_STATS_URL", "http://hub-readsb-green:150/stats.json") 18 | ENABLED_BG_TASKS = os.getenv( 19 | "ADSBLOL_ENABLED_BG_TASKS", "fetch_hub_stats,fetch_ingest,fetch_mlat" 20 | ).split(",") 21 | 22 | MLAT_SERVERS = os.getenv( 23 | "ADSBLOL_MLAT_SERVERS", 24 | "mlat-mlat-server-0a,mlat-mlat-server-0b,mlat-mlat-server-0c", 25 | ).split(",") 26 | -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adsblol/api/9ea8630552befa96871e81a4f6c52b7410341768/static/favicon.ico -------------------------------------------------------------------------------- /templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adsblol/api/9ea8630552befa96871e81a4f6c52b7410341768/templates/.gitkeep -------------------------------------------------------------------------------- /templates/mylocalip.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | My Local IP 4 | 5 | 6 |

My Local IP

7 |
    8 | {% for ip in ips %} 9 |
  • http://{{ ip }}:5000/
  • 10 | {% endfor %} 11 | {% if ips == [] %} 12 |
  • No IPs found
  • 13 | {% endif %} 14 |
15 | 16 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | # Gotta do this in order for test to work when FastAPI cache is being used 4 | mock.patch("fastapi_cache.decorator.cache", lambda *args, **kwargs: lambda f: f).start() 5 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aioresponses import aioresponses 4 | from fastapi.testclient import TestClient 5 | 6 | from adsb_api.app import app 7 | from adsb_api.utils.models import V2Response_Model, V2Response_AcItem 8 | 9 | 10 | mocked_happy_V2Response_Model = V2Response_Model( 11 | ac=[ 12 | V2Response_AcItem( 13 | hex="f1337", 14 | messages=1, 15 | mlat=["x"], 16 | rssi=0.1, 17 | seen=0.1, 18 | tisb=["y"], 19 | type="A321", 20 | ) 21 | ], 22 | ctime=1700000000, 23 | msg="No error", 24 | now=1700000000, 25 | ptime=1700000000, 26 | total=1, 27 | ) 28 | 29 | 30 | @pytest.fixture 31 | def mock_happy_reapi(): 32 | """Mocks the ReAPI service which is external to this project.""" 33 | with aioresponses() as mock: 34 | mock.get( 35 | "http://reapi-readsb:30152/re-api/?all&jv2", 36 | body=mocked_happy_V2Response_Model.json(), 37 | ) 38 | 39 | mock.get( 40 | "http://reapi-readsb:30152/re-api/?all&filter_squawk=1200&jv2", 41 | body=mocked_happy_V2Response_Model.json(), 42 | ) 43 | mock.get( 44 | "http://reapi-readsb:30152/re-api/?filter_squawk=1200", 45 | body=mocked_happy_V2Response_Model.json(), 46 | ) 47 | 48 | mock.get( 49 | "http://reapi-readsb:30152/re-api/?find_type=A320&jv2", 50 | body=mocked_happy_V2Response_Model.json(), 51 | ) 52 | mock.get( 53 | "http://reapi-readsb:30152/re-api/?find_type=A320", 54 | body=mocked_happy_V2Response_Model.json(), 55 | ) 56 | 57 | mock.get( 58 | "http://reapi-readsb:30152/re-api/?find_reg=G-KELS&jv2", 59 | body=mocked_happy_V2Response_Model.json(), 60 | ) 61 | mock.get( 62 | "http://reapi-readsb:30152/re-api/?find_reg=G-KELS", 63 | body=mocked_happy_V2Response_Model.json(), 64 | ) 65 | 66 | mock.get( 67 | "http://reapi-readsb:30152/re-api/?find_hex=4CA87C&jv2'", 68 | body=mocked_happy_V2Response_Model.json(), 69 | ) 70 | mock.get( 71 | "http://reapi-readsb:30152/re-api/?find_hex=4CA87C", 72 | body=mocked_happy_V2Response_Model.json(), 73 | ) 74 | 75 | mock.get( 76 | "http://reapi-readsb:30152/re-api/?find_callsign=JBU1942&jv2", 77 | body=mocked_happy_V2Response_Model.json(), 78 | ) 79 | mock.get( 80 | "http://reapi-readsb:30152/re-api/?find_callsign=JBU1942", 81 | body=mocked_happy_V2Response_Model.json(), 82 | ) 83 | 84 | mock.get( 85 | "http://reapi-readsb:30152/re-api/?circle=10.0,50.0,250&jv2", 86 | body=mocked_happy_V2Response_Model.json(), 87 | ) 88 | mock.get( 89 | "http://reapi-readsb:30152/re-api/?circle=10.0,50.0,250", 90 | body=mocked_happy_V2Response_Model.json(), 91 | ) 92 | 93 | yield mock 94 | 95 | 96 | @pytest.fixture 97 | def test_client(): 98 | return TestClient(app) 99 | 100 | 101 | @pytest.mark.asyncio 102 | async def test_v2_all(mock_happy_reapi, test_client): 103 | response = test_client.get("/v2/all") 104 | resp = response.json() 105 | 106 | assert len(resp["ac"]) > 0, "No aircraft in response" 107 | assert resp["msg"] == "No error" 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_v2_pia(mock_happy_reapi, test_client): 112 | response = test_client.get("/v2/pia") 113 | resp = response.json() 114 | 115 | assert len(resp["ac"]) > 0, "No aircraft in response" 116 | assert resp["msg"] == "No error" 117 | 118 | 119 | @pytest.mark.asyncio 120 | async def test_v2_mil(mock_happy_reapi, test_client): 121 | response = test_client.get("/v2/mil") 122 | resp = response.json() 123 | 124 | assert len(resp["ac"]) > 0, "No aircraft in response" 125 | assert resp["msg"] == "No error" 126 | 127 | 128 | @pytest.mark.asyncio 129 | async def test_v2_ladd(mock_happy_reapi, test_client): 130 | response = test_client.get("/v2/ladd") 131 | resp = response.json() 132 | 133 | assert len(resp["ac"]) > 0, "No aircraft in response" 134 | assert resp["msg"] == "No error" 135 | 136 | 137 | @pytest.mark.asyncio 138 | async def test_v2_squawk(mock_happy_reapi, test_client): 139 | response = test_client.get("/v2/sqk/1200") 140 | resp = response.json() 141 | 142 | assert len(resp["ac"]) > 0, "No aircraft in response" 143 | assert resp["msg"] == "No error" 144 | 145 | response = test_client.get("/v2/squawk/1200") 146 | resp = response.json() 147 | 148 | assert len(resp["ac"]) > 0, "No aircraft in response" 149 | assert resp["msg"] == "No error" 150 | 151 | 152 | @pytest.mark.asyncio 153 | async def test_v2_aircarft_type(mock_happy_reapi, test_client): 154 | response = test_client.get("/v2/type/A320") 155 | resp = response.json() 156 | 157 | assert len(resp["ac"]) > 0, "No aircraft in response" 158 | assert resp["msg"] == "No error" 159 | 160 | 161 | @pytest.mark.asyncio 162 | async def test_v2_registration(mock_happy_reapi, test_client): 163 | response = test_client.get("/v2/reg/G-KELS") 164 | resp = response.json() 165 | 166 | assert resp["msg"] == "No error" 167 | 168 | response = test_client.get("/v2/registration/G-KELS") 169 | resp = response.json() 170 | 171 | assert resp["msg"] == "No error" 172 | 173 | 174 | @pytest.mark.asyncio 175 | async def test_v2_icao(mock_happy_reapi, test_client): 176 | response = test_client.get("/v2/icao/4CA87C") 177 | resp = response.json() 178 | 179 | assert resp["msg"] == "No error" 180 | 181 | response = test_client.get("/v2/hex/4CA87C") 182 | resp = response.json() 183 | 184 | assert resp["msg"] == "No error" 185 | 186 | 187 | @pytest.mark.asyncio 188 | async def test_v2_callsign(mock_happy_reapi, test_client): 189 | response = test_client.get("/v2/callsign/JBU1942") 190 | resp = response.json() 191 | 192 | assert resp["msg"] == "No error" 193 | 194 | 195 | @pytest.mark.asyncio 196 | async def test_v2_radius(mock_happy_reapi, test_client): 197 | response = test_client.get("/v2/lat/10/lon/50/dist/500") 198 | resp = response.json() 199 | 200 | assert len(resp["ac"]) > 0, "No aircraft in response" 201 | assert resp["msg"] == "No error" 202 | 203 | response = test_client.get("/v2/point/10/50/500") 204 | resp = response.json() 205 | 206 | assert len(resp["ac"]) > 0, "No aircraft in response" 207 | assert resp["msg"] == "No error" 208 | 209 | 210 | @pytest.mark.asyncio 211 | async def test_api_me(test_client): 212 | response = test_client.get("/api/0/me") 213 | resp = response.json() 214 | 215 | assert "clients" in resp.keys() 216 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import aiohttp 3 | 4 | 5 | @pytest.fixture 6 | def test_client(): 7 | return aiohttp.ClientSession( 8 | headers={"User-agent": "adsblol integration test"}, raise_for_status=True 9 | ) 10 | 11 | 12 | HOST = "https://api.adsb.lol" 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_v2_all(test_client): 17 | async with test_client.get(f"{HOST}/v2/all") as response: 18 | resp = await response.json() 19 | 20 | assert len(resp["ac"]) > 0, "No aircraft in response" 21 | assert resp["msg"] == "No error" 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_v2_pia(test_client): 26 | async with test_client.get(f"{HOST}/v2/pia") as response: 27 | resp = await response.json() 28 | 29 | assert len(resp["ac"]) > 0, "No aircraft in response" 30 | assert resp["msg"] == "No error" 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_v2_mil(test_client): 35 | async with test_client.get(f"{HOST}/v2/mil") as response: 36 | resp = await response.json() 37 | 38 | assert len(resp["ac"]) > 0, "No aircraft in response" 39 | assert resp["msg"] == "No error" 40 | 41 | 42 | @pytest.mark.asyncio 43 | async def test_v2_ladd(test_client): 44 | async with test_client.get(f"{HOST}/v2/ladd") as response: 45 | resp = await response.json() 46 | 47 | assert len(resp["ac"]) > 0, "No aircraft in response" 48 | assert resp["msg"] == "No error" 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_v2_squawk(test_client): 53 | async with test_client.get(f"{HOST}/v2/sqk/1200") as response: 54 | resp = await response.json() 55 | 56 | assert len(resp["ac"]) > 0, "No aircraft in response" 57 | assert resp["msg"] == "No error" 58 | 59 | async with test_client.get(f"{HOST}/v2/squawk/1200") as response: 60 | resp = await response.json() 61 | 62 | assert len(resp["ac"]) > 0, "No aircraft in response" 63 | assert resp["msg"] == "No error" 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_v2_aircarft_type(test_client): 68 | async with test_client.get(f"{HOST}/v2/type/A320") as response: 69 | resp = await response.json() 70 | 71 | assert len(resp["ac"]) > 0, "No aircraft in response" 72 | assert resp["msg"] == "No error" 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_v2_registration(test_client): 77 | async with test_client.get(f"{HOST}/v2/reg/G-KELS") as response: 78 | resp = await response.json() 79 | 80 | assert resp["msg"] == "No error" 81 | 82 | async with test_client.get(f"{HOST}/v2/registration/G-KELS") as response: 83 | resp = await response.json() 84 | 85 | assert resp["msg"] == "No error" 86 | 87 | 88 | @pytest.mark.asyncio 89 | async def test_v2_icao(test_client): 90 | async with test_client.get(f"{HOST}/v2/icao/4CA87C") as response: 91 | resp = await response.json() 92 | 93 | assert resp["msg"] == "No error" 94 | 95 | async with test_client.get(f"{HOST}/v2/hex/4CA87C") as response: 96 | resp = await response.json() 97 | 98 | assert resp["msg"] == "No error" 99 | 100 | 101 | @pytest.mark.asyncio 102 | async def test_v2_callsign(test_client): 103 | async with test_client.get(f"{HOST}/v2/callsign/JBU1942") as response: 104 | resp = await response.json() 105 | 106 | assert resp["msg"] == "No error" 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_v2_radius(test_client): 111 | async with test_client.get(f"{HOST}/v2/lat/10/lon/50/dist/500") as response: 112 | resp = await response.json() 113 | 114 | assert len(resp["ac"]) > 0, "No aircraft in response" 115 | assert resp["msg"] == "No error" 116 | 117 | async with test_client.get(f"{HOST}/v2/point/10/50/500") as response: 118 | resp = await response.json() 119 | 120 | assert len(resp["ac"]) > 0, "No aircraft in response" 121 | assert resp["msg"] == "No error" 122 | 123 | 124 | @pytest.mark.asyncio 125 | async def test_api_me(test_client): 126 | async with test_client.get(f"{HOST}/api/0/me") as response: 127 | resp = await response.json() 128 | 129 | assert "clients" in resp.keys() 130 | --------------------------------------------------------------------------------