Skip to content

Commit

Permalink
Merge API system refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
sco1 committed Nov 17, 2023
2 parents f12c528 + 297bc9a commit 935ec1c
Show file tree
Hide file tree
Showing 26 changed files with 631 additions and 280 deletions.
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ extend-ignore=
extend-exclude=
.venv,
per-file-ignores =
tests/test_*.py:D103 E501,
skyportal/networklib.py:E731,
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
# Changelog
Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (`<major>`.`<minor>`.`<patch>`)

## [vNext]
### Added
* #14 Add support for the ADSB.lol API
* #14 Add support for a generic flight data API

### Changed
* (Internal) Refactor API handlers to share a common base class

### Removed
* #11 Remove screenshot UI feature

## [v1.1.0]
### Added
* #3 Add optional screenshot UI target, enabled using the `SHOW_SCREENSHOT_BUTTON` config var
Expand Down
65 changes: 51 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# skyportal
A PyPortal based flight tracker powered by [Adafruit](https://io.adafruit.com/), [Geoapify](https://www.geoapify.com/), and [The OpenSky Network](https://opensky-network.org/).
A PyPortal based flight tracker powered by [Adafruit](https://io.adafruit.com/), [Geoapify](https://www.geoapify.com/), [ADSB.lol](https://adsb.lol), and [The OpenSky Network](https://opensky-network.org/).

Heavily inspired by Bob Hammell"s PyPortal Flight Tracker ([GH](https://github.com/rhammell/pyportal-flight-tracker), [Tutorial](https://www.hackster.io/rhammell/pyportal-flight-tracker-0be6b0#story)).

Expand Down Expand Up @@ -50,24 +50,61 @@ secrets = {
"aio_username" : "YOUR_AIO_USERNAME",
"aio_key" : "YOUR_AIO_KEY",
# Open Sky Network credentials, for getting flight information
# Can be omitted if not using OpenSky
"opensky_username": "YOUR_OPENSKY_USERNAME",
"opensky_password": "YOUR_OPENSKY_PASSWORD"
"opensky_password": "YOUR_OPENSKY_PASSWORD",
# Proxy API Gateway credentials
# Can be omitted if not using a proxy server
"proxy_api_url": "YOUR_PROXY_API_URL",
"proxy_api_key": "YOUR_PROXY_API_KEY",
}
```

#### Skyportal Configuration
A collection of functionality-related constants is specified in `skyportal_config.py`, which can be adjusted to suit your needs:

| Variable Name | Description | Default |
|----------------------------|-------------------------------------------------------|----------|
| `SHOW_SCREENSHOT_BUTTON` | Provide a UI button for taking screenshots | `False` |
| `KEEP_N_SCREENSHOTS` | Keep the `n` most recent screenshots in SD storage | `5` |
| `USE_DEFAULT_MAP` | Use the default map image rather than query Geoapify | `False` |
| `MAP_CENTER_LAT` | Map center latitude, decimal degrees | `42.41` |
| `MAP_CENTER_LON` | Map center longitude, deimal degrees | `-71.17` |
| `GRID_WIDTH_MI` | Map grid width, miles | `15` |
| `SKIP_GROUND` | Skip drawing aircraft on the ground | `True` |
| `GEO_ALTITUDE_THRESHOLD_M` | Skip drawing aircraft below this GPS altitude, meters | `20` |
| Variable Name | Description | Default |
|----------------------------|-------------------------------------------------------|-----------|
| `USE_DEFAULT_MAP` | Use the default map image rather than query Geoapify | `False` |
| `MAP_CENTER_LAT` | Map center latitude, decimal degrees | `42.41` |
| `MAP_CENTER_LON` | Map center longitude, deimal degrees | `-71.17` |
| `GRID_WIDTH_MI` | Map grid width, miles | `15` |
| `AIRCRAFT_DATA_SOURCE` | Aircraft State API to utilize<sup>1</sup> | `opensky` |
| `SKIP_GROUND` | Skip drawing aircraft on the ground | `True` |
| `GEO_ALTITUDE_THRESHOLD_M` | Skip drawing aircraft below this GPS altitude, meters | `20` |

**Notes:**
1. See [Data Sources](#data-sources) for valid options

## Data Sources
### OpenSky-Network - `"opensky"`
Query the [OpenSky Network](https://opensky-network.org/) API. This requires a user account to be created & credentials added to `secrets.py`.

Information on their REST API can be found [here](https://openskynetwork.github.io/opensky-api/rest.html).

### ADSB.lol - `"adsblol"`
Query the [ADSB.lol](https://adsb.lol/). This currently does not require user authentication.

Information on their REST API can be found [here](https://api.adsb.lol/docs).

**NOTE:** This API provides a lot of interesting information in the state vector provided for each aircraft. Depending on the level of congestion in your query area, may be more data than can fit into RAM (See: [Known Limitations](#known-limitations)).

### Proxy API - `"proxy"`
Query a user-specified proxy server using the URL and API key provided in `secrets.py`.

For authentication, the API is assumed to expect an key provided in the `"x-api-key"` header.

The proxy API is assumed to expect three parameters:
* `lat`, center latitude, decimal degrees
* `lon`, denter longitude, decimal degrees
* `radius`, search radius, miles

The proxy API is expected to return two parameters:
* `"ac"` - A list of state vectors, as dictionaries, whose kv pairs map directly to `skyportal.aircraftlib.AircraftState`
* `"api_time"` - UTC epoch time, seconds, may be a float

An example using ADSB.lol and AWS Lambda is provided by this repository in [`./adsblol-proxy`](./adsblol-proxy/README.md)


## Touchscreen Functionality
**NOTE:** Touchscreen input is mostly limited to one touch event per screen tap, rather than continuously firing while the screen is being touched.
Expand All @@ -77,5 +114,5 @@ A collection of functionality-related constants is specified in `skyportal_confi
### Aircraft Information
Tapping on an aircraft icon will display state information for the aircraft closest to the registered touch point.

### Screenshot
If enabled in the SkyPortal configuration file, a screenshot button is created in the lower left, allowing the user to take a screenshot to SD card storage. The device utilizes a rolling storage, keeping the `n` most recent screenshots and discarding the oldest screenshot if above this threshold.
## Known Limitations
The PyPortal is a highly memory constrained environment, which presents challenges when aiming to create a highly expressive UI. While every attempt is being made to minimize memory usage to keep the Skyportal functioning, the device may occasionally run out of memory. The most likely point for this to happen is when receiving the web request with aircraft state information from your API of choice. Depending on how congested your selected airspace is at query time, there may simply be too much information provided by the API for the device to handle & I've intentionally left the exception unhandled so it will crash the device. Should you find this ocurring often, you may be interested in setting up a proxy server to return only the information needed for the device to function, which can significantly alleviate the amount of RAM needed. See [Proxy API](#proxy-api---proxy) for more information.
98 changes: 98 additions & 0 deletions adsblol-proxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# ADSB.lol Proxy API
The enclosed `adsblol_proxy.py` file was developed to be deployed using an [AWS Lambda](https://aws.amazon.com/lambda/) behind an [AWS API Gateway](https://aws.amazon.com/api-gateway/). For the default Skyportal configuration this should fall well within the monthly limits of the AWS Free Tier.

If, like me, you've never used AWS before this point, the following steps should help you get up and running. If you know what you're doing then you can probably figure all this out without my help.

## AWS Lambda
### Create Function
From the Lambda console, create a new function with the following configuration:
* Author from scratch
* Whatever function name you want
* Python 3.11 Runtime
* x86_64 architecture

Once created, edit your Runtime Settings and change the Handler to `adsblol_proxy.lambda_handler`.

### Create a `.zip` deployment
Our Lambda depends on [`httpx`](https://github.com/encode/httpx/) to make its web request, so it will need to be installed along with its dependencies before we can deploy. One way to achieve this with Lambda is to upload a [`.zip` deployment package](https://docs.aws.amazon.com/lambda/latest/dg/python-package.html#python-package-create-dependencies); the documentation can be a little obtuse but ultimately the goal is to end up with a zip file whose contents look something like this:

```
anyio/
anyio-4.0.0.dist-info/
certifi/
certifi-2023.7.22.dist-info/
h11/
h11-0.14.0.dist-info/
httpcore/
httpcore-1.0.2.dist-info/
httpx/
httpx-0.25.1.dist-info/
idna/
idna-3.4.dist-info/
sniffio/
sniffio-1.3.0.dist-info/
adsblol_proxy.py
```

I accomplished this using a virtual environment, e.g.:

```
$ python -m venv ./.venv
$ source ./.venv/Scripts/activate
$ python -m pip install -U pip httpx
```

Move or copy the everything from `./.venv/Lib/site-packages` **EXCEPT** `pip` (blows up the file size unnecessarily & we don't need it) into the directory with `adsblol_proxy.py` and zip everything together so you get the layout above. You can then upload this zip file to Lambda & then deploy the code.

## AWS API Gateway
### Create API
From the API Gateway console, create a new API:
* REST API
* New API
* Whatever name you'd like
* Optional description
* Regional endpoint type

### Create Method
Under resources, create a new method:
* `GET` method type
* Lambda function integration type
* Enable "Lambda proxy integration"
* If you've already created your Lambda function above, you should be able to select it
* Default timeout should be fine

### Edit Method
Edit your method request settings:
* Authorization - None
* Request validator - None
* API key required - CHECK
* URL query string parameters
* `lat`, required
* `lon`, required
* `radius`, required

Once this is done, Deploy your API. You will need to specify a stage name if you haven't previously. Since you're just doing a hobby API you can name it whatever you want; I called mine `live`.

Make a note of your Invoke URL, which can be found under Stages. It will be something like `https://abcd123.execute-api.us-east-69.amazonaws.com/live/`.

### Create an API key
Under API Keys create a new API key & store in a secure location that you can access later.

### Create a Usage Plan
This must be created in order for the API key to work. Fill out the options however you'd like.

Once this is created you'll need to add a stage, this is what you targeted when you deployed your API.

Finally, you'll need to add your API key to the Associated API keys.

## Testing
You can check that your API is functional using `curl`:

```
$ curl --location "https://abcd123.execute-api.us-east-69.amazonaws.com/live/?lat=42.41&lon=-71.17&radius=30" --header "x-api-key:<key>"
```

Which should give back some aircraft data.

## Configuring Skyportal
To utilize the proxy server, copy your Invoke URL and API key into `secrets.py`, and set `AIRCRAFT_DATA_SOURCE = "proxy"` in your `skyportal_config.py`.
91 changes: 91 additions & 0 deletions adsblol-proxy/adsblol_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import json

import httpx

URL_BASE = "https://api.adsb.lol/v2"


def query_data(lat: float, lon: float, radius: float) -> dict:
"""Execute the desired ADSB.lol query & return the JSON response."""
query_url = f"{URL_BASE}/lat/{lat}/lon/{lon}/dist/{radius}"
r = httpx.get(query_url)
r.raise_for_status()

return r.json()


def simplify_aircraft(flight_data: list[dict]) -> list[dict]:
"""
Simplify the ADSB.lol API response into something more Skyportal memory friendly.
The following cleanup operations are made on the provided aircraft state vectors:
* All non-airborne flights are discarded
* Keys are reorganized into a layout that directly matches `skyportal.AircraftState`
* Value units are converted, where necessary
* Missing keys are set to `None` if expected to be present
"""
airborne_flights = []
for state_vector in flight_data:
if (baro_alt := state_vector["alt_baro"]) == "ground":
# Skip airborne aircraft
continue

if (callsign := state_vector.get("flight", None)) is None:
callsign = state_vector.get("r", None)
if callsign is not None:
callsign = callsign.strip()

# Ground track is likely not transmitted on the ground
# If an aircraft is on the ground it may be transmitting true_heading
if (track := state_vector.get("track", None)) is None:
track = state_vector.get("true_heading", None)

if (alt_geo := state_vector.get("alt_geom", None)) is not None:
alt_geo *= 0.3048 # Provided in ft

if (baro_rate := state_vector.get("baro_rate", None)) is not None:
baro_rate *= 0.3048 # Provided in ft

simplified_vector = {
"icao": state_vector["hex"],
"callsign": callsign,
"lat": state_vector["lat"],
"lon": state_vector["lon"],
"track": track,
"velocity_mps": state_vector["gs"] * 0.5144, # Provided in kts
"on_ground": False,
"baro_altitude_m": baro_alt,
"geo_altitude_m": alt_geo,
"vertical_rate_mps": baro_rate,
"aircraft_category": state_vector.get("category", "NO_INFO"),
}

airborne_flights.append(simplified_vector)

return airborne_flights


def lambda_handler(event, context) -> dict: # noqa: ANN001 D103
try:
params = event["queryStringParameters"]
full_data = query_data(lat=params["lat"], lon=params["lon"], radius=params["radius"])
except httpx.HTTPError as e:
return {
"statusCode": 400,
"body": json.dumps(f"HTTP Exception for {e.request.url} - {e}"),
}
except KeyError as e:
return {
"statusCode": 400,
"body": json.dumps(str(e)),
}

return {
"statusCode": 200,
"body": json.dumps(
{
"ac": simplify_aircraft(full_data["ac"]),
"api_time": full_data["now"] / 1000, # Server time given in milliseconds
}
),
}
Binary file removed assets/camera_green.bmp
Binary file not shown.
Binary file removed assets/camera_red.bmp
Binary file not shown.
Binary file removed assets/splash.bmp
Binary file not shown.
51 changes: 38 additions & 13 deletions code.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@

from skyportal.displaylib import SkyPortalUI
from skyportal.maplib import build_bounding_box
from skyportal.networklib import APIException, APITimeoutError
from skyportal.opensky import OpenSky
from skyportal.networklib import APIException, APIHandlerBase, APITimeoutError

try:
from secrets import secrets
Expand All @@ -22,7 +21,7 @@
raise Exception("Could not locate configuration file.") from e


def _utc_to_local(utc_timestamp: int, utc_offset: str = "-0000") -> datetime:
def _utc_to_local(utc_timestamp: float, utc_offset: str = "-0000") -> datetime:
"""
Convert the given timestamp into local time with the provided UTC offset.
Expand All @@ -38,7 +37,7 @@ def _utc_to_local(utc_timestamp: int, utc_offset: str = "-0000") -> datetime:

# Device Initialization
PYPORTAL = PyPortal() # This also takes care of mounting the SD to /sd
skyportal_ui = SkyPortalUI(enable_screenshot=skyportal_config.SHOW_SCREENSHOT_BUTTON)
skyportal_ui = SkyPortalUI()

PYPORTAL.network.connect()
print("Wifi connected")
Expand All @@ -51,32 +50,58 @@ def _utc_to_local(utc_timestamp: int, utc_offset: str = "-0000") -> datetime:
grid_bounds = build_bounding_box()
skyportal_ui.post_connect_init(grid_bounds)

opensky_handler = OpenSky(grid_bounds=grid_bounds)

api_handler: APIHandlerBase
if skyportal_config.AIRCRAFT_DATA_SOURCE == "adsblol":
from skyportal.networklib import ADSBLol

api_handler = ADSBLol(
lat=skyportal_config.MAP_CENTER_LAT,
lon=skyportal_config.MAP_CENTER_LON,
radius=skyportal_config.GRID_WIDTH_MI * 2,
)
print("Using ADSB.lol as aircraft data source")
elif skyportal_config.AIRCRAFT_DATA_SOURCE == "opensky":
from skyportal.networklib import OpenSky

api_handler = OpenSky(grid_bounds=grid_bounds)
print("Using OpenSky as aircraft data source")
elif skyportal_config.AIRCRAFT_DATA_SOURCE == "proxy":
from skyportal.networklib import ProxyAPI

api_handler = ProxyAPI(
lat=skyportal_config.MAP_CENTER_LAT,
lon=skyportal_config.MAP_CENTER_LON,
radius=skyportal_config.GRID_WIDTH_MI * 2,
)
print("Using proxy API as aircraft data source")
else:
raise ValueError(f"Unknown API specified: '{skyportal_config.AIRCRAFT_DATA_SOURCE}'")

gc.collect()
print(f"\n{'='*40}\nInitialization complete\n{'='*40}\n")

# Main loop
skyportal_ui.touch_on()
loop_start_time = datetime.now() - opensky_handler.refresh_interval # Force first API call
loop_start_time = datetime.now() - api_handler.refresh_interval # Force first API call
while True:
if (datetime.now() - loop_start_time) >= opensky_handler.refresh_interval:
if (datetime.now() - loop_start_time) >= api_handler.refresh_interval:
skyportal_ui.touch_off()
try:
opensky_handler.update()
api_handler.update()
except (APITimeoutError, APIException) as e:
print(e)

gc.collect()

if opensky_handler.can_draw():
if api_handler.can_draw:
print("Updating aircraft locations")
skyportal_ui.draw_aircraft(opensky_handler.aircraft)
skyportal_ui.time_label.text = f"{_utc_to_local(opensky_handler.api_time, utc_offset)}"
skyportal_ui.draw_aircraft(api_handler.aircraft)
skyportal_ui.time_label.text = f"{_utc_to_local(api_handler.api_time, utc_offset)}"
else:
print("No aircraft to draw, skipping redraw")

loop_start_time = datetime.now()
next_request_at = loop_start_time + opensky_handler.refresh_interval
next_request_at = loop_start_time + api_handler.refresh_interval
print(f"Sleeping... next refresh at {next_request_at} local")
skyportal_ui.touch_on()

Expand Down
Binary file removed lib/adafruit_ticks.mpy
Binary file not shown.
Binary file removed lib/asyncio/__init__.mpy
Binary file not shown.
Binary file removed lib/asyncio/core.mpy
Binary file not shown.
Binary file removed lib/asyncio/event.mpy
Binary file not shown.
Binary file removed lib/asyncio/funcs.mpy
Binary file not shown.
Binary file removed lib/asyncio/lock.mpy
Binary file not shown.
Binary file removed lib/asyncio/manifest.mpy
Binary file not shown.
Binary file removed lib/asyncio/stream.mpy
Binary file not shown.
Binary file removed lib/asyncio/task.mpy
Binary file not shown.
Loading

0 comments on commit 935ec1c

Please sign in to comment.