diff --git a/.env.mock b/.env.mock index aaaa8eb6..28db0f0f 100644 --- a/.env.mock +++ b/.env.mock @@ -1,12 +1,18 @@ +# ADSB providers AEROAPI_API_KEY= +AIRLABS_API_KEY= # BOUNDING BOX OF AREA TO CHECK # lower left -LAT_LOWER_LEFT=21.659 -LONG_LOWER_LEFT=-105.22 +LAT_LOWER_LEFT= +LONG_LOWER_LEFT= # upper right -LAT_UPPER_RIGHT=24.803 -LONG_UPPER_RIGHT=-102.194 +LAT_UPPER_RIGHT= +LONG_UPPER_RIGHT= # Push notifications -PUSH_BULLET_API_KEY= \ No newline at end of file +PUSH_BULLET_API_KEY= + +# Weather checking +OPENWEATHER_API_KEY= +CLOUD_COVER_THRESHOLD=85 \ No newline at end of file diff --git a/.github/workflows/clamav-scan.yml b/.github/workflows/clamav-scan.yml new file mode 100644 index 00000000..d021dab1 --- /dev/null +++ b/.github/workflows/clamav-scan.yml @@ -0,0 +1,53 @@ +name: ClamAV-Scan + +on: + pull_request: + branches: [main, dev] + +jobs: + clamav-scan: + runs-on: ubuntu-latest + steps: + - name: Checkout code from PR + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install ClamAV + run: | + sudo apt-get update + sudo apt-get install -y clamav clamav-daemon + + - name: Update hashes database + run: | + sudo systemctl stop clamav-freshclam || true + sudo freshclam + + - name: Get files added or updated + id: changed + run: | + git fetch origin ${{ github.base_ref }} --depth=1 + git diff --name-only origin/${{ github.base_ref }}...HEAD > changed-files.txt + cat changed-files.txt + + - name: Scan files + run: | + cat changed-files.txt | xargs -r clamscan --infected --alert-broken \ + 2>&1 | tee scan-results.txt + + - name: Verify results + run: | + if grep -q "Infected files: 0" scan-results.txt; then + echo "βœ… No threats found" + else + echo "🚨 Infected files were detected!" + grep "FOUND" scan-results.txt || true + exit 1 + fi + + - name: Upload report (when it has failed) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: virus-scan-report + path: scan-results.txt \ No newline at end of file diff --git a/.gitignore b/.gitignore index bd472af9..0852c1ce 100644 --- a/.gitignore +++ b/.gitignore @@ -164,9 +164,17 @@ cython_debug/ # data de421.bsp notes.txt +dev-notes.txt data/possible-transits/*.csv data/possible-transits/log-backup.txt +static/gallery/**/* + +# tests +tests/test_results.json # misc exp.ipynb -*.DS_Store \ No newline at end of file +*.DS_Store + +# VSCode +.vscode/* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..18f52657 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## Flymoon 2.0 +Released May 2026 + +Special thanks to **Tom Harnish** for his contributions, ideas, and feedback that shaped several features in this release. + +**What's new:** +1. Transit algorithm uses angular separation for a more accurate possibility level +2. Dual target tracking +3. Non-transit values now shown in the table +4. Optional weather check +5. Setup wizard +6. Headless monitor mode (no browser required) +7. Map view β€” aircraft positions, direction, and altitude +8. Support for Airlabs as an alternative ADS-B provider (1,000 free API calls/month) +9. Personal gallery to organize transit photos diff --git a/Makefile b/Makefile index b9aca6e5..7c9c2471 100644 --- a/Makefile +++ b/Makefile @@ -41,4 +41,12 @@ create-env: @$(CMD_CHECK_ENV) +run: + @( \ + $(CMD_ACTIVATE_VENV) || exit 1; \ + python3 app.py; \ + ) + + + setup: create-env install diff --git a/README.md b/README.md index 81e978ba..175d09c8 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,105 @@ # Flymoon -A web app to run locally on a LAN network that checks for possible transits over the Moon or the Sun (up to 15 minutes ahead). +A web app to run locally on a LAN network that checks for possible transits over the Moon or the Sun (up to several minutes ahead). -Get flight data from an existing API. +Get flight data from an existing ADSB provider API. -You need to set coordinates for an area to check flights as a bounding box, input your position, choose a target (Moon or Sun), and then the app will compute future flight positions and check intersections with the target, which is called a transit. +![](data/assets/transit_example.jpg) -![](data/assets/flymoon2.png) +You need to set coordinates for an area to check flights as a bounding box, input your position, choose a target (Moon, Sun or both), and then the app will compute future flight positions and check intersections with the target, which is called a transit. +![](data/assets/flymoon2-0-0.png) -The results show the difference in alt-azimuthal coordinates. Typically, you can expect a likely transit when there's no change in elevation and the difference in altitude (alt diff) and azimuth (az diff) is less than a few grades for both. In such cases, the row of results will be highlighted. Yellow 🟑: Low possibility; Orange 🟠: Medium possibility; Green 🟒: High possibility. +The results show the future and minimum angular separation from aircraft and the chosen target. Typically, you can expect a likely transit when there's expected a lower angular separation, no change in elevation and the difference in altitude (alt diff) and azimuth (az diff, both not in all cases) is less than a few degrees for both. In such cases, the row of results will be highlighted: +| Color | Possibility | +|---|---| +| 🟑 Yellow | Low | +| 🟠 Orange | Medium | +| 🟒 Green | High | + +**Main features** +1. Check transits on demand (user click on _Check_ button) +2. Check transits on Auto mode (each X chosen minutes the app checks, send push notification and sounds alert) +3. Display aircrafts over a map (position, direction and altitude, highlighed when a transit is predicted) +4. Display results, ordered by more probable transits to less probable ones +5. Check weather, min altitude and targets above horizon before getting API flight data +6. Personal gallery (you can organize your own collection of transits) +7. Background monitor (run Flymoon in auto mode without a browser, only the Terminal is required) -------- -## Setup +## Setup & Configuration -**Pre-requisites** +See [SETUP.md](SETUP.md) for full installation and configuration instructions (interactive wizard and manual setup). -- Python +3.9 -- Download or clone this project from GitHub (if you download a zip file, please extract it first, please). -**Linux distros and MacOS** +-------- -1) Run setup, this will create a virtual environment and install required python libraries. -```shell -make setup -``` +## Usage -2) Activate virtual env. +### Web app -```shell -source .venv/bin/activate -``` - -**Windows** +Activate venv and launch the server: -1) Open the CMD and move to the project path -2) Run this command to create the `.env` file: -```shell -copy .env.mock .env -``` -3) Create a virtual environment: ```shell -python -m venv .venv -``` -4) Activate the virtual environment: -```shell -.venv\Scripts\activate -``` -5) Install all the required python dependencies: -```shell -pip install -r requirements.txt -``` - -**Configuration** +# macOS/Linux +source .venv/bin/activate && python3 app.py -Open the `.env` file. You may need to display the hidden files. - -In Windows, if you don't have a text editor to open the `.env` file, you can download and install [Notepad++](https://notepad-plus-plus.org/downloads/) +# Windows +.venv\Scripts\activate && python app.py +``` -1) Set `AEROAPI_API_KEY`. Sign up on [FlightAware AeroAPI](https://www.flightaware.com/commercial/aeroapi/) and use the [Personal free tier](https://www.flightaware.com/aeroapi/signup/personal) to generate an API KEY. +Access it from any device on the same network (the LAN address is printed on startup): +- From another device: `http://192.168.x.x:8000` +- From the host: `http://localhost:8000` -2) Set the area of flights to check. I strong suggest to cover a 15 min area. This must be a bounding box, using latitudes and longitudes. Set `LAT_LOWER_LEFT`, `LONG_LOWER_LEFT`, `LAT_UPPER_RIGHT`, and `LONG_UPPER_RIGHT` appropriately. +Enter your latitude, longitude and elevation (saved in local storage). Use [MAPS.ie](https://www.maps.ie/coordinates.html) or Google Maps to get your coordinates. -3) (Optional) When using the auto mode If you want to receive notifications in your smartphone, you can get an API KEY from [Pushbucket platform](https://www.pushbullet.com/) and then set `PUSH_BULLET_API_KEY`. To get it, create an account, install the app in your phone and go to *Settings* > *Create Access Token*. +**Key controls:** +- **Check** β€” compute transits now +- **Auto** β€” repeat every X minutes; plays a sound and sends a push notification on medium/high probability transits +- **Target icon** β€” toggle between Moon, Sun, and Auto mode (tracks whichever is above the horizon) +- **Map button** β€” interactive map with your position, bounding box, azimuth arrows, and aircraft (β—† = predicted transit) +Click a table row or map aircraft to cross-highlight between them. -![](data/assets/bounding-box-example.png) +**Weather filtering:** if `OPENWEATHER_API_KEY` is set in `.env`, cloud cover is checked before each run (threshold: `CLOUD_COVER_THRESHOLD`, default 85%). -------- -## Usage +## Background Monitor +Run Flymoon in auto mode from the terminal, no browser needed. -**Activate venv** - +**macOS/Linux** ```shell -source .venv/bin/activate +python3 monitor.py --lat --long --elev [options] ``` -For Windows you can use: -```shell -.venv\Scripts\activate -``` - -Launch the web server from a terminal. - -```shell -python3 app.py -``` - -Windows: +**Windows** ```shell -python app.py +python windows_monitor.py ``` -The IP address in LAN network will be displayed, use it to access from any device inside the same network. - -Example: `http://192.168.3.199:8000` - -**Input your position (coordinates)** - -I suggest using [MAPS.ie](https://www.maps.ie/coordinates.html#google_vignette) or [Google Maps](https://maps.google.com/). The values will be saved in local storage, so you won't need to type them again next time if you're in the same location. - +**Options for `monitor.py`:** -**Compute possible transits** - -Click on Go! button to display results. Each row will include differences in alt-azimuthal coordinates only if it’s a possible transit. If the difference is enough small, the row will be highlighted in yellow, orange or green color (less probable to more probable). - -**Compute possible transits every X minutes** - -Click on Auto button, which will require a time in minutes, then the web app will check for transits every X minutes, it there's at leat one possible transit then a sound alert will be played along the sending of a push notification if it was configured (only medium to high probable flighs are notified). - -**Change target** - -Tap into the target icon and it'll toggle between Sun and Moon. +| Argument | Default | Description | +|---|---|---| +| `--lat` | required | Observer latitude | +| `--long` | required | Observer longitude | +| `--elev` | required | Observer elevation (meters) | +| `--target` | `auto` | `moon`, `sun`, or `auto` | +| `--interval` | `12` | Check interval in minutes | +| `--adsb` | `flightaware-aeroapi` | ADS-B provider (`flightaware-aeroapi` or `airlabs`) | +| `--min-alt` | `15` | Minimum target altitude (degrees) | +| `--notify` | off | Send push notifications | +| `--weather` | off | Check weather before each run | +| `--test` | off | Test mode | -------- @@ -127,21 +107,23 @@ Tap into the target icon and it'll toggle between Sun and Moon. ## Limitations -1) Computing the moment when there is a minimum difference between a plane and the target in alt-azimuthal coordinates is a numerical approach. Perhaps there could be an analytical way to optimize it. +1) Computing the moment when there is a minimum angular separation between a plane and the target is a numerical approach. Perhaps there could be an analytical way to optimize it. -2) The app assumes that airplanes maintain a constant speed and direction. However, changes to these factors within the 15-minute observation window can alter the ETA and potentially disrupt the predicted transit. +2) The app assumes that airplanes maintain a constant speed and direction. However, changes to these factors within a several-minute observation window can alter the ETA and potentially disrupt the predicted transit. -------- - ## Contribute -This web app is still under active testing. If you want to fix something, improve it, or make a suggestion, feel free to open a Pull Request or an issue. +This web app is still under active testing. If you want to fix something, improve it, or make a suggestion, feel free to open a Pull Request or an issue. Please, don't forget testing proposal code before opening a PR. -**Share your epic picture!** +------- + +See [CHANGELOG.md](CHANGELOG.md) for release history. -I'd love to watch some transit picture taken with the help of this tool. So, post it on this [issue](https://github.com/dbetm/flymoon/issues/21). -Pro-tip: You can use the Fightradar24 app to complement this web app. \ No newline at end of file +**Share your epic picture!** + +I'd love to watch some transit picture taken with the help of this tool. So, post it on this [issue](https://github.com/dbetm/flymoon/issues/21). \ No newline at end of file diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 00000000..8474de1d --- /dev/null +++ b/SETUP.md @@ -0,0 +1,120 @@ +# Flymoon β€” Setup & Configuration + +## Pre-requisites + +- Python 3.9+ +- Download or clone this project from GitHub. If you downloaded a zip file, extract it first. + +--- + +## Installation + +### Linux / macOS + +1. Open a Terminal and navigate to the project directory. +2. Run setup β€” this creates a virtual environment and installs all required dependencies: + +```shell +make setup +``` + +3. Activate the virtual environment: + +```shell +source .venv/bin/activate +``` + +### Windows + +1. Open CMD and navigate to the project directory. +2. Create the `.env` file from the template: + +```shell +copy .env.mock .env +``` + +3. Create a virtual environment: + +```shell +python -m venv .venv +``` + +4. Activate it: + +```shell +.venv\Scripts\activate +``` + +5. Install dependencies: + +```shell +pip install -r requirements.txt +``` + +--- + +## Configuration + +You need to set up at minimum an ADSB API key and a bounding box before running the app. You can do this interactively with the wizard or manually by editing the `.env` file. + +### Option A β€” Interactive wizard (recommended) + +Run the wizard and follow the on-screen prompts: + +```shell +python3 src/config_wizard.py --setup +``` + +On Windows, use `python` instead of `python3`: + +```shell +python src\config_wizard.py --setup +``` + +The wizard guides you through 4 steps: + +1. **ADSB Provider API key** β€” At least one is required for real-time flight data. You can set one or both: + - [FlightAware AeroAPI](https://flightaware.com/aeroapi/signup/personal) *(recommended, 100 free requests/month)* + - [AirLabs](https://airlabs.co/register) *(1000 free requests/month)* + +2. **Flight search area** β€” A bounding box covering roughly a 15-minute flight radius from your location. Use [MAPS.ie](https://www.maps.ie/coordinates.html) or Google Maps to find coordinates. You can also adjust it visually from the map view in the browser after launching the app. + +3. **Push notifications** *(optional)* β€” A [Pushbullet](https://www.pushbullet.com/) API key to receive smartphone alerts when a transit is detected in auto mode. + +4. **Weather filtering** *(optional)* β€” An [OpenWeatherMap](https://openweathermap.org/api) API key to skip transit checks when cloud cover is too high. + +All settings are saved to the `.env` file. You can re-run the wizard at any time to update your configuration. + +To validate the current configuration without making changes: + +```shell +python3 src/config_wizard.py --validate +``` + +--- + +### Option B β€” Manual setup + +Open the `.env` file with any text editor. You may need to enable hidden files visibility in your file explorer. + +> On Windows, if you don't have a suitable text editor, [Notepad++](https://notepad-plus-plus.org/downloads/) is a good option. + +**Required** + +| Variable | Description | +|---|---| +| `AEROAPI_API_KEY` | [FlightAware AeroAPI](https://www.flightaware.com/aeroapi/signup/personal) key β€” recommended, 100 free requests/month | +| `AIRLABS_API_KEY` | [AirLabs](https://airlabs.co/register) key β€” alternative, 1000 free requests/month | +| `LAT_LOWER_LEFT` / `LONG_LOWER_LEFT` | Southwest corner of the flight search area | +| `LAT_UPPER_RIGHT` / `LONG_UPPER_RIGHT` | Northeast corner of the flight search area | + +At least one ADSB key is required. The bounding box should cover roughly a 15-minute flight radius from your location. Use [MAPS.ie](https://www.maps.ie/coordinates.html) or right-click on Google Maps to get coordinates. You can also fine-tune the box visually from the map view in the browser after launching the app. + +![](data/assets/bounding-box-example.png) + +**Optional** + +| Variable | Description | +|---|---| +| `PUSH_BULLET_API_KEY` | [Pushbullet](https://www.pushbullet.com/) key for smartphone push notifications in auto mode. Go to *Settings* > *Create Access Token* after installing the app. | +| `OPENWEATHER_API_KEY` | [OpenWeatherMap](https://openweathermap.org/api) key for weather-based filtering (skips checks when too cloudy). | diff --git a/app.py b/app.py index 9f272d83..9410c7a4 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,14 @@ import argparse import asyncio +import json +import os import time -from datetime import date +from datetime import date, datetime +from http import HTTPStatus from dotenv import load_dotenv from flask import Flask, jsonify, render_template, request +from werkzeug.utils import secure_filename from src.constants import POSSIBLE_TRANSITS_LOGFILENAME @@ -12,12 +16,38 @@ load_dotenv() from src import logger +from src.config_wizard import ConfigWizard from src.flight_data import save_possible_transits, sort_results from src.notify import send_notifications from src.transit import get_transits app = Flask(__name__) +# Gallery configuration +UPLOAD_FOLDER = "static/gallery" +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"} +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER +app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # 16 MB limit + + +def allowed_file(filename): + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + + +def check_config(): + """Validate configuration from .env""" + wizard = ConfigWizard() + is_valid = wizard.validate(interactive=False) + print(wizard.get_status_report()) + + if not is_valid: + logger.error("\n🚨 Critical issues detected:") + print( + "\nπŸ’‘ Please run 'python3 src/config_wizard.py --setup' to configure" + " or manuallu add the required values to .env\n" + ) + exit(1) + @app.route("/") def index(): @@ -32,9 +62,39 @@ def get_all_flights(): latitude = float(request.args["latitude"]) longitude = float(request.args["longitude"]) elevation = float(request.args["elevation"]) - has_send_notification = request.args["send-notification"] == "true" + min_altitude = float(request.args.get("min_altitude", 15)) + has_send_notification = request.args["send_notification"] == "true" + adsb_provider = request.args["adsb_provider"] + check_weather = request.args["check_weather"] == "true" - data: dict = get_transits(latitude, longitude, elevation, target, test_mode) + # Check for custom bounding box from user + custom_bbox = None + bbox_args = [ + "bbox_lat_lower_left", + "bbox_lon_lower_left", + "bbox_lat_upper_right", + "bbox_lon_upper_right", + ] + if all(key in request.args for key in bbox_args): + custom_bbox = { + "lat_lower_left": float(request.args["bbox_lat_lower_left"]), + "lon_lower_left": float(request.args["bbox_lon_lower_left"]), + "lat_upper_right": float(request.args["bbox_lat_upper_right"]), + "lon_upper_right": float(request.args["bbox_lon_upper_right"]), + } + logger.info(f"Given bounding box: {custom_bbox}") + + data: dict = get_transits( + latitude, + longitude, + elevation, + target, + test_mode, + min_altitude, + custom_bbox, + adsb_provider, + check_weather, + ) data["flights"] = sort_results(data["flights"]) end_time = time.time() @@ -58,18 +118,214 @@ def get_all_flights(): try: asyncio.run(send_notifications(data["flights"], target)) except Exception as e: - logger.error(f"Error while trying to send notification. Details:\n{str(e)}") + logger.error( + f"Error while trying to send push notification. Details:\n{str(e)}" + ) return jsonify(data) +@app.route("/gallery") +def gallery(): + """Display the transit image gallery page.""" + return render_template("gallery.html") + + +@app.route("/gallery/upload", methods=["POST"]) +def upload_transit_image(): + """Upload a transit image with metadata.""" + if "file" not in request.files: + return jsonify({"error": "No file provided"}), HTTPStatus.BAD_REQUEST + + file = request.files["file"] + if file.filename == "": + return jsonify({"error": "No file selected"}), HTTPStatus.BAD_REQUEST + + if file and allowed_file(file.filename): + transit_date_str = request.form.get("transit_date", "") + try: + transit_dt = ( + datetime.strptime(transit_date_str, "%Y-%m-%d") + if transit_date_str + else datetime.now() + ) + except ValueError: + transit_dt = datetime.now() + + flight_id = request.form.get("flight_id", "UNKNOWN").replace("/", "_") + ext = file.filename.rsplit(".", 1)[1].lower() + + # Create year/month directories based on transit date + year_month_path = os.path.join( + app.config["UPLOAD_FOLDER"], str(transit_dt.year), f"{transit_dt.month:02d}" + ) + os.makedirs(year_month_path, exist_ok=True) + + # Save image + date_prefix = transit_dt.strftime("%Y%m%d") + filename = secure_filename(f"{date_prefix}_{flight_id}.{ext}") + filepath = os.path.join(year_month_path, filename) + file.save(filepath) + + # Save metadata + metadata = { + "flight_id": request.form.get("flight_id", ""), + "aircraft_type": request.form.get("aircraft_type", ""), + "upload_date": datetime.now().isoformat(), + "target": request.form.get("target", ""), + "caption": request.form.get("caption", ""), + "equipment": request.form.get("equipment", ""), + "transit_date": transit_dt.strftime("%Y-%m-%d"), + } + + metadata_path = filepath.rsplit(".", 1)[0] + ".json" + with open(metadata_path, "w") as f: + json.dump(metadata, f, indent=2) + + logger.info(f"Uploaded transit image: {filename}") + return jsonify({"success": True, "filename": filename}), HTTPStatus.OK + + return ( + jsonify({"error": "Invalid file type. Allowed: png, jpg, jpeg, gif"}), + HTTPStatus.BAD_REQUEST, + ) + + +@app.route("/gallery/list") +def list_gallery(): + """List all gallery images with metadata.""" + gallery_path = app.config["UPLOAD_FOLDER"] + images = [] + + # Create gallery directory if it doesn't exist + os.makedirs(gallery_path, exist_ok=True) + + # Walk directory structure + for root, dirs, files in os.walk(gallery_path): + for file in files: + if file.lower().endswith((".png", ".jpg", ".jpeg", ".gif")): + full_path = os.path.join(root, file) + rel_path = os.path.relpath(full_path, "static") + # Use forward slashes for web paths + rel_path = rel_path.replace("\\", "/") + metadata_path = full_path.rsplit(".", 1)[0] + ".json" + + metadata = {} + if os.path.exists(metadata_path): + try: + with open(metadata_path, "r") as f: + metadata = json.load(f) + except Exception as e: + logger.error(f"Error reading metadata for {file}: {str(e)}") + + images.append( + {"path": rel_path, "filename": file, "metadata": metadata} + ) + + # Sort by timestamp (most recent first) + images.sort(key=lambda x: x["metadata"].get("timestamp", ""), reverse=True) + return jsonify(images) + + +@app.route("/gallery/delete/", methods=["DELETE"]) +def delete_gallery_image(filepath): + """Delete a gallery image and its metadata.""" + try: + # Security check - ensure filepath is within gallery directory + full_path = os.path.join("static", filepath) + abs_path = os.path.abspath(full_path) + gallery_abs = os.path.abspath(app.config["UPLOAD_FOLDER"]) + + if not abs_path.startswith(gallery_abs): + return jsonify({"error": "Invalid file path"}), HTTPStatus.FORBIDDEN + + # Delete image file + if os.path.exists(abs_path): + os.remove(abs_path) + logger.info(f"Deleted image: {filepath}") + + # Delete metadata file + metadata_path = abs_path.rsplit(".", 1)[0] + ".json" + if os.path.exists(metadata_path): + os.remove(metadata_path) + logger.info(f"Deleted metadata: {metadata_path}") + + return jsonify({"success": True}), HTTPStatus.OK + except Exception as e: + logger.error(f"Error deleting image {filepath}: {str(e)}") + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR + + +@app.route("/gallery/update/", methods=["POST"]) +def update_gallery_metadata(filepath): + """Update metadata for a gallery image.""" + try: + # Security check - ensure filepath is within gallery directory + full_path = os.path.join("static", filepath) + abs_path = os.path.abspath(full_path) + gallery_abs = os.path.abspath(app.config["UPLOAD_FOLDER"]) + + if not abs_path.startswith(gallery_abs): + return jsonify({"error": "Invalid file path"}), HTTPStatus.FORBIDDEN + + # Get metadata file path + metadata_path = abs_path.rsplit(".", 1)[0] + ".json" + + # Read existing metadata + metadata = {} + if os.path.exists(metadata_path): + with open(metadata_path, "r") as f: + metadata = json.load(f) + + # Update with new values from request + metadata.update( + { + "flight_id": request.form.get( + "flight_id", metadata.get("flight_id", "") + ), + "aircraft_type": request.form.get( + "aircraft_type", metadata.get("aircraft_type", "") + ), + "target": request.form.get("target", metadata.get("target", "")), + "caption": request.form.get("caption", metadata.get("caption", "")), + "equipment": request.form.get( + "equipment", metadata.get("equipment", "") + ), + "transit_date": request.form.get( + "transit_date", metadata.get("transit_date", "") + ), + } + ) + + # Save updated metadata + with open(metadata_path, "w") as f: + json.dump(metadata, f, indent=2) + + logger.info(f"Updated metadata for: {filepath}") + return jsonify({"success": True, "metadata": metadata}), HTTPStatus.OK + except Exception as e: + logger.error(f"Error updating metadata for {filepath}: {str(e)}") + return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR + + if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("--test", action="store_true", help="load existing flight data") + parser = argparse.ArgumentParser(description="Flymoon Transit Monitor") + parser.add_argument( + "--test", + action="store_true", + help="Use test generated flights data with some possible transits", + ) + # parser.add_argument("--demo", action="store_true", help="Use mock demonstration data with guaranteed classifications") args = parser.parse_args() global test_mode + # test_mode = args.test or args.demo test_mode = args.test + if test_mode: + logger.info(f"πŸ§ͺ Starting in test mode - using generated flight data") + + check_config() + port = 8000 app.run(host="0.0.0.0", port=port, debug=True) diff --git a/data/assets/flymoon2-0-0.png b/data/assets/flymoon2-0-0.png new file mode 100644 index 00000000..32e78522 Binary files /dev/null and b/data/assets/flymoon2-0-0.png differ diff --git a/data/assets/flymoon2.png b/data/assets/flymoon2.png deleted file mode 100644 index 9f122bd2..00000000 Binary files a/data/assets/flymoon2.png and /dev/null differ diff --git a/data/assets/transit_example.jpg b/data/assets/transit_example.jpg new file mode 100644 index 00000000..d41c550e Binary files /dev/null and b/data/assets/transit_example.jpg differ diff --git a/data/raw_flight_data_example.json b/data/raw_flight_data_aeroapi_example.json similarity index 100% rename from data/raw_flight_data_example.json rename to data/raw_flight_data_aeroapi_example.json diff --git a/data/raw_flight_data_airlabs_example.json b/data/raw_flight_data_airlabs_example.json new file mode 100644 index 00000000..056f75e1 --- /dev/null +++ b/data/raw_flight_data_airlabs_example.json @@ -0,0 +1,348 @@ +{ + "request":{ + "lang":"en", + "currency":"USD", + "time":0, + "id":"m66017q5p8w", + "server":"g", + "host":"airlabs.co", + "pid":3330899, + "key":{ + "id":40575, + "api_key":"fa67c1e7-9ebb-45a1-9d75-1c55d3779c64", + "type":"free", + "expired":"2026-04-28T00:00:00.000Z", + "registered":"2026-03-28T03:04:06.000Z", + "upgraded":null, + "limits_by_hour":2500, + "limits_by_minute":250, + "limits_by_month":1000, + "limits_total":998 + }, + "params":{ + "bbox":"21.659,-105.22,24.803,-102.194", + "lang":"en" + }, + "version":9, + "method":"flights", + "client":{ + "ip":"2605:59c8:710a:7110:c58:86f5:fdac:7d37", + "geo":{ + "country_code":"MX", + "country":"Mexico", + "continent":"North America", + "city":"Mexico City", + "lat":19.4326, + "lng":-99.1332, + "timezone":"America/Mexico_City" + }, + "connection":{ + + }, + "device":{ + + }, + "agent":{ + + }, + "karma":{ + "is_blocked":false, + "is_crawler":false, + "is_bot":false, + "is_friend":false, + "is_regular":true + } + } + }, + "response":[ + { + "hex":"A2107B", + "reg_number":"N232FR", + "flag":"US", + "lat":22.977863, + "lng":-103.932975, + "alt":12120, + "dir":209.7, + "speed":751, + "v_speed":0, + "flight_number":"327", + "flight_icao":"FFT327", + "flight_iata":"F9327", + "dep_icao":"KATL", + "dep_iata":"ATL", + "arr_icao":"MMPR", + "arr_iata":"PVR", + "airline_icao":"FFT", + "airline_iata":"F9", + "aircraft_icao":"A320", + "updated":1774724815, + "status":"en-route", + "type":"adsb" + }, + { + "hex":"A09852", + "reg_number":"N13750", + "flag":"US", + "lat":21.98986, + "lng":-102.930757, + "alt":11183, + "dir":14.6, + "speed":903, + "v_speed":0, + "flight_number":"1725", + "flight_icao":"UAL1725", + "flight_iata":"UA1725", + "dep_icao":"MMGL", + "dep_iata":"GDL", + "arr_icao":"KIAH", + "arr_iata":"IAH", + "airline_icao":"UAL", + "airline_iata":"UA", + "aircraft_icao":"B737", + "updated":1774724815, + "status":"en-route", + "type":"adsb" + }, + { + "hex":"0D0E21", + "reg_number":"XA-MJJ", + "flag":"MX", + "lat":23.902813, + "lng":-104.927441, + "alt":11297, + "dir":129, + "speed":842, + "v_speed":0, + "flight_number":"177", + "flight_icao":"AMX177", + "flight_iata":"AM177", + "dep_icao":"MMTJ", + "dep_iata":"TIJ", + "arr_icao":"MMMX", + "arr_iata":"MEX", + "airline_icao":"AMX", + "airline_iata":"AM", + "aircraft_icao":"B39M", + "updated":1774724815, + "status":"en-route", + "type":"adsb" + }, + { + "hex":"ABDCFD", + "reg_number":"N86344", + "flag":"US", + "lat":22.465431, + "lng":-104.715718, + "alt":8211, + "dir":7, + "speed":780, + "v_speed":0, + "flight_number":"6336", + "flight_icao":"ASH6336", + "flight_iata":"UA6336", + "dep_icao":"MMEP", + "dep_iata":"TPQ", + "arr_icao":"KIAH", + "arr_iata":"IAH", + "airline_icao":"ASH", + "airline_iata":"YV", + "aircraft_icao":"E75L", + "updated":1774724815, + "status":"en-route", + "type":"adsb" + }, + { + "hex":"A04EDB", + "reg_number":"N119NN", + "flag":"US", + "lat":22.874133, + "lng":-104.177056, + "alt":10924, + "dir":35.5, + "speed":867, + "v_speed":0, + "flight_number":"1045", + "flight_icao":"AAL1045", + "flight_iata":"AA1045", + "dep_icao":"MMPR", + "dep_iata":"PVR", + "arr_icao":"KDFW", + "arr_iata":"DFW", + "airline_icao":"AAL", + "airline_iata":"AA", + "aircraft_icao":"A321", + "updated":1774724815, + "status":"en-route", + "type":"adsb" + }, + { + "hex":"C060C7", + "reg_number":"C-GKQW", + "flag":"CA", + "lat":23.714209, + "lng":-103.622095, + "alt":10688, + "dir":43, + "speed":885, + "v_speed":0, + "flight_number":"794", + "flight_icao":"POE794", + "flight_iata":"P3794", + "dep_icao":"MMPR", + "dep_iata":"PVR", + "arr_icao":"CYOW", + "arr_iata":"YOW", + "airline_icao":"POE", + "airline_iata":"PD", + "aircraft_icao":"E295", + "updated":1774724815, + "status":"en-route", + "type":"adsb" + }, + { + "hex":"4D2378", + "reg_number":"9H-AMV", + "flag":"MX", + "lat":23.56277, + "lng":-103.586303, + "alt":9598, + "dir":210, + "speed":798, + "v_speed":0, + "flight_number":"4340", + "flight_icao":"VIV4340", + "flight_iata":"VB4340", + "dep_icao":"MMMY", + "dep_iata":"MTY", + "arr_icao":"MMPR", + "arr_iata":"PVR", + "airline_icao":"VIV", + "airline_iata":"VB", + "aircraft_icao":"A320", + "updated":1774724815, + "status":"en-route", + "type":"adsb" + }, + { + "hex":"0D09D5", + "reg_number":"XA-VLU", + "flag":"MX", + "lat":24.163851, + "lng":-103.330828, + "alt":11480, + "dir":319, + "speed":852, + "v_speed":0, + "flight_number":"740", + "flight_icao":"VOI740", + "flight_iata":"Y4740", + "dep_icao":"MMMX", + "dep_iata":"MEX", + "arr_icao":"KLAS", + "arr_iata":"LAS", + "airline_icao":"VOI", + "airline_iata":"Y4", + "aircraft_icao":"A321", + "updated":1774724815, + "status":"en-route", + "type":"adsb" + }, + { + "hex":"AACD96", + "reg_number":"N79541", + "flag":"US", + "lat":24.02363, + "lng":-103.308464, + "alt":12737, + "dir":219.4, + "speed":796, + "v_speed":0, + "flight_number":"304", + "flight_icao":"UAL304", + "flight_iata":"UA304", + "dep_icao":"KIAH", + "dep_iata":"IAH", + "arr_icao":"MMPR", + "arr_iata":"PVR", + "airline_icao":"UAL", + "airline_iata":"UA", + "aircraft_icao":"B738", + "updated":1774724815, + "status":"en-route", + "type":"adsb" + }, + { + "hex":"0D0806", + "reg_number":"XA-AMN", + "flag":"MX", + "lat":23.884466, + "lng":-102.956002, + "alt":12097, + "dir":347, + "speed":858, + "v_speed":0, + "flight_number":"104", + "flight_icao":"AMX104", + "flight_iata":"AM104", + "dep_icao":"MMMX", + "dep_iata":"MEX", + "arr_icao":"MMTC", + "arr_iata":"TRC", + "airline_icao":"AMX", + "airline_iata":"AM", + "aircraft_icao":"B738", + "updated":1774724815, + "status":"en-route", + "type":"adsb" + }, + { + "hex":"ABE8E4", + "reg_number":"N867AM", + "flag":"MX", + "lat":22.849673, + "lng":-102.726165, + "alt":10992, + "dir":303, + "speed":824, + "v_speed":0, + "flight_number":"523", + "flight_icao":"AMX523", + "flight_iata":"AM523", + "dep_icao":"MMMX", + "dep_iata":"MEX", + "arr_icao":"MMHO", + "arr_iata":"HMO", + "airline_icao":"AMX", + "airline_iata":"AM", + "aircraft_icao":"B39M", + "updated":1774724815, + "status":"en-route", + "type":"adsb" + }, + { + "hex":"0D0993", + "reg_number":"XA-VLN", + "flag":"MX", + "lat":23.971896, + "lng":-104.961347, + "alt":11907, + "dir":133, + "speed":833, + "v_speed":0, + "flight_number":"5591", + "flight_icao":"VOI5591", + "flight_iata":"Y45591", + "dep_icao":"MMTJ", + "dep_iata":"TIJ", + "arr_icao":"MMOX", + "arr_iata":"OAX", + "airline_icao":"VOI", + "airline_iata":"Y4", + "aircraft_icao":"A320", + "updated":1774724815, + "status":"en-route", + "type":"adsb" + } + ], + "terms":"Unauthorized access is prohibited and punishable by law. \nReselling data 'As Is' without AirLabs.Co permission is strictly prohibited. \nFull terms on https://airlabs.co/. \nContact us info@airlabs.co" +} \ No newline at end of file diff --git a/data/raw_flight_data_example.txt b/data/raw_flight_data_example.txt deleted file mode 100644 index 04f55489..00000000 --- a/data/raw_flight_data_example.txt +++ /dev/null @@ -1 +0,0 @@ -{'flights': [{'ident': 'AMX190', 'ident_icao': 'AMX190', 'ident_iata': 'AM190', 'fa_flight_id': 'AMX190-1720409223-schedule-630p', 'actual_off': '2024-07-10T04:04:16Z', 'actual_on': None, 'foresight_predictions_available': True, 'predicted_out': None, 'predicted_off': None, 'predicted_on': None, 'predicted_in': None, 'predicted_out_source': None, 'predicted_off_source': None, 'predicted_on_source': None, 'predicted_in_source': None, 'origin': {'code': 'MMMX', 'code_icao': 'MMMX', 'code_iata': 'MEX', 'code_lid': None, 'timezone': 'America/Mexico_City', 'name': "Lic. Benito Juarez Int'l", 'city': 'Mexico City', 'airport_info_url': '/airports/MMMX'}, 'destination': {'code': 'MMML', 'code_icao': 'MMML', 'code_iata': 'MXL', 'code_lid': None, 'timezone': 'America/Tijuana', 'name': "General Rodolfo Sanchez Taboada Int'l", 'city': 'Mexicali', 'airport_info_url': '/airports/MMML'}, 'waypoints': [19.43, -99.07, 19.43, -99.17, 19.42, -99.25, 19.41, -99.34, 19.4, -99.51, 19.39, -99.68, 19.38, -99.75, 19.36, -100.06, 19.35, -100.13, 19.39, -100.24, 19.4, -100.26, 19.45, -100.42, 19.48, -100.49, 19.53, -100.64, 19.56, -100.72, 19.65, -100.99, 19.68, -101.08, 20, -102.06, 20.2, -102.69, 20.52, -103.31, 20.75, -103.56, 20.87, -103.69, 21.11, -103.95, 21.22, -104.08, 21.34, -104.21, 21.44, -104.32, 21.74, -104.66, 21.86, -104.79, 22.2, -105.18, 22.87, -105.93, 23.16, -106.27, 23.34, -106.46, 23.52, -106.65, 23.82, -106.99, 24.12, -107.32, 25.33, -108.67, 25.37, -108.72, 25.68, -109.07, 26.11, -109.37, 26.62, -109.73, 27.14, -110.11, 27.36, -110.26, 27.58, -110.42, 28.32, -110.97, 28.83, -111.34, 29, -111.47, 29.67, -111.98, 30.13, -112.33, 30.28, -112.45, 30.59, -112.69, 31.11, -113.1, 31.18, -113.16, 31.37, -113.3, 31.45, -113.43, 31.48, -113.47, 31.59, -113.63, 31.6, -113.65, 31.84, -114.02, 32.15, -114.49, 32.25, -114.64, 32.38, -114.84, 32.38, -114.85, 32.48, -115, 32.52, -115.06, 32.53, -115.08, 32.58, -115.16, 32.63, -115.24, 32.63, -115.24], 'first_position_time': '2024-07-10T02:34:42Z', 'last_position': {'fa_flight_id': 'AMX190-1720409223-schedule-630p', 'altitude': 360, 'altitude_change': '-', 'groundspeed': 448, 'heading': 308, 'latitude': 23.55767, 'longitude': -103.36938, 'timestamp': '2024-07-10T04:58:19Z', 'update_type': 'A'}, 'bounding_box': [23.55767, -103.36938, 19.42242, -98.68464], 'ident_prefix': None, 'aircraft_type': 'B39M'}, {'ident': 'VOI3299', 'ident_icao': 'VOI3299', 'ident_iata': 'Y43299', 'fa_flight_id': 'VOI3299-1720410716-airline-1304p', 'actual_off': '2024-07-10T04:13:15Z', 'actual_on': None, 'foresight_predictions_available': True, 'predicted_out': None, 'predicted_off': None, 'predicted_on': None, 'predicted_in': None, 'predicted_out_source': None, 'predicted_off_source': None, 'predicted_on_source': None, 'predicted_in_source': None, 'origin': {'code': 'MMSM', 'code_icao': 'MMSM', 'code_iata': 'NLU', 'code_lid': None, 'timezone': 'America/Mexico_City', 'name': 'Mexico City Santa LucΓ­a Airport', 'city': 'Santa LucΓ­a', 'airport_info_url': '/airports/MMSM'}, 'destination': {'code': 'MMTJ', 'code_icao': 'MMTJ', 'code_iata': 'TIJ', 'code_lid': None, 'timezone': 'America/Tijuana', 'name': "General Abelardo L. Rodriguez Int'l", 'city': 'Tijuana', 'airport_info_url': '/airports/MMTJ'}, 'waypoints': [19.74, -99.03, 19.84, -99.06, 19.91, -99.09, 19.96, -99.1, 19.99, -99.11, 20.14, -99.15, 20.3, -99.2, 20.45, -99.25, 20.63, -99.41, 20.79, -99.56, 21, -99.75, 21, -99.76, 21.04, -99.79, 21.18, -100, 21.25, -100.11, 21.38, -100.31, 21.52, -100.53, 21.72, -100.85, 21.75, -100.9, 21.89, -101.11, 22, -101.29, 22.89, -102.69, 23.04, -103.11, 23.4, -104.17, 24.68, -105.61, 25.04, -106.03, 25.35, -106.39, 25.5, -106.57, 26.57, -107.84, 26.73, -108.03, 27.81, -109.37, 29.09, -111, 30.51, -112.89, 31.68, -114.52, 31.73, -114.59, 31.86, -114.78, 31.91, -114.93, 31.92, -114.98, 31.95, -115.05, 32.03, -115.32, 32.12, -115.59, 32.14, -115.66, 32.28, -116.1, 32.33, -116.29, 32.41, -116.53, 32.46, -116.71, 32.46, -116.71, 32.49, -116.81, 32.52, -116.9, 32.54, -116.97, 32.54, -116.97], 'first_position_time': '2024-07-10T04:01:54Z', 'last_position': {'fa_flight_id': 'VOI3299-1720410716-airline-1304p', 'altitude': 360, 'altitude_change': '-', 'groundspeed': 454, 'heading': 311, 'latitude': 23.30303, 'longitude': -102.65752, 'timestamp': '2024-07-10T04:57:53Z', 'update_type': 'A'}, 'bounding_box': [23.30303, -102.65752, 19.72869, -98.89443], 'ident_prefix': None, 'aircraft_type': 'A320'}, {'ident': 'VOI3143', 'ident_icao': 'VOI3143', 'ident_iata': 'Y43143', 'fa_flight_id': 'VOI3143-1720408804-airline-889p', 'actual_off': '2024-07-10T03:25:03Z', 'actual_on': None, 'foresight_predictions_available': True, 'predicted_out': None, 'predicted_off': None, 'predicted_on': None, 'predicted_in': None, 'predicted_out_source': None, 'predicted_off_source': None, 'predicted_on_source': None, 'predicted_in_source': None, 'origin': {'code': 'MMOX', 'code_icao': 'MMOX', 'code_iata': 'OAX', 'code_lid': None, 'timezone': 'America/Mexico_City', 'name': "Xoxocotlan Int'l", 'city': 'Oaxaca', 'airport_info_url': '/airports/MMOX'}, 'destination': {'code': 'MMTJ', 'code_icao': 'MMTJ', 'code_iata': 'TIJ', 'code_lid': None, 'timezone': 'America/Tijuana', 'name': "General Abelardo L. Rodriguez Int'l", 'city': 'Tijuana', 'airport_info_url': '/airports/MMTJ'}, 'waypoints': [16.97, -96.73, 17.06, -96.8, 17.12, -96.84, 17.19, -96.89, 17.31, -96.98, 17.31, -96.98, 17.45, -97.08, 17.6, -97.19, 17.75, -97.35, 17.9, -97.5, 18.1, -97.7, 18.11, -97.71, 18.24, -97.84, 18.33, -97.9, 18.43, -97.96, 18.63, -98.09, 18.84, -98.22, 19.15, -98.42, 19.24, -98.48, 19.36, -98.55, 20, -98.96, 20.22, -99.11, 20.45, -99.25, 21.04, -99.79, 21.35, -100.26, 21.55, -100.58, 21.75, -100.9, 21.89, -101.11, 22, -101.29, 22.89, -102.69, 23.04, -103.11, 23.4, -104.17, 24.68, -105.61, 25.04, -106.03, 25.35, -106.39, 25.5, -106.57, 26.57, -107.84, 26.73, -108.03, 27.81, -109.37, 29.09, -111, 30.51, -112.89, 31.58, -114.38, 31.73, -114.59, 31.86, -114.78, 31.91, -114.93, 31.92, -114.98, 31.95, -115.05, 32.03, -115.32, 32.12, -115.59, 32.14, -115.66, 32.28, -116.1, 32.33, -116.29, 32.41, -116.53, 32.46, -116.71, 32.46, -116.71, 32.49, -116.81, 32.52, -116.9, 32.54, -116.97, 32.54, -116.97], 'first_position_time': '2024-07-10T03:22:25Z', 'last_position': {'fa_flight_id': 'VOI3143-1720408804-airline-889p', 'altitude': 380, 'altitude_change': 'C', 'groundspeed': 454, 'heading': 314, 'latitude': 24.10182, 'longitude': -104.95519, 'timestamp': '2024-07-10T04:58:12Z', 'update_type': 'A'}, 'bounding_box': [24.10182, -104.95519, 16.74749, -96.69896], 'ident_prefix': None, 'aircraft_type': 'A20N'}, {'ident': 'AMX192', 'ident_icao': 'AMX192', 'ident_iata': 'AM192', 'fa_flight_id': 'AMX192-1720411088-schedule-590p', 'actual_off': '2024-07-10T04:10:44Z', 'actual_on': None, 'foresight_predictions_available': True, 'predicted_out': None, 'predicted_off': None, 'predicted_on': None, 'predicted_in': None, 'predicted_out_source': None, 'predicted_off_source': None, 'predicted_on_source': None, 'predicted_in_source': None, 'origin': {'code': 'MMMX', 'code_icao': 'MMMX', 'code_iata': 'MEX', 'code_lid': None, 'timezone': 'America/Mexico_City', 'name': "Lic. Benito Juarez Int'l", 'city': 'Mexico City', 'airport_info_url': '/airports/MMMX'}, 'destination': {'code': 'MMTJ', 'code_icao': 'MMTJ', 'code_iata': 'TIJ', 'code_lid': None, 'timezone': 'America/Tijuana', 'name': "General Abelardo L. Rodriguez Int'l", 'city': 'Tijuana', 'airport_info_url': '/airports/MMTJ'}, 'waypoints': [19.43, -99.07, 19.43, -99.17, 19.42, -99.25, 19.41, -99.33, 19.4, -99.51, 19.39, -99.68, 19.38, -99.75, 19.36, -100.03, 19.35, -100.13, 19.38, -100.23, 19.46, -100.43, 19.48, -100.51, 19.53, -100.63, 19.54, -100.66, 19.56, -100.72, 19.65, -101, 19.68, -101.09, 20, -102.06, 20.2, -102.69, 20.52, -103.31, 20.75, -103.56, 20.87, -103.69, 21.11, -103.95, 21.22, -104.08, 21.34, -104.21, 21.54, -104.43, 21.74, -104.66, 21.86, -104.79, 22.2, -105.18, 22.87, -105.93, 23.16, -106.27, 23.34, -106.46, 23.52, -106.65, 23.82, -106.99, 24.12, -107.32, 25.33, -108.67, 25.37, -108.72, 25.68, -109.07, 26.11, -109.37, 26.62, -109.73, 27.14, -110.11, 27.36, -110.26, 27.58, -110.42, 28.32, -110.97, 28.83, -111.34, 29, -111.47, 29.67, -111.98, 30.13, -112.33, 30.59, -112.69, 31.37, -113.3, 31.67, -114.21, 31.7, -114.31, 31.86, -114.78, 31.86, -114.8, 31.89, -114.88, 31.94, -115.05, 32, -115.21, 32.1, -115.53, 32.14, -115.66, 32.28, -116.1, 32.33, -116.28, 32.41, -116.52, 32.46, -116.71, 32.48, -116.77, 32.49, -116.8, 32.52, -116.89, 32.54, -116.97, 32.54, -116.97], 'first_position_time': '2024-07-10T03:53:10Z', 'last_position': {'fa_flight_id': 'AMX192-1720411088-schedule-590p', 'altitude': 360, 'altitude_change': '-', 'groundspeed': 462, 'heading': 309, 'latitude': 22.43372, 'longitude': -102.86, 'timestamp': '2024-07-10T04:57:55Z', 'update_type': 'A'}, 'bounding_box': [22.43372, -102.86, 19.42464, -98.68479], 'ident_prefix': None, 'aircraft_type': 'B38M'}, {'ident': 'MCS2001', 'ident_icao': 'MCS2001', 'ident_iata': 'T22001', 'fa_flight_id': 'MCS2001-1720577031-dlad-2184p', 'actual_off': '2024-07-10T03:46:26Z', 'actual_on': None, 'foresight_predictions_available': True, 'predicted_out': None, 'predicted_off': None, 'predicted_on': None, 'predicted_in': None, 'predicted_out_source': None, 'predicted_off_source': None, 'predicted_on_source': None, 'predicted_in_source': None, 'origin': {'code': 'MMHO', 'code_icao': 'MMHO', 'code_iata': 'HMO', 'code_lid': None, 'timezone': 'America/Hermosillo', 'name': "General Ignacio Pesqueira Garcia Int'l", 'city': 'Hermosillo', 'airport_info_url': '/airports/MMHO'}, 'destination': {'code': 'MMTO', 'code_icao': 'MMTO', 'code_iata': 'TLC', 'code_lid': None, 'timezone': 'America/Mexico_City', 'name': "Lic. Adolfo Lopez Mateos Int'l", 'city': 'Toluca', 'airport_info_url': '/airports/MMTO'}, 'waypoints': [29.1, -111.05, 29.1, -111.05, 29.02, -111, 28.96, -110.95, 28.9, -110.91, 28.75, -110.8, 28.61, -110.7, 28.29, -110.47, 28.14, -110.37, 27.98, -110.25, 27.95, -110.23, 27.92, -110.21, 27.82, -110.14, 27.81, -110.13, 27.67, -110.03, 27.59, -109.98, 27.58, -109.68, 27.48, -109.54, 26.33, -108.06, 25.23, -106.6, 24.88, -106.15, 24.39, -105.65, 23.92, -104.95, 22.56, -103.32, 21.71, -102.32, 21.43, -101.99, 21.35, -101.9, 21.28, -101.81, 21.06, -101.56, 21, -101.48, 20.77, -101.19, 20.73, -101.14, 20.71, -101.12, 20.68, -101.08, 20.6, -100.98, 20.48, -100.83, 20.32, -100.63, 20.26, -100.55, 19.93, -100.15, 19.82, -100.01, 19.76, -99.94, 19.68, -99.84, 19.65, -99.8, 19.59, -99.76, 19.55, -99.72, 19.5, -99.68, 19.48, -99.67, 19.42, -99.62, 19.35, -99.57, 19.35, -99.57], 'first_position_time': '2024-07-10T03:40:47Z', 'last_position': {'fa_flight_id': 'MCS2001-1720577031-dlad-2184p', 'altitude': 351, 'altitude_change': '-', 'groundspeed': 417, 'heading': 132, 'latitude': 22.56205, 'longitude': -103.31927, 'timestamp': '2024-07-10T04:58:09Z', 'update_type': 'M'}, 'bounding_box': [28.86535, -111.17794, 22.56205, -103.31927], 'ident_prefix': None, 'aircraft_type': 'B734'}, {'ident': 'VIV1255', 'ident_icao': 'VIV1255', 'ident_iata': 'VB1255', 'fa_flight_id': 'VIV1255-1720579020-adhoc-3462p', 'actual_off': '2024-07-10T02:53:38Z', 'actual_on': None, 'foresight_predictions_available': False, 'predicted_out': None, 'predicted_off': None, 'predicted_on': None, 'predicted_in': None, 'predicted_out_source': None, 'predicted_off_source': None, 'predicted_on_source': None, 'predicted_in_source': None, 'origin': {'code': 'MMTJ', 'code_icao': 'MMTJ', 'code_iata': 'TIJ', 'code_lid': None, 'timezone': 'America/Tijuana', 'name': "General Abelardo L. Rodriguez Int'l", 'city': 'Tijuana', 'airport_info_url': '/airports/MMTJ'}, 'destination': None, 'waypoints': [], 'first_position_time': '2024-07-10T02:37:39Z', 'last_position': {'fa_flight_id': 'VIV1255-1720579020-adhoc-3462p', 'altitude': 350, 'altitude_change': '-', 'groundspeed': 466, 'heading': 139, 'latitude': 22.86218, 'longitude': -102.94708, 'timestamp': '2024-07-10T04:58:13Z', 'update_type': 'A'}, 'bounding_box': [32.54488, -117.10574, 22.86218, -102.94708], 'ident_prefix': None, 'aircraft_type': 'A321'}, {'ident': 'VIV1323', 'ident_icao': 'VIV1323', 'ident_iata': 'VB1323', 'fa_flight_id': 'VIV1323-1720408299-schedule-726p', 'actual_off': '2024-07-10T03:49:54Z', 'actual_on': None, 'foresight_predictions_available': True, 'predicted_out': None, 'predicted_off': None, 'predicted_on': None, 'predicted_in': None, 'predicted_out_source': None, 'predicted_off_source': None, 'predicted_on_source': None, 'predicted_in_source': None, 'origin': {'code': 'MMHO', 'code_icao': 'MMHO', 'code_iata': 'HMO', 'code_lid': None, 'timezone': 'America/Hermosillo', 'name': "General Ignacio Pesqueira Garcia Int'l", 'city': 'Hermosillo', 'airport_info_url': '/airports/MMHO'}, 'destination': {'code': 'MMMX', 'code_icao': 'MMMX', 'code_iata': 'MEX', 'code_lid': None, 'timezone': 'America/Mexico_City', 'name': "Lic. Benito Juarez Int'l", 'city': 'Mexico City', 'airport_info_url': '/airports/MMMX'}, 'waypoints': [29.1, -111.05, 29.02, -110.96, 28.97, -110.9, 28.91, -110.83, 28.81, -110.71, 28.75, -110.64, 28.1, -110.6, 27.71, -109.96, 27.69, -109.93, 27.2, -109.13, 26.59, -108.17, 26.46, -107.96, 26.24, -107.63, 25.8, -106.97, 25.69, -106.79, 24.88, -105.59, 24.78, -105.45, 24.51, -105.05, 24.14, -104.52, 23.52, -103.61, 23.14, -103.07, 22.89, -102.69, 21.56, -101.75, 21.51, -101.66, 21.07, -100.95, 21.03, -100.9, 20.94, -100.77, 20.73, -100.47, 20.7, -100.43, 20.66, -100.38, 20.65, -100.37, 20.51, -100.18, 20.36, -99.97, 20.21, -99.76, 20.05, -99.61, 19.92, -99.5, 19.87, -99.46, 19.75, -99.35, 19.63, -99.24, 19.63, -99.24, 19.56, -99.18, 19.5, -99.12, 19.43, -99.07], 'first_position_time': '2024-07-10T02:46:16Z', 'last_position': {'fa_flight_id': 'VIV1323-1720408299-schedule-726p', 'altitude': 371, 'altitude_change': '-', 'groundspeed': 462, 'heading': 126, 'latitude': 24.01392, 'longitude': -104.33289, 'timestamp': '2024-07-10T04:58:20Z', 'update_type': 'A'}, 'bounding_box': [29.09861, -111.17018, 24.01392, -104.33289], 'ident_prefix': None, 'aircraft_type': 'A320'}, {'ident': 'VOI1771', 'ident_icao': 'VOI1771', 'ident_iata': 'Y41771', 'fa_flight_id': 'VOI1771-1720403081-airline-539p', 'actual_off': '2024-07-10T01:55:17Z', 'actual_on': None, 'foresight_predictions_available': True, 'predicted_out': None, 'predicted_off': None, 'predicted_on': None, 'predicted_in': None, 'predicted_out_source': None, 'predicted_off_source': None, 'predicted_on_source': None, 'predicted_in_source': None, 'origin': {'code': 'KOAK', 'code_icao': 'KOAK', 'code_iata': 'OAK', 'code_lid': 'OAK', 'timezone': 'America/Los_Angeles', 'name': 'Metro Oakland Intl', 'city': 'Oakland', 'airport_info_url': '/airports/KOAK'}, 'destination': {'code': 'MMGL', 'code_icao': 'MMGL', 'code_iata': 'GDL', 'code_lid': None, 'timezone': 'America/Mexico_City', 'name': "Don Miguel Hidalgo y Costilla Int'l", 'city': 'Guadalajara', 'airport_info_url': '/airports/MMGL'}, 'waypoints': [37.72, -122.22, 37.69, -122.25, 37.61, -122.34, 37.6, -122.35, 37.59, -122.35, 37.57, -122.37, 37.55, -122.39, 37.54, -122.37, 37.53, -122.36, 37.51, -122.33, 37.49, -122.31, 37.48, -122.3, 37.46, -122.28, 37.46, -122.27, 37.45, -122.26, 37.41, -122.21, 37.32, -122.11, 37.29, -122.07, 37.21, -121.97, 37.13, -121.88, 37.1, -121.84, 36.96, -121.67, 36.95, -121.67, 36.92, -121.64, 36.79, -121.48, 36.66, -121.32, 36.59, -121.24, 36.43, -121.06, 36.42, -121.05, 36.35, -120.94, 36.27, -120.83, 36.1, -120.59, 35.87, -120.26, 35.84, -120.23, 35.74, -120.16, 35.71, -120.13, 35.68, -120.11, 35.55, -120.01, 34.6, -119.27, 34.46, -119.12, 34.06, -118.65, 32.57, -116.98, 32.55, -116.97, 32.54, -116.95, 30.9, -114.91, 30.07, -113.92, 29.8, -113.61, 29.18, -112.9, 27.76, -111.33, 27.45, -110.98, 25.68, -109.07, 25.37, -108.72, 25.33, -108.67, 25.18, -108.5, 25.17, -108.27, 24.2, -106.98, 23.77, -106.4, 23.3, -105.79, 23.04, -105.46, 22.91, -105.29, 22.52, -104.78, 22.23, -104.41, 21.64, -103.68, 21.48, -103.62, 21.32, -103.57, 21.28, -103.56, 21.12, -103.51, 20.92, -103.44, 20.82, -103.41, 20.81, -103.41, 20.76, -103.39, 20.68, -103.37, 20.67, -103.37, 20.6, -103.34, 20.52, -103.32], 'first_position_time': '2024-07-10T00:51:51Z', 'last_position': {'fa_flight_id': 'VOI1771-1720403081-airline-539p', 'altitude': 350, 'altitude_change': 'C', 'groundspeed': 453, 'heading': 131, 'latitude': 22.0161, 'longitude': -104.14834, 'timestamp': '2024-07-10T04:58:21Z', 'update_type': 'A'}, 'bounding_box': [37.79301, -122.46257, 22.0161, -104.14834], 'ident_prefix': None, 'aircraft_type': 'A21N'}, {'ident': 'VIV4027', 'ident_icao': 'VIV4027', 'ident_iata': 'VB4027', 'fa_flight_id': 'VIV4027-1720414961-schedule-24p', 'actual_off': '2024-07-10T04:43:35Z', 'actual_on': None, 'foresight_predictions_available': True, 'predicted_out': None, 'predicted_off': None, 'predicted_on': None, 'predicted_in': None, 'predicted_out_source': None, 'predicted_off_source': None, 'predicted_on_source': None, 'predicted_in_source': None, 'origin': {'code': 'MMMZ', 'code_icao': 'MMMZ', 'code_iata': 'MZT', 'code_lid': None, 'timezone': 'America/Mazatlan', 'name': "General Rafael Buelna Int'l", 'city': 'Mazatlan', 'airport_info_url': '/airports/MMMZ'}, 'destination': {'code': 'MMMY', 'code_icao': 'MMMY', 'code_iata': 'MTY', 'code_lid': None, 'timezone': 'America/Monterrey', 'name': "General Mariano Escobedo Int'l", 'city': 'Monterrey', 'airport_info_url': '/airports/MMMY'}, 'waypoints': [23.15, -106.25, 23.16, -106.27, 23.21, -106.19, 23.25, -106.12, 23.29, -106.04, 23.37, -105.91, 23.44, -105.77, 23.45, -105.75, 23.66, -105.38, 23.77, -105.19, 23.91, -104.93, 23.92, -104.92, 24.06, -104.65, 24.12, -104.54, 24.14, -104.52, 24.43, -103.79, 24.55, -103.49, 24.68, -103.17, 24.81, -102.83, 24.93, -102.53, 25.17, -101.92, 25.26, -101.67, 25.36, -101.42, 25.54, -100.95, 25.55, -100.93, 25.59, -100.77, 25.65, -100.54, 25.69, -100.37, 25.7, -100.37, 25.72, -100.28, 25.74, -100.19, 25.77, -100.1], 'first_position_time': '2024-07-10T04:42:05Z', 'last_position': {'fa_flight_id': 'VIV4027-1720414961-schedule-24p', 'altitude': 296, 'altitude_change': 'C', 'groundspeed': 467, 'heading': 59, 'latitude': 23.86682, 'longitude': -105.01038, 'timestamp': '2024-07-10T04:58:23Z', 'update_type': 'A'}, 'bounding_box': [23.86682, -106.45602, 23.11249, -105.01038], 'ident_prefix': None, 'aircraft_type': 'A320'}, {'ident': 'VOI324', 'ident_icao': 'VOI324', 'ident_iata': 'Y4324', 'fa_flight_id': 'VOI324-1720410414-airline-516p', 'actual_off': '2024-07-10T04:07:37Z', 'actual_on': None, 'foresight_predictions_available': True, 'predicted_out': None, 'predicted_off': None, 'predicted_on': None, 'predicted_in': None, 'predicted_out_source': None, 'predicted_off_source': None, 'predicted_on_source': None, 'predicted_in_source': None, 'origin': {'code': 'MMMX', 'code_icao': 'MMMX', 'code_iata': 'MEX', 'code_lid': None, 'timezone': 'America/Mexico_City', 'name': "Lic. Benito Juarez Int'l", 'city': 'Mexico City', 'airport_info_url': '/airports/MMMX'}, 'destination': {'code': 'MMML', 'code_icao': 'MMML', 'code_iata': 'MXL', 'code_lid': None, 'timezone': 'America/Tijuana', 'name': "General Rodolfo Sanchez Taboada Int'l", 'city': 'Mexicali', 'airport_info_url': '/airports/MMML'}, 'waypoints': [19.43, -99.07, 19.59, -99.1, 19.68, -99.11, 19.78, -99.13, 19.84, -99.14, 19.95, -99.16, 20.01, -99.17, 20.27, -99.22, 20.45, -99.25, 20.45, -99.26, 20.63, -99.41, 20.84, -99.61, 21.04, -99.79, 21.08, -99.86, 21.15, -99.97, 21.3, -100.19, 21.52, -100.54, 21.75, -100.9, 21.89, -101.11, 22, -101.29, 22.89, -102.69, 23.19, -103.04, 23.91, -103.87, 24.33, -104.37, 24.61, -104.7, 25.31, -105.53, 25.6, -105.88, 26.39, -106.86, 26.64, -107.17, 26.89, -107.48, 27.15, -107.8, 27.93, -108.81, 28.18, -109.13, 28.9, -110.08, 29.32, -110.66, 29.47, -110.84, 29.77, -111.22, 30.79, -112.54, 30.97, -112.77, 31.14, -113, 31.32, -113.24, 31.37, -113.3, 31.42, -113.38, 31.47, -113.45, 31.61, -113.67, 31.74, -113.86, 31.85, -114.03, 31.96, -114.19, 32.15, -114.49, 32.25, -114.64, 32.38, -114.84, 32.38, -114.85, 32.45, -114.95, 32.48, -115, 32.53, -115.08, 32.54, -115.09, 32.58, -115.16, 32.63, -115.24, 32.63, -115.24], 'first_position_time': '2024-07-10T03:56:31Z', 'last_position': {'fa_flight_id': 'VOI324-1720410414-airline-516p', 'altitude': 340, 'altitude_change': '-', 'groundspeed': 448, 'heading': 314, 'latitude': 23.17678, 'longitude': -103.01626, 'timestamp': '2024-07-10T04:57:58Z', 'update_type': 'A'}, 'bounding_box': [23.17678, -103.01626, 19.42739, -98.68459], 'ident_prefix': None, 'aircraft_type': 'A321'}, {'ident': 'VIV3339', 'ident_icao': 'VIV3339', 'ident_iata': 'VB3339', 'fa_flight_id': 'VIV3339-1720408300-schedule-1269p', 'actual_off': '2024-07-10T03:57:15Z', 'actual_on': None, 'foresight_predictions_available': True, 'predicted_out': None, 'predicted_off': None, 'predicted_on': None, 'predicted_in': None, 'predicted_out_source': None, 'predicted_off_source': None, 'predicted_on_source': None, 'predicted_in_source': None, 'origin': {'code': 'MMCS', 'code_icao': 'MMCS', 'code_iata': 'CJS', 'code_lid': None, 'timezone': 'America/Chihuahua', 'name': "Abraham Gonzalez Int'l", 'city': 'Ciudad Juarez', 'airport_info_url': '/airports/MMCS'}, 'destination': {'code': 'MMGL', 'code_icao': 'MMGL', 'code_iata': 'GDL', 'code_lid': None, 'timezone': 'America/Mexico_City', 'name': "Don Miguel Hidalgo y Costilla Int'l", 'city': 'Guadalajara', 'airport_info_url': '/airports/MMGL'}, 'waypoints': [31.64, -106.43, 31.64, -106.43, 31.64, -106.43, 31.56, -106.41, 31.53, -106.4, 31.49, -106.39, 31.48, -106.39, 31.34, -106.36, 31.17, -106.32, 30.79, -106.24, 30.58, -106.19, 30.31, -106.13, 30.3, -106.13, 30.02, -106.07, 29.9, -106.05, 29.87, -105.82, 29.46, -105.76, 27.68, -105.37, 26.73, -105.13, 26.18, -105, 25.3, -104.89, 25.27, -104.88, 24.52, -104.64, 24.14, -104.52, 23.34, -104.24, 23.06, -104.15, 22.77, -104.05, 22.62, -104.01, 22.3, -103.92, 22.29, -103.91, 22.25, -103.9, 22.19, -103.88, 21.96, -103.79, 21.73, -103.71, 21.64, -103.68, 21.32, -103.57, 21.28, -103.56, 21.12, -103.51, 20.92, -103.44, 20.82, -103.41, 20.76, -103.39, 20.76, -103.39, 20.68, -103.37, 20.6, -103.34, 20.52, -103.32], 'first_position_time': '2024-07-10T03:45:45Z', 'last_position': {'fa_flight_id': 'VIV3339-1720408300-schedule-1269p', 'altitude': 370, 'altitude_change': '-', 'groundspeed': 455, 'heading': 168, 'latitude': 24.57088, 'longitude': -104.61787, 'timestamp': '2024-07-10T04:58:18Z', 'update_type': 'A'}, 'bounding_box': [31.68544, -106.43618, 24.57088, -104.61787], 'ident_prefix': None, 'aircraft_type': 'A320'}], 'links': None, 'num_pages': 1} \ No newline at end of file diff --git a/monitor.py b/monitor.py new file mode 100755 index 00000000..f0ac192a --- /dev/null +++ b/monitor.py @@ -0,0 +1,270 @@ +import argparse +import asyncio +import os +import platform +import subprocess +import time +from datetime import date, datetime, timedelta +from typing import Optional + +from dotenv import load_dotenv + +load_dotenv() # noqa + +from src import logger +from src.constants import ( + POSIBILITY_LEVEL_TO_COLOR, + POSSIBLE_TRANSITS_LOGFILENAME, + TARGET_TO_EMOJI, + PossibilityLevel, +) +from src.flight_data import save_possible_transits, sort_results +from src.notify import send_notifications +from src.transit import get_transits + + +class TransitClient: + ALERT_SOUND_PATH = os.path.join( + "static", "sounds", "tissman-alert1-maximum-distortion.mp3" + ) + + def __init__( + self, + target: str, + lat: float, + long: float, + elevation: float, + interval_min: int, + send_app_notification: bool = False, + adsb_provider: str = "flightaware-aeroapi", + min_altitude: float = 15, + check_weather: bool = False, + test_mode: bool = False, + ): + self.target = target + self.latitude = lat + self.longitude = long + self.elevation = elevation + self.interval = interval_min + self.min_altitude = min_altitude + self.test_mode = test_mode + self.send_app_notification = send_app_notification + self.total_transits = 0 + self.adsb_provider = adsb_provider + self.check_weather = check_weather + + def __get_next_check_time(self) -> str: + current_datetime = datetime.now() + + next_datetime = current_datetime + timedelta(minutes=self.interval) + + return next_datetime.strftime("%H:%M:%S") + + def __play_sound(self, filepath: Optional[str] = None) -> None: + system = platform.system() + num_times_play_sound = 1 + time_between_sounds = 0.1 + + if system == "Windows": + import winsound + + if filepath: + winsound.PlaySound(filepath, winsound.SND_FILENAME) + else: + for _ in range(num_times_play_sound): + winsound.Beep(440, 500) # 440 Hz for 500 ms + time.sleep(time_between_sounds) + elif system == "Darwin": # macOS + ruta = filepath or "/System/Library/Sounds/Glass.aiff" + + for _ in range(num_times_play_sound): + subprocess.run(["afplay", ruta], check=True) + time.sleep(time_between_sounds) + elif system == "Linux": + if filepath: + # try aplay first, then paplay as fallback + for cmd in ("aplay", "paplay"): + try: + for _ in range(num_times_play_sound): + subprocess.run( + [cmd, filepath], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + time.sleep(time_between_sounds) + return + except (FileNotFoundError, subprocess.CalledProcessError): + continue + logger.warning("Neither aplay nor paplay were found") + else: + # Beep ASCII as fallback + for _ in range(num_times_play_sound): + print("\a", end="", flush=True) + time.sleep(time_between_sounds) + else: + logger.warning(f"Not supported system: {system}") + for _ in range(num_times_play_sound): + print("\a", end="", flush=True) + time.sleep(time_between_sounds) + + def run(self): + while True: + self.check_transits() + next_check_time = self.__get_next_check_time() + + for i in range(self.interval * 60, 0, -1): + print( + f"\rNext check at ⏰ {next_check_time} ({i} seconds)", + end="", + flush=True, + ) + time.sleep(1) + print() + + def check_transits(self): + data = get_transits( + self.latitude, + self.longitude, + self.elevation, + self.target, + self.test_mode, + min_altitude=self.min_altitude, + adsb_provider=self.adsb_provider, + check_weather=self.check_weather, + ) + + data["flights"] = sort_results(data["flights"]) + + flights = data.get("flights", []) + weather_info = data.get("weather", {}) + tracking_targets = data.get("trackingTargets", []) + + # Store weather info for status display + self.last_weather = weather_info + self.last_tracking_targets = tracking_targets + + # Log weather and tracking info + if weather_info: + logger.info( + f"Weather: {weather_info.get('description', 'unknown')} ({weather_info.get('cloud_cover', 'N/A')}% clouds)" + ) + if tracking_targets: + logger.info(f"Tracking: {', '.join(tracking_targets)}") + + # Check if any targets are trackable + if not tracking_targets: + logger.info("No targets trackable (below horizon, threshold or weather)") + self.current_transits = [] + return + + logger.info(data["targetCoordinates"]) + + # Filter for medium and high possibility transits + possible_transits = [ + f + for f in flights + if f.get("possibility_level") + in (PossibilityLevel.MEDIUM.value, PossibilityLevel.HIGH.value) + ] + + if not possible_transits: + logger.info("No possible transits found") + return + + # Save to CSV (only MEDIUM/HIGH) + if not self.test_mode: + try: + date_ = date.today().strftime("%Y%m%d") + asyncio.run( + save_possible_transits( + possible_transits, + POSSIBLE_TRANSITS_LOGFILENAME.format(date_=date_), + ) + ) + self.total_transits += len(possible_transits) + except Exception as e: + logger.error(f"Error saving transits: {e}") + + # if there's possible transits, make a sound + num_possible_transits = len(possible_transits) + if num_possible_transits > 0: + transit_word = "transits" if num_possible_transits > 1 else "transit" + + msg = "\n\n".join( + ["-" * 21] + + [ + f"{POSIBILITY_LEVEL_TO_COLOR[flight['possibility_level']]}" + f" {TARGET_TO_EMOJI[flight['target']]} {flight['id']} ({flight['aircraft_type']}) in {flight['time']} min." + f" {flight['origin']} -> {flight['destination']}." + f" Angular separation: {flight['angular_separation']}Β°" + for flight in possible_transits + ] + + ["-" * 42] + ) + logger.info(msg) + + self.__play_sound(self.ALERT_SOUND_PATH) + + self.total_transits += num_possible_transits + logger.info( + f"Found {num_possible_transits} possible {transit_word}. Session total: {self.total_transits}" + ) + + if self.send_app_notification and not self.test_mode: + try: + asyncio.run(send_notifications(data["flights"], self.target)) + except Exception as e: + logger.error( + f"Error while trying to send push notification. Details:\n{str(e)}" + ) + + +def main(): + parser = argparse.ArgumentParser(description="Flymoon for the Terminal") + parser.add_argument("--lat", type=float, help="Observer latitude", required=True) + parser.add_argument("--long", type=float, help="Observer longitude", required=True) + parser.add_argument( + "--elev", type=float, help="Observer elevation in meters", required=True + ) + parser.add_argument( + "--target", + choices=["moon", "sun", "auto"], + default="auto", + help="Target celestial object", + ) + parser.add_argument( + "--adsb", + choices=["flightaware-aeroapi", "airlabs"], + default="flightaware-aeroapi", + ) + parser.add_argument( + "--interval", type=int, default=12, help="Check interval in minutes" + ) + parser.add_argument("--notify", action="store_true", help="Send push notification") + parser.add_argument( + "--min-alt", type=float, default=15, help="Minimum altitude for targets" + ) + parser.add_argument("--weather", action="store_true", help="Check weather") + parser.add_argument("--test", action="store_true", help="Use test mode") + + args = parser.parse_args() + logger.info(args) + + app = TransitClient( + args.target, + args.lat, + args.long, + args.elev, + args.interval, + args.notify, + adsb_provider=args.adsb, + min_altitude=args.min_alt, + test_mode=args.test, + ) + + app.run() + + +if __name__ == "__main__": + main() diff --git a/requirements-dev.txt b/requirements-dev.txt index b3efb4fa..2bae3504 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,3 @@ -autoflake==2.3.1 +autoflake==26.3.1 black==24.4.2 isort==5.13.2 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ff7c4dc0..a94d0a8e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ aiofiles==24.1.0 Flask==3.0.3 pushbullet.py==0.12.0 -python-dotenv==1.0.1 -requests==2.32.3 +python-dotenv==1.2.1 +requests==2.32.4 +rumps==0.4.0 skyfield==1.49 tzlocal==5.2 diff --git a/src/astro.py b/src/astro.py index 91fd5556..8233dbcd 100644 --- a/src/astro.py +++ b/src/astro.py @@ -21,6 +21,7 @@ def update_position(self, ref_datetime: datetime): ref_datetime : datetime Python datetime object to get the future or past position of the celestial object, """ + time_ = EARTH_TIMESCALE.from_datetime(ref_datetime) astrometric = self.observer_position.at(time_).observe(self.data_obj) alt, az, distance = astrometric.apparent().altaz() diff --git a/src/config_wizard.py b/src/config_wizard.py new file mode 100644 index 00000000..42308fce --- /dev/null +++ b/src/config_wizard.py @@ -0,0 +1,490 @@ +import getpass +import os +import sys +from pathlib import Path + +from dotenv import find_dotenv, load_dotenv, set_key + + +class ConfigWizard: + """Configuration wizard and validator for Flymoon. Handles first-run setup + and configuration validation. + """ + + def __init__(self, config_file=None): + self.config_file = config_file or find_dotenv() or Path(".env") + self.errors = [] + self.warnings = [] + + def validate(self, interactive=False) -> bool: + """Validate configuration and optionally run interactive setup. + + Returns: + bool: True if config is valid, False otherwise + """ + load_dotenv(self.config_file) + + # Check critical settings + self._check_adsb_api_key() + self._check_weather_key() + self._check_pushbullet_api_key() + # self._check_coordinates() # currently they are passed + self._check_bounding_box() + + if interactive: + return self._run_interactive_setup() + + return len(self.errors) == 0 + + def _check_adsb_api_key(self): + """Check FlightAware AeroAPI key.""" + aeroapi_key = os.getenv("AEROAPI_API_KEY") + airlabs_key = os.getenv("AIRLABS_API_KEY") + + if not (aeroapi_key or airlabs_key): + self.errors.append( + { + "field": "ADSB API KEY", + "message": ( + "At least one ADSB API Key is required. FlightAware AeroAPI API Key" + " or AirLabs API Key is required for live flight data." + ), + "severity": "ERROR", + } + ) + + def _check_weather_key(self): + """Check OpenWeather API key.""" + key = os.getenv("OPENWEATHER_API_KEY") + if not key: + self.warnings.append( + { + "field": "OPENWEATHER_API_KEY", + "message": "OpenWeather API key missing (weather filtering disabled)", + "severity": "WARNING", + } + ) + + def _check_pushbullet_api_key(self): + """Check optionally Pushbullet API KEY""" + key = os.getenv("PUSH_BULLET_API_KEY") + if not key: + self.warnings.append( + { + "field": "PUSH_BULLET_API_KEY", + "message": "Pushbullet API key missing (push notifications disabled)", + "severity": "WARNING", + } + ) + + # def _check_coordinates(self): + # """Check observer coordinates.""" + # lat = os.getenv("OBSERVER_LATITUDE") + # lon = os.getenv("OBSERVER_LONGITUDE") + + # if not lat or not lon: + # self.errors.append({ + # "field": "OBSERVER_COORDINATES", + # "message": "Observer coordinates not set", + # "severity": "ERROR", + # }) + # else: + # try: + # lat_f = float(lat) + # lon_f = float(lon) + # if not (-90 <= lat_f <= 90): + # self.errors.append({ + # "field": "OBSERVER_LATITUDE", + # "message": f"Invalid latitude: {lat} (must be -90 to 90)", + # "severity": "ERROR" + # }) + # if not (-180 <= lon_f <= 180): + # self.errors.append({ + # "field": "OBSERVER_LONGITUDE", + # "message": f"Invalid longitude: {lon} (must be -180 to 180)", + # "severity": "ERROR" + # }) + # except ValueError: + # self.errors.append({ + # "field": "OBSERVER_COORDINATES", + # "message": "Coordinates must be numeric", + # "severity": "ERROR" + # }) + + def _check_bounding_box(self): + """Check flight search bounding box, set default if missing.""" + fields = [ + "LAT_LOWER_LEFT", + "LONG_LOWER_LEFT", + "LAT_UPPER_RIGHT", + "LONG_UPPER_RIGHT", + ] + values = {f: os.getenv(f) for f in fields} + + missing = [f for f, v in values.items() if not v] + if missing: + self.errors.append( + { + "field": "BOUNDING_BOX", + "message": f"Bounding box are required to search flights", + "severity": "ERROR", + } + ) + + def _prompt(self, message, default=None, required=True): + """Prompt user for input with optional default.""" + if default: + prompt_str = f"{message} [{default}]: " + else: + prompt_str = f"{message}: " + + while True: + value = input(prompt_str).strip() + if not value and default: + return default + if not value and required: + print(" This field is required. Please enter a value.") + continue + if not value and not required: + return None + return value + + def _prompt_secret(self, message, required=True): + """Prompt user for a sensitive value (e.g. API key) without echoing input.""" + while True: + value = getpass.getpass(f"{message}: ").strip() + if not value and required: + print(" This field is required. Please enter a value.") + continue + if not value and not required: + return None + return value + + def _prompt_float(self, message, default=None, min_val=None, max_val=None): + """Prompt for a float value with validation.""" + while True: + value = self._prompt(message, default=str(default) if default else None) + try: + f_val = float(value) + if min_val is not None and f_val < min_val: + print(f" Value must be at least {min_val}") + continue + if max_val is not None and f_val > max_val: + print(f" Value must be at most {max_val}") + continue + return f_val + except ValueError: + print(" Please enter a valid number") + + def _prompt_yes_no(self, message, default=True): + """Prompt for yes/no with default.""" + default_str = "Y/n" if default else "y/N" + while True: + value = input(f"{message} [{default_str}]: ").strip().lower() + if not value: + return default + if value in ("y", "yes"): + return True + if value in ("n", "no"): + return False + print(" Please enter 'y' or 'n'") + + def _run_interactive_setup(self): + """Run interactive setup wizard.""" + print("\n" + "=" * 60) + print(" Flymoon Configuration Wizard") + print("=" * 60) + print("\nThis wizard will help you configure Flymoon step by step.") + print("You can press Ctrl+C at any time to cancel.\n") + + try: + self._setup_adsb_api_keys() + # self._setup_observer_location() # not required this time + self._setup_bounding_box() + self._setup_notification_api_key() + self._setup_optional_additional_settings() + except KeyboardInterrupt: + print("\n\nSetup cancelled.") + return False + + print("\n" + "=" * 60) + print(" Configuration Complete!") + print("=" * 60) + print(f"\nSettings saved to: {self.config_file}") + print("\nTo start Flymoon:") + print(" python3 app.py") + print("\nThen open: http://localhost:8000") + print("") + + return True + + def _setup_adsb_api_keys(self): + """Setup API keys for ADSB providers (at least one required).""" + print("-" * 40) + print("STEP 1: ADSB Provider API Keys") + print("-" * 40) + print("\nAt least one provider is required for real-time flight data.") + print("You can configure one or both.\n") + + providers = [ + { + "label": "FlightAware AeroAPI (RECOMMENDED)", + "env_key": "AEROAPI_API_KEY", + "signup_url": "https://flightaware.com/aeroapi/signup/personal", + "prompt_label": "FlightAware AeroAPI key", + }, + { + "label": "AirLabs", + "env_key": "AIRLABS_API_KEY", + "signup_url": "https://airlabs.co/register", + "prompt_label": "AirLabs API key", + }, + ] + + while True: + for provider in providers: + current = os.getenv(provider["env_key"]) + status = f"(set: {current[:8]}...)" if current else "(not set)" + print(f" [{provider['label']}] {status}") + print(f" Get a free key at: {provider['signup_url']}") + + if current: + if not self._prompt_yes_no( + f" Change {provider['prompt_label']}?", default=False + ): + continue + else: + if not self._prompt_yes_no( + f" Set {provider['prompt_label']}?", default=True + ): + continue + + key = self._prompt_secret( + f" Enter your {provider['prompt_label']}", required=True + ) + set_key(self.config_file, provider["env_key"], key) + # Reload so os.getenv reflects the new value + load_dotenv(self.config_file, override=True) + print(" Saved!\n") + + # Validate at least one is set + if os.getenv("AEROAPI_API_KEY") or os.getenv("AIRLABS_API_KEY"): + break + + print( + "\n At least one ADSB API key is required. Please set at least one.\n" + ) + + # def _setup_observer_location(self): + # """Setup observer location.""" + # print("\n" + "-" * 40) + # print("STEP 2: Your Location") + # print("-" * 40) + # print("\nEnter your observation location (where you'll watch transits).") + # print(" Find coordinates at: https://www.maps.ie/coordinates.html") + # print(" Or use Google Maps: right-click any location to see coordinates.") + + # current_lat = os.getenv("OBSERVER_LATITUDE") + # current_lon = os.getenv("OBSERVER_LONGITUDE") + # current_elev = os.getenv("OBSERVER_ELEVATION", "0") + + # if current_lat and current_lon: + # print(f"\n Current location: {current_lat}, {current_lon} (elev: {current_elev}m)") + # if not self._prompt_yes_no(" Change location?", default=False): + # return + + # print("") + # lat = self._prompt_float(" Latitude (e.g., 33.12)", min_val=-90, max_val=90) + # lon = self._prompt_float(" Longitude (e.g., -117.31)", min_val=-180, max_val=180) + # elev = self._prompt_float(" Elevation in meters (e.g., 35)", default=0, min_val=0, max_val=10000) + + # set_key(self.config_file, "OBSERVER_LATITUDE", str(lat)) + # set_key(self.config_file, "OBSERVER_LONGITUDE", str(lon)) + # set_key(self.config_file, "OBSERVER_ELEVATION", str(elev)) + + # # Store for bounding box calculation + # self._observer_lat = lat + # self._observer_lon = lon + + # print(" Saved!") + + def _setup_bounding_box(self): + """Setup flight search bounding box.""" + print("\n" + "-" * 40) + print("STEP 2: Flight Search Area") + print("-" * 40) + print("\nThe bounding box defines the area to search for flights.") + print( + "Recommended covering roughly a 15-minute flight radius from your location." + ) + + fields = [ + ("LAT_LOWER_LEFT", "Lower-left latitude", -90, 90), + ("LONG_LOWER_LEFT", "Lower-left longitude", -180, 180), + ("LAT_UPPER_RIGHT", "Upper-right latitude", -90, 90), + ("LONG_UPPER_RIGHT", "Upper-right longitude", -180, 180), + ] + + current_values = {key: os.getenv(key) for key, *_ in fields} + all_set = all(current_values.values()) + + if all_set: + print(f"\n Current bounding box:") + print( + f" Lower-left: ({current_values['LAT_LOWER_LEFT']}, {current_values['LONG_LOWER_LEFT']})" + ) + print( + f" Upper-right: ({current_values['LAT_UPPER_RIGHT']}, {current_values['LONG_UPPER_RIGHT']})" + ) + if not self._prompt_yes_no(" Change bounding box?", default=False): + return + + print("\n Enter bounding box coordinates:") + print(" (Lower-left is southwest corner, upper-right is northeast corner)") + + for key, label, min_val, max_val in fields: + current = current_values[key] + default = float(current) if current else None + value = self._prompt_float( + f" {label}", default=default, min_val=min_val, max_val=max_val + ) + set_key(self.config_file, key, str(value)) + + load_dotenv(self.config_file, override=True) + print(" Saved!") + + def _setup_optional_additional_settings(self): + """Setup optional additional settings.""" + print("\n" + "-" * 40) + print("STEP 4: Optional Settings") + print("-" * 40) + + # Weather API + print("\nOpenWeatherMap API key (optional)") + print(" Enables weather-based filtering (skip checks when cloudy).") + print(" Get a free key at: https://openweathermap.org/api") + + current = os.getenv("OPENWEATHER_API_KEY") + if current: + print(f" Current: {current[:8]}...") + if self._prompt_yes_no(" Change weather API key?", default=False): + key = self._prompt_secret( + " Enter OpenWeatherMap API key", required=False + ) + if key: + set_key(self.config_file, "OPENWEATHER_API_KEY", key) + print(" Saved!") + else: + if self._prompt_yes_no(" Add weather API key?", default=False): + key = self._prompt_secret( + " Enter OpenWeatherMap API key", required=False + ) + if key: + set_key(self.config_file, "OPENWEATHER_API_KEY", key) + print(" Saved!") + else: + print(" Skipped. Weather filtering will be disabled.") + + def _setup_notification_api_key(self): + """Setup optional Pushbullet API key for push notifications.""" + print("\n" + "-" * 40) + print("STEP 3: Push Notifications (Optional)") + print("-" * 40) + print("\nPushbullet API key (optional)") + print( + " In auto mode, receive smartphone notifications when a transit is detected." + ) + print(" To get your key:") + print(" 1. Create an account at: https://www.pushbullet.com/") + print(" 2. Install the Pushbullet app on your phone.") + print(" 3. Go to Settings > Create Access Token.") + + current = os.getenv("PUSH_BULLET_API_KEY") + if current: + print(f" Current: {current[:8]}...") + if self._prompt_yes_no(" Change Pushbullet API key?", default=False): + key = self._prompt_secret( + " Enter your Pushbullet API key", required=False + ) + if key: + set_key(self.config_file, "PUSH_BULLET_API_KEY", key) + load_dotenv(self.config_file, override=True) + print(" Saved!") + else: + if self._prompt_yes_no(" Add Pushbullet API key?", default=False): + key = self._prompt_secret( + " Enter your Pushbullet API key", required=False + ) + if key: + set_key(self.config_file, "PUSH_BULLET_API_KEY", key) + load_dotenv(self.config_file, override=True) + print(" Saved!") + else: + print(" Skipped. Push notifications will be disabled.") + + def get_status_report(self): + """Get human-readable status report.""" + report = [] + + if not self.errors and not self.warnings: + report.append("βœ… Configuration is valid") + + if self.errors: + report.append(f"\n❌ {len(self.errors)} Error(s):") + for err in self.errors: + report.append(f" β€’ {err['field']}: {err['message']}") + + if self.warnings: + report.append(f"\n⚠️ {len(self.warnings)} Warning(s):") + for warn in self.warnings: + report.append(f" β€’ {warn['field']}: {warn['message']}") + + return "\n".join(report) + + +def quick_setup(): + """Quick setup for first-time users.""" + wizard = ConfigWizard() + + if not wizard.validate(interactive=False): + print("\nπŸ”§ First-time setup required\n") + wizard.validate(interactive=True) + else: + print("βœ… Configuration OK") + + return wizard + + +def main(): + """CLI entry point for config wizard.""" + import argparse + + parser = argparse.ArgumentParser(description="Flymoon Configuration Wizard") + parser.add_argument( + "--validate", + action="store_true", + help="Validate configuration without interactive setup", + ) + parser.add_argument("--setup", action="store_true", help="Run interactive setup") + parser.add_argument("--config", help="Path to .env file") + + args = parser.parse_args() + + wizard = ConfigWizard(args.config) + + if args.setup: + wizard.validate(interactive=True) + elif args.validate: + if wizard.validate(interactive=False): + print("βœ… Configuration is valid") + sys.exit(0) + else: + print(wizard.get_status_report()) + sys.exit(1) + else: + # Default: run quick setup + quick_setup() + + +if __name__ == "__main__": + main() diff --git a/src/constants.py b/src/constants.py index 182adbb8..8491aecb 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,3 +1,4 @@ +import os from enum import Enum from skyfield.api import load @@ -6,15 +7,33 @@ NUM_MINUTES_PER_HOUR = 60 NUM_SECONDS_PER_MIN = 60 EARTH_RADIOUS = 6371 +KM_TO_NAUTICAL_MILES = 0.539957 # Notifications -TARGET_TO_EMOJI = {"moon": "πŸŒ™", "sun": "β˜€οΈ"} +TARGET_TO_EMOJI = {"moon": "πŸŒ™", "sun": "β˜€οΈ", "both": "πŸŒ™β˜€οΈ"} MAX_NUM_ITEMS_TO_NOTIFY = 5 ALT_DIFF_THRESHOLD_TO_NOTIFY = 5.0 AZ_DIFF_THRESHOLD_TO_NOTIFY = 10.0 +# Weather +WEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY") +WEATHER_CACHE_DURATION_MINUTES = 10 # 6 requests per hour +WEATHER_API_URL = "https://api.openweathermap.org/data/2.5/weather" +WEATHER_ICONS = { + "clear": "β˜€οΈ", + "clouds": "☁️", + "partly_cloudy": "β›…", + "rain": "🌧️", + "snow": "🌨️", + "thunderstorm": "β›ˆοΈ", + "unknown": "❓", +} + # Flight data -API_URL = "https://aeroapi.flightaware.com/aeroapi/flights/search" +# FlightAware AeroAPI +AEROAPI_BASE_URL = "https://aeroapi.flightaware.com/aeroapi" +FLIGHTS_SEARCH_URL = f"{AEROAPI_BASE_URL}/flights/search" + CHANGE_ELEVATION = { "C": "climbing", "D": "descending", @@ -43,15 +62,15 @@ # Transit -class Altitude(Enum): - LOW = lambda x: x <= 15 # less or equal - MEDIUM = lambda x: x <= 30 # less or equal - MEDIUM_HIGH = lambda x: x <= 60 # less or equal - HIGH = lambda x: x > 60 # greater than - - class PossibilityLevel(Enum): - IMPOSSIBLE = 0 + UNLIKELY = 0 LOW = 1 MEDIUM = 2 HIGH = 3 + + +POSIBILITY_LEVEL_TO_COLOR = { + PossibilityLevel.HIGH.value: "🟒", + PossibilityLevel.MEDIUM.value: "🟠", + PossibilityLevel.LOW.value: "🟑", +} diff --git a/src/demo.py b/src/demo.py new file mode 100644 index 00000000..ab541155 --- /dev/null +++ b/src/demo.py @@ -0,0 +1,191 @@ +import math +import random +from datetime import datetime +from typing import List + +from src.constants import EARTH_RADIOUS + + +def generate_test_flightaware_data( + observer_position, target_names: List[str], target_coordinates: dict +) -> dict: + """Generate flight data, in which the last position is favorable to transits over the targets (Moon / Sun). + + + observer position is an object , you can get the latitude, longitude and elevation as follow: + -> float(observer_position.target.latitude.degrees) + + target_names could be moon and/or sun + + target_coordinates is a dictionary, example: "moon": { + "altitude": 42.2, + "azimuthal": 160.3, + } + """ + num_targets = len(target_names) + assert num_targets > 0, "you should provide at least one target" + + obs_lat = float(observer_position.target.latitude.degrees) + obs_lon = float(observer_position.target.longitude.degrees) + + # Use the first available target to compute flight positions + target = ( + target_names[0] + if num_targets == 1 + else target_names[random.randint(0, num_targets - 1)] + ) + + target_alt = target_coordinates[target]["altitude"] + target_az = target_coordinates[target]["azimuthal"] + + def get_geo_pos_from_altaz( + apparent_alt_deg: float, apparent_az_deg: float, elevation_m: float + ): + """Return the lat/lon at which an aircraft flying at elevation_m + would appear at (apparent_alt_deg, apparent_az_deg) from the observer. + + Uses the flat-Earth approximation d_horiz = h / tan(alt), which is + accurate to <0.2Β° for the distances involved (~5–40 km). + """ + alt_rad = math.radians(max(apparent_alt_deg, 1.0)) # guard tan(0) + d_horiz_km = (elevation_m / math.tan(alt_rad)) / 1000 + d_rad = d_horiz_km / EARTH_RADIOUS + + az_rad = math.radians(apparent_az_deg) + lat1 = math.radians(obs_lat) + lon1 = math.radians(obs_lon) + + lat2 = math.asin( + math.sin(lat1) * math.cos(d_rad) + + math.cos(lat1) * math.sin(d_rad) * math.cos(az_rad) + ) + lon2 = lon1 + math.atan2( + math.sin(az_rad) * math.sin(d_rad) * math.cos(lat1), + math.cos(d_rad) - math.sin(lat1) * math.sin(lat2), + ) + + return round(math.degrees(lat2), 6), round(math.degrees(lon2), 6) + + def pos_offset( + lat_deg: float, lon_deg: float, bearing_deg: float, distance_km: float + ): + """Move a point (lat_deg, lon_deg) by distance_km along bearing_deg (direction)""" + d_rad = distance_km / EARTH_RADIOUS + brng = math.radians(bearing_deg % 360) + lat1 = math.radians(lat_deg) + lon1 = math.radians(lon_deg) + + lat2 = math.asin( + math.sin(lat1) * math.cos(d_rad) + + math.cos(lat1) * math.sin(d_rad) * math.cos(brng) + ) + lon2 = lon1 + math.atan2( + math.sin(brng) * math.sin(d_rad) * math.cos(lat1), + math.cos(d_rad) - math.sin(lat1) * math.sin(lat2), + ) + + return round(math.degrees(lat2), 6), round(math.degrees(lon2), 6) + + # The closest-approach point is placed at (target_alt + Ξ”alt, target_az + Ξ”az). + configs = [ + # id origin destination type elev. Ξ”alt. Ξ”az. eta + ( + "AMX190", + "Mexico City", + "Guadalajara", + "B738", + 350, + 0.5, + 1.0, + 3.0, + ), # HIGH sep <= 2Β° + ( + "VOI282", + "Monterrey", + "Cancun", + "A320", + 330, + 2.5, + 1.0, + 5.0, + ), # MEDIUM sep <= 4Β° (min sep 2.5Β°) + ( + "VIV415", + "Guadalajara", + "Tijuana", + "A320", + 310, + 3.0, + 1.5, + 4.0, + ), # MEDIUM sep <= 4Β° (min sep 3.0Β°) + ( + "TAR031", + "Mexico City", + "Merida", + "B737", + 280, + 6.0, + 4.0, + 7.0, + ), # LOW sep <= 12Β° (min sep 6.0Β°) + ( + "AMX541", + "Hermosillo", + "Mexico City", + "B39M", + 250, + 15.0, + 10.0, + 6.0, + ), # UNLIKELY sep > 12Β° (min sep 15.0Β°) + ] + + speed_knots = 465 + speed_kmh = speed_knots * 1.852 + + flights = [] + for ( + id, + origin, + dest, + aircraft_type, + alt_hundreds_ft, + delta_alt, + delta_az, + eta_min, + ) in configs: + elevation_m = alt_hundreds_ft * 100 * 0.3048 + desired_alt = min(target_alt + delta_alt, 90.0) # cap well below zenith + desired_az = (target_az + delta_az) % 360 + + # Closest-approach point in lat/lon + close_lat, close_lon = get_geo_pos_from_altaz( + desired_alt, desired_az, elevation_m + ) + + # Back-project: place aircraft upstream so it reaches close_lat/lon at t=eta_min. + # The aircraft flies toward desired_az, so it starts in the opposite direction. + d_km = speed_kmh * eta_min / 60 + upstream_brng = (desired_az + 180) % 360 + lat, lon = pos_offset(close_lat, close_lon, upstream_brng, d_km) + heading = int(desired_az) + + flights.append( + { + "ident": id, + "flight_icao": id, + "aircraft_type": aircraft_type, + "fa_flight_id": f"{id}-demo-test", + "origin": {"city": origin}, + "destination": {"city": dest}, + "last_position": { + "latitude": lat, + "longitude": lon, + "heading": heading, + "groundspeed": speed_knots, + "altitude": alt_hundreds_ft, + "altitude_change": "-", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + }, + } + ) + + return {"flights": flights} diff --git a/src/flight_data.py b/src/flight_data.py index b940b3f1..3a67be4d 100644 --- a/src/flight_data.py +++ b/src/flight_data.py @@ -1,53 +1,133 @@ import json import os +from abc import ABC, abstractmethod from datetime import datetime from http import HTTPStatus -from typing import List +from typing import List, Optional import requests from src.position import AreaBoundingBox -def get_flight_data( - area_bbox: AreaBoundingBox, url_: str, api_key: str = "" -) -> List[dict]: - - headers = {"Accept": "application/json; charset=UTF-8", "x-apikey": api_key} - - # example: https://aeroapi.flightaware.com/aeroapi/flights/search?query=-latlong+%2221.305695+-104.458904+23.925834+-101.365481%22&max_pages=1 - url = ( - f"{url_}?query=-latlong+%22{area_bbox.lat_lower_left}+{area_bbox.long_lower_left}+" - f"{area_bbox.lat_upper_right}+{area_bbox.long_upper_right}%22&max_pages=1" - ) - - response = requests.get(url=url, headers=headers) - - if response.status_code == HTTPStatus.OK: - return response.json() - else: - # If not successful, raise exception with the status code and response text - raise Exception(f"Error: {response.status_code}, {response.text}") - - -def parse_fligh_data(flight_data: dict): - has_destination = isinstance(flight_data.get("destination"), dict) - - return { - "name": flight_data["ident"], - "origin": flight_data["origin"]["city"], - "destination": ( - "N/D" - if not has_destination - else flight_data.get("destination", dict()).get("city") - ), - "latitude": flight_data["last_position"]["latitude"], - "longitude": flight_data["last_position"]["longitude"], - "direction": flight_data["last_position"]["heading"], - "speed": int(flight_data["last_position"]["groundspeed"]) * 1.852, - "elevation": int(flight_data["last_position"]["altitude"]) * 100 * 0.3048, - "elevation_change": flight_data["last_position"]["altitude_change"], - } +class ADSBProviderClient(ABC): + def __init__(self, area_bbox: AreaBoundingBox, api_key: str) -> None: + self.bbox = area_bbox + self.api_key = api_key + + @abstractmethod + def get_flight_data(self) -> List[dict]: + """Retrieve real time aircraft data. Last positions of planes inside the bounding box.""" + + @abstractmethod + def parse(self, flight: dict) -> Optional[dict]: + """Get useful fields from a raw flight data, convert to used units and normalize values + between ADSB providers.""" + + +class FlightAwareAeroAPIClient(ADSBProviderClient): + BASE_URL = "https://aeroapi.flightaware.com/aeroapi" + + def get_flight_data(self) -> List[dict]: + endpoint_url = f"{self.BASE_URL}/flights/search" + headers = { + "Accept": "application/json; charset=UTF-8", + "x-apikey": self.api_key, + } + + # example: https://aeroapi.flightaware.com/aeroapi/flights/search?query=-latlong+%2221.305695+-104.458904+23.925834+-101.365481%22&max_pages=1 + url = ( + f"{endpoint_url}?query=-latlong+%22{self.bbox.lat_lower_left}+{self.bbox.long_lower_left}+" + f"{self.bbox.lat_upper_right}+{self.bbox.long_upper_right}%22&max_pages=1" + ) + + response = requests.get(url=url, headers=headers) + + if response.status_code == HTTPStatus.OK: + return response.json() + else: + # If not successful, raise exception with the status code and response text + raise Exception(f"Error: {response.status_code}, {response.text}") + + def parse(self, flight: dict) -> dict: + has_destination = isinstance(flight.get("destination"), dict) + + return { + "name": flight["ident"], + "aircraft_type": flight.get("aircraft_type", "N/A"), + "fa_flight_id": flight.get("fa_flight_id", ""), + "origin": flight["origin"]["city"], + "destination": ( + "N/D" + if not has_destination + else flight.get("destination", dict()).get("city") + ), + "latitude": flight["last_position"]["latitude"], + "longitude": flight["last_position"]["longitude"], + "direction": flight["last_position"]["heading"], + "speed": int(flight["last_position"]["groundspeed"]) * 1.852, # km/h + "elevation": int(flight["last_position"]["altitude"]) + * 0.3048 + * 100, # hundreds of feet to meters (for calculations) + "elevation_feet": int(flight["last_position"]["altitude"]) + * 100, # API returns hundreds of feet, multiply by 100 + "elevation_change": flight["last_position"]["altitude_change"], + "last_update": flight["last_position"]["timestamp"], + } + + +class AirLabsClient(ADSBProviderClient): + BASE_URL = "https://airlabs.co/api/v9" + + def get_flight_data(self) -> List[dict]: + endpoint_url = f"{self.BASE_URL}/flights" + + fmt_bbox = ( + f"{self.bbox.lat_lower_left},{self.bbox.long_lower_left}" + f",{self.bbox.lat_upper_right},{self.bbox.long_upper_right}" + ) + + query_params = f"api_key={self.api_key}&bbox={fmt_bbox}" + url = f"{endpoint_url}?{query_params}" + + headers = {"Accept": "application/json; charset=UTF-8"} + response = requests.get(url=url, headers=headers) + + if response.status_code == HTTPStatus.OK: + return {"flights": response.json()["response"]} + else: + # If not successful, raise exception with the status code and response text + raise Exception(f"Error: {response.status_code}, {response.text}") + + def parse(self, flight: dict) -> Optional[dict]: + v_speed = flight.get("v_speed", 0) + + # check that exists essential data + for col in ["speed", "lat", "lng", "dir", "alt"]: + if not flight.get(col): + return None + + return { + "name": flight["flight_icao"], + "aircraft_type": flight.get("aircraft_icao", "N/A"), + "fa_flight_id": flight.get("flight_iata", ""), + "origin": flight["dep_iata"], + "destination": flight["arr_iata"], + "latitude": flight["lat"], + "longitude": flight["lng"], + "direction": flight["dir"], + "speed": flight["speed"], # km/h + "elevation": flight["alt"], # meters + "elevation_feet": flight["alt"] * 3.28084, + "elevation_change": "-" if v_speed == 0 else ("C" if v_speed > 0 else "D"), + "last_update": convert_unix_timestamp_to_datetime_str(flight["updated"]), + } + + +def convert_unix_timestamp_to_datetime_str(unix_timestamp: int) -> str: + dt = datetime.fromtimestamp(unix_timestamp) + + return dt.strftime("%Y-%m-%d %H:%M:%S") def load_existing_flight_data(path: str) -> dict: @@ -56,13 +136,12 @@ def load_existing_flight_data(path: str) -> dict: def sort_results(data: List[dict]) -> List[dict]: - """Sort data flight results considering if it's possible transit, ETA and time.""" + """Sort data flight results considering if it's possible transit, angular separation, ETA and time.""" - def _custom_sort(a: dict) -> bool: - total_diff = a["alt_diff"] + a["az_diff"] if a["is_possible_transit"] else 100 - return (a["is_possible_transit"], -1 * total_diff, a["time"], a["id"]) + def _custom_sort(a: dict) -> tuple: + return (a.get("angular_separation", 1000), a.get("time", 999)) - return sorted(data, key=_custom_sort, reverse=True) + return sorted(data, key=_custom_sort) async def save_possible_transits(data: List[dict], dest_path: str) -> None: diff --git a/src/notify.py b/src/notify.py index 587e3c9b..e22114c6 100644 --- a/src/notify.py +++ b/src/notify.py @@ -15,6 +15,7 @@ async def send_notifications(flight_data: List[dict], target: str) -> None: if not API_TOKEN: logger.warning("No API token to send notifications, skipping...") + return possible_transits_data = list() @@ -23,11 +24,10 @@ async def send_notifications(flight_data: List[dict], target: str) -> None: PossibilityLevel.MEDIUM.value, PossibilityLevel.HIGH.value, ): - diff_sum = flight["alt_diff"] + flight["az_diff"] possible_transits_data.append( f"{flight['id']} in {flight['time']} min." f" {flight['origin']}->{flight['destination']}" - f" βˆ‘β–³{diff_sum}" + f" βˆ‘β–³{flight['angular_separation']}" ) if len(possible_transits_data) >= MAX_NUM_ITEMS_TO_NOTIFY: diff --git a/src/position.py b/src/position.py index 9d969a97..6036e61a 100644 --- a/src/position.py +++ b/src/position.py @@ -1,10 +1,14 @@ from dataclasses import dataclass from datetime import datetime -from math import asin, atan2, cos, degrees, radians, sin +from math import asin, atan2, cos, degrees, radians, sin, sqrt from skyfield.api import wgs84 -from src.constants import EARTH_RADIOUS, EARTH_TIMESCALE, NUM_MINUTES_PER_HOUR +from src.constants import ( + EARTH_RADIOUS, + EARTH_TIMESCALE, + NUM_MINUTES_PER_HOUR, +) @dataclass @@ -15,6 +19,50 @@ class AreaBoundingBox: long_upper_right: float +def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Calculate the great circle distance between two points on Earth using the Haversine formula. + + Parameters + ---------- + lat1 : float + Latitude of the first point in decimal degrees. + lon1 : float + Longitude of the first point in decimal degrees. + lat2 : float + Latitude of the second point in decimal degrees. + lon2 : float + Longitude of the second point in decimal degrees. + + Returns + ------- + float + Distance between the two points in kilometers. + + Notes + ----- + The Haversine formula calculates the shortest distance over the earth's surface, + giving an "as-the-crow-flies" distance between the points (ignoring any hills, etc.). + + Formula: + a = sinΒ²(Ξ”lat/2) + cos(lat1) * cos(lat2) * sinΒ²(Ξ”lon/2) + c = 2 * atan2(√a, √(1-a)) + distance = R * c + + where R is the Earth's radius and Ξ”lat, Ξ”lon are the differences in latitude and longitude. + """ + lat1_rad, lon1_rad = radians(lat1), radians(lon1) + lat2_rad, lon2_rad = radians(lat2), radians(lon2) + + dlat = lat2_rad - lat1_rad + dlon = lon2_rad - lon1_rad + + a = sin(dlat / 2) ** 2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2) ** 2 + c = 2 * atan2(sqrt(a), sqrt(1 - a)) + distance_km = EARTH_RADIOUS * c + + return distance_km + + def predict_position( lat: float, lon: float, speed: float, direction: float, minutes: float ) -> tuple: diff --git a/src/transit.py b/src/transit.py index fb2a0a2f..a50a203f 100644 --- a/src/transit.py +++ b/src/transit.py @@ -1,6 +1,6 @@ +import math import os from datetime import datetime, timedelta -from typing import List, Tuple from zoneinfo import ZoneInfo import numpy as np @@ -10,79 +10,108 @@ from src import logger from src.astro import CelestialObject from src.constants import ( - API_URL, ASTRO_EPHEMERIS, CHANGE_ELEVATION, INTERVAL_IN_SECS, NUM_SECONDS_PER_MIN, - TEST_DATA_PATH, TOP_MINUTE, - Altitude, + WEATHER_API_KEY, PossibilityLevel, ) -from src.flight_data import get_flight_data, load_existing_flight_data, parse_fligh_data +from src.demo import generate_test_flightaware_data +from src.flight_data import AirLabsClient, FlightAwareAeroAPIClient from src.position import ( AreaBoundingBox, geographic_to_altaz, get_my_pos, + haversine_distance, predict_position, ) +from src.weather import get_weather_condition EARTH = ASTRO_EPHEMERIS["earth"] -area_bbox = AreaBoundingBox( - lat_lower_left=os.getenv("LAT_LOWER_LEFT"), - long_lower_left=os.getenv("LONG_LOWER_LEFT"), - lat_upper_right=os.getenv("LAT_UPPER_RIGHT"), - long_upper_right=os.getenv("LONG_UPPER_RIGHT"), -) +def calculate_angular_separation( + alt_1: float, az_1: float, alt_2: float, az_2: float +) -> float: + """Calculate great-circle angular separation in alt-az space. + + Uses the spherical law of cosines, which is numerically stable + for all separations and altitudes including near the zenith. + + Parameters + ---------- + alt_1 : float + Altitude in degrees for the first object + az_1 : float + Azimuth in degrees for the first object + alt_2 : float + Altitude in degrees for the first object + az_2 : float + Azimuth in degrees for the first object -def get_thresholds(altitude: float) -> Tuple[float, float]: - """Receives target altitude and return the suggested threshold for both coordinates: - altitude and azimuthal. + Returns + ------- + float + Angular separation in degrees """ - if Altitude.LOW(altitude): - return (5.0, 10.0) - elif Altitude.MEDIUM(altitude): - return (10.0, 20.0) - elif Altitude.MEDIUM_HIGH(altitude): - return (10.0, 15.0) - elif Altitude.HIGH(altitude): - return (8.0, 180.0) - logger.warning(f"{altitude=}") - raise Exception(f"Given altitude is not valid!") + # Convert to radians + alt_1_rad = math.radians(alt_1) + az_1_rad = math.radians(az_1) + alt_2_rad = math.radians(alt_2) + az_2_rad = math.radians(az_2) + + ### Apply spheric cosines law ### + + # Term A: Sines product for altitud + term_a = math.sin(alt_1_rad) * math.sin(alt_2_rad) + # Term B: Product of cosines * cosine of the azimuth diff + az_diff = abs(az_1_rad - az_2_rad) + term_b = math.cos(alt_1_rad) * math.cos(alt_2_rad) * math.cos(az_diff) -def get_possibility_level( - altitude: float, alt_diff: float, az_diff: float, eta: float = None -) -> str: - possibility_level = PossibilityLevel.IMPOSSIBLE + # Calculate the total angle and convert back to degrees + # Note: We bound the value between -1 and 1 to avoid floating-point errors + cos_theta = min(1.0, max(-1.0, term_a + term_b)) + theta_rad = math.acos(cos_theta) - if alt_diff <= 10 and az_diff <= 10 or (Altitude.HIGH(altitude) and alt_diff <= 5): - possibility_level = PossibilityLevel.LOW + return math.degrees(theta_rad) - if Altitude.LOW(altitude) and (alt_diff <= 1 and az_diff <= 2): - possibility_level = PossibilityLevel.MEDIUM - elif Altitude.MEDIUM(altitude) and (alt_diff <= 2 and az_diff <= 2): - possibility_level = PossibilityLevel.MEDIUM - elif Altitude.MEDIUM_HIGH(altitude) and (alt_diff <= 3 and az_diff <= 3): - possibility_level = PossibilityLevel.MEDIUM - elif Altitude.HIGH(altitude) and (alt_diff <= 5 and az_diff <= 10): - possibility_level = PossibilityLevel.MEDIUM - if eta is not None and (alt_diff <= 1 and az_diff <= 1): - possibility_level = PossibilityLevel.HIGH +def get_possibility_level(angular_separation: float) -> str: + """Classify transit probability based on angular separation. - return possibility_level.value + Using 1.0Β° target diameter (expanded from actual ~0.5Β° to increase detection + of partial transits). Classification based on how close the aircraft passes + to the target center. + + Parameters + ---------- + angular_separation : float + Angular separation in degrees between aircraft and target + + Returns + ------- + str + Possibility level: HIGH (≀2Β°), MEDIUM (≀4Β°), LOW (≀12Β°), or UNLIKELY (>12Β°) + """ + if angular_separation <= 2.0: + return PossibilityLevel.HIGH.value + elif angular_separation <= 4.0: + return PossibilityLevel.MEDIUM.value + elif angular_separation <= 12.0: + return PossibilityLevel.LOW.value + else: + return PossibilityLevel.UNLIKELY.value def check_transit( flight: dict, window_time: list, ref_datetime: datetime, - my_position: Topos, + observer_position: Topos, target: CelestialObject, earth_ref, ) -> dict: @@ -98,7 +127,7 @@ def check_transit( ref_datetime: datetime Reference datetime, deltas from window_time will be add to this reference to compute the future position of plane and target. - my_position: Topos + observer_position: Topos Object from skifield library which was instanced with current position of the observer ( latitude, longitude and elevation). target: CelestialObject @@ -113,10 +142,22 @@ def check_transit( id, origin, destination, time, target_alt, plane_alt, target_az, plane_az, alt_diff, az_diff, is_possible_transit, and change_elev. """ - min_diff_combined = float("inf") + min_angular_sep = float("inf") response = None no_decreasing_count = 0 update_response = False + POSSIBLE_TRANSIT_LEVELS = { + PossibilityLevel.HIGH.value, + PossibilityLevel.MEDIUM.value, + } + + # Calculate horizontal distance from observer to aircraft in kilometers + distance_km = haversine_distance( + float(observer_position.target.latitude.degrees), + float(observer_position.target.longitude.degrees), + flight["latitude"], + flight["longitude"], + ) for idx, minute in enumerate(window_time): # Get future position of plane @@ -128,7 +169,7 @@ def check_transit( minutes=minute, ) - future_time = ref_datetime + timedelta(minutes=int(minute)) + future_time = ref_datetime + timedelta(minutes=minute) # Convert future position of plane to alt-azimuthal coordinates future_alt, future_az = geographic_to_altaz( @@ -136,145 +177,269 @@ def check_transit( future_lon, flight["elevation"], earth_ref, - my_position, + observer_position, future_time, ) - if idx > 0 and idx % 60 == 0: - # Update target position every 60 data points (1 min) + if idx > 0 and idx % 30 == 0: + # Update target position every 30 data points (0.5 min) target.update_position(future_time) alt_diff = abs(future_alt - target.altitude.degrees) az_diff = abs(future_az - target.azimuthal.degrees) - diff_combined = alt_diff + az_diff - if no_decreasing_count >= 180: - logger.info(f"diff is increasing, stop checking, min={round(minute, 2)}") - break + angular_sep = calculate_angular_separation( + alt_1=target.altitude.degrees, + az_1=target.azimuthal.degrees, + alt_2=future_alt, + az_2=future_az, + ) - if diff_combined < min_diff_combined: + if angular_sep < min_angular_sep: no_decreasing_count = 0 - min_diff_combined = diff_combined + min_angular_sep = angular_sep update_response = True else: no_decreasing_count += 1 - alt_threshold, az_threshold = get_thresholds(target.altitude.degrees) - - if future_alt > 0 and alt_diff < alt_threshold and az_diff < az_threshold: - - if update_response: - response = { - "id": flight["name"], - "origin": flight["origin"], - "destination": flight["destination"], - "alt_diff": round(float(alt_diff), 3), - "az_diff": round(float(az_diff), 3), - "time": round(float(minute), 3), - "target_alt": round(float(target.altitude.degrees), 2), - "plane_alt": round(float(future_alt), 2), - "target_az": round(float(target.azimuthal.degrees), 2), - "plane_az": round(float(future_az), 2), - "is_possible_transit": 1, - "possibility_level": get_possibility_level( - target.altitude.degrees, alt_diff, az_diff, minute - ), - "elevation_change": CHANGE_ELEVATION.get( - flight["elevation_change"], None - ), - "direction": flight["direction"], - } + if no_decreasing_count >= 120: + logger.info( + f"Angular separation increasing, stop checking at min={round(minute, 2)}" + ) + break + + # Always track aircraft above horizon, will be classified by angular separation + if update_response: + possibility_level = get_possibility_level(angular_sep) + + response = { + "id": flight["name"], + "aircraft_type": flight.get("aircraft_type", "N/A"), + "fa_flight_id": flight.get("fa_flight_id", ""), + "origin": flight["origin"], + "destination": flight["destination"], + "alt_diff": round(float(alt_diff), 2), + "az_diff": round(float(az_diff), 2), + "angular_separation": round(float(angular_sep), 2), + "time": round(float(minute), 2), + "target_alt": round(float(target.altitude.degrees), 2), + "plane_alt": round(float(future_alt), 2), + "target_az": round(float(target.azimuthal.degrees), 2), + "plane_az": round(float(future_az), 2), + "is_possible_transit": ( + 1 if possibility_level in POSSIBLE_TRANSIT_LEVELS else 0 + ), + "possibility_level": possibility_level, + "elevation_change": CHANGE_ELEVATION.get( + flight["elevation_change"], None + ), + "direction": flight["direction"], + "speed": flight["speed"], + "target": target.name, + "latitude": flight["latitude"], + "longitude": flight["longitude"], + "aircraft_elevation": flight.get( + "elevation", 0 + ), # Actual altitude in meters + "aircraft_elevation_km": round( + flight.get("elevation", 0) / 1_000, 2 + ), # Actual altitude in kilometers + "aircraft_elevation_feet": flight.get( + "elevation_feet", 0 + ), # Actual altitude in feet # TODO: deprecate + "distance_km": round(distance_km, 1), # Distance from observer in km + } update_response = False - if response: - return response + if not response: + raise Exception("No response was generated!") - return { - "id": flight["name"], - "origin": flight["origin"], - "destination": flight["destination"], - "alt_diff": None, - "az_diff": None, - "time": None, - "target_alt": None, - "plane_alt": None, - "target_az": None, - "plane_az": None, - "is_possible_transit": 0, - "possibility_level": PossibilityLevel.IMPOSSIBLE.value, - "elevation_change": CHANGE_ELEVATION.get(flight["elevation_change"], None), - "direction": flight["direction"], - } + return response def get_transits( latitude: float, longitude: float, elevation: float, - target_name: str = "moon", + target_name: str = "auto", test_mode: bool = False, -) -> List[dict]: - API_KEY = os.getenv("AEROAPI_API_KEY") + min_altitude: float = 15, + custom_bbox: dict = None, + adsb_provider: str = "flightaware-aeroapi", + check_weather: bool = True, +) -> dict: + """Get transit predictions for celestial targets. - logger.info(f"{latitude=}, {longitude=}, {elevation=}") + Parameters + ---------- + target_name : str + 'moon', 'sun', or 'auto' (checks both if conditions permit) + min_altitude : float + Minimum altitude in degrees for target to be tracked (default 15) + test_mode : bool + If True, return mock results for demonstration + custom_bbox : dict + Optional custom bounding box with keys: lat_lower_left, lon_lower_left, lat_upper_right, lon_upper_right + adsb_provider: str: + Optional ADSB provider name to use. You must set the API Key for the choosen one. Default: `flightaware-aeroapi`. + check_weather : bool + If True, check weather only if the API key was configured. + """ + # TODO: compute from current user position + AREA_BBOX_FROM_ENV = AreaBoundingBox( + lat_lower_left=float(os.getenv("LAT_LOWER_LEFT")), + long_lower_left=float(os.getenv("LONG_LOWER_LEFT")), + lat_upper_right=float(os.getenv("LAT_UPPER_RIGHT")), + long_upper_right=float(os.getenv("LONG_UPPER_RIGHT")), + ) - MY_POSITION = get_my_pos( + OBSERVER_POSITION = get_my_pos( lat=latitude, lon=longitude, elevation=elevation, base_ref=EARTH, ) + if min_altitude < 0: + min_altitude = 0 + logger.warning( + "Min altitude was changed to 0, no below horizon is tracking possible" + ) + + logger.info(f"Starting transit computation for target={target_name}") + window_time = np.linspace( 0, TOP_MINUTE, TOP_MINUTE * (NUM_SECONDS_PER_MIN // INTERVAL_IN_SECS) ) logger.info(f"number of times to check for each flight: {len(window_time)}") + # Get the local timezone using tzlocal local_timezone = get_localzone_name() naive_datetime_now = datetime.now() - # Make the datetime object timezone-aware ref_datetime = naive_datetime_now.replace(tzinfo=ZoneInfo(local_timezone)) - celestial_obj = CelestialObject(name=target_name, observer_position=MY_POSITION) - celestial_obj.update_position(ref_datetime=ref_datetime) - current_target_coordinates = celestial_obj.get_coordinates() + # Determine which targets to check + target_names = ["moon", "sun"] if target_name == "auto" else [target_name] + targets_to_check = [] + target_coordinates = {} - logger.info(celestial_obj.__str__()) + # Check both moon and sun if conditions permit + for target in target_names: + obj = CelestialObject(name=target, observer_position=OBSERVER_POSITION) + obj.update_position(ref_datetime=ref_datetime) + coords = obj.get_coordinates(precision=4) + + target_coordinates[target] = coords + + if coords["altitude"] >= min_altitude: + targets_to_check.append(target) + logger.info( + f"{target} at {coords['altitude']}Β° az {coords['azimuthal']}Β° - tracking enabled" + ) + else: + reason = ( + "below horizon or threshold" + if coords["altitude"] < min_altitude + else "weather" + ) + logger.info(f"{target} at {coords['altitude']}Β° - skipped ({reason})") data = list() + tracking_targets = targets_to_check.copy() # For response + + # Use custom bounding box if provided, otherwise use default + if custom_bbox: + search_bbox = AreaBoundingBox( + lat_lower_left=custom_bbox["lat_lower_left"], + long_lower_left=custom_bbox["lon_lower_left"], + lat_upper_right=custom_bbox["lat_upper_right"], + long_upper_right=custom_bbox["lon_upper_right"], + ) + else: + search_bbox = AREA_BBOX_FROM_ENV + logger.info(f"Using bounding box as search area from ENV: {search_bbox}") + + logger.info(f"{adsb_provider=}") + + # Instanciate the ADSB provider client + if adsb_provider == "flightaware-aeroapi": + adsb_client = FlightAwareAeroAPIClient( + search_bbox, os.getenv("AEROAPI_API_KEY") + ) + elif adsb_provider == "airlabs": + adsb_client = AirLabsClient(search_bbox, os.getenv("AIRLABS_API_KEY")) + else: + raise ValueError( + "Pass a valid ADSB provider name, allowed values: flightaware-aeroapi, airlabs" + ) - if current_target_coordinates["altitude"] > 0: + # Check weather conditions + if targets_to_check and check_weather: + is_clear, weather_info = get_weather_condition( + latitude, longitude, WEATHER_API_KEY, test_mode + ) + logger.info(f"Weather check: clear={is_clear}, {weather_info}") + else: + is_clear, weather_info = get_weather_condition( + latitude, longitude, WEATHER_API_KEY, return_default_response=True + ) + + if targets_to_check and is_clear: + # Fetch flight data once if test_mode: - raw_flight_data = load_existing_flight_data(TEST_DATA_PATH) - logger.info("Loading existing flight data since is using TEST mode") + logger.info("πŸ§ͺ TEST MODE: generating test flight data...") + raw_flight_data = generate_test_flightaware_data( + OBSERVER_POSITION, targets_to_check, target_coordinates + ) else: - raw_flight_data = get_flight_data(area_bbox, API_URL, API_KEY) + raw_flight_data = adsb_client.get_flight_data() flight_data = list() - for flight in raw_flight_data["flights"]: - flight_data.append(parse_fligh_data(flight)) + # flight_data.append(parse_fligh_data(flight)) + parsed_data_flight = adsb_client.parse(flight) + + if parsed_data_flight: + flight_data.append(parsed_data_flight) logger.info(f"there are {len(flight_data)} flights near") - for flight in flight_data: - celestial_obj.update_position(ref_datetime=ref_datetime) + # Check transits for each target + for target in targets_to_check: + celestial_obj = CelestialObject( + name=target, observer_position=OBSERVER_POSITION + ) + # celestial_obj.update_position(ref_datetime=ref_datetime) + + for flight in flight_data: + celestial_obj.update_position(ref_datetime=ref_datetime) - data.append( - check_transit( + transit_result = check_transit( flight, window_time, ref_datetime, - MY_POSITION, + OBSERVER_POSITION, celestial_obj, EARTH, ) - ) - - logger.info(data[-1]) - else: - logger.warning( - f"{target_name} target is under horizon, skipping checking for transits..." - ) + data.append(transit_result) + logger.info(transit_result) - return {"flights": data, "targetCoordinates": current_target_coordinates} + return { + "flights": data, + "targetCoordinates": target_coordinates, + "trackingTargets": tracking_targets, + "weather": weather_info, + "boundingBox": { + "latLowerLeft": float(search_bbox.lat_lower_left), + "lonLowerLeft": float(search_bbox.long_lower_left), + "latUpperRight": float(search_bbox.lat_upper_right), + "lonUpperRight": float(search_bbox.long_upper_right), + }, + "observerPosition": { + "latitude": latitude, + "longitude": longitude, + "elevation": elevation, + }, + "isTestMode": test_mode, + } diff --git a/src/weather.py b/src/weather.py new file mode 100644 index 00000000..20adcfe1 --- /dev/null +++ b/src/weather.py @@ -0,0 +1,173 @@ +import os +from datetime import datetime, timedelta +from typing import Optional, Tuple + +import requests + +from src import logger +from src.constants import WEATHER_API_URL, WEATHER_CACHE_DURATION_MINUTES, WEATHER_ICONS + + +class WeatherCache: + """Simple cache for weather data to avoid excessive API calls.""" + + def __init__(self): + self._cache = {} + self._cache_time = {} + + def get(self, key: str) -> Optional[dict]: + if key not in self._cache: + return None + + cache_age = datetime.now() - self._cache_time[key] + if cache_age > timedelta(minutes=WEATHER_CACHE_DURATION_MINUTES): + logger.info("Weather cache expired") + del self._cache[key] + del self._cache_time[key] + return None + + logger.info("Using cached weather data") + return self._cache[key] + + def set(self, key: str, value: dict): + self._cache[key] = value + self._cache_time[key] = datetime.now() + + +# Global cache instance +_weather_cache = WeatherCache() + + +def get_weather_condition( + latitude: float, + longitude: float, + api_key: str, + return_default_response: bool = False, +) -> Tuple[bool, dict]: + """Fetch weather conditions from OpenWeatherMap API. + + Parameters + ---------- + latitude : float + Observer's latitude + longitude : float + Observer's longitude + api_key : str + OpenWeatherMap API key + return_default_response: bool + Flag to return default response, the same response when there's no API Key + + Returns + ------- + is_clear : bool + True if sky is clear enough for tracking + weather_info : dict + Dictionary containing: + - cloud_cover: percentage (0-100) + - condition: weather condition string + - icon: emoji icon for condition + - description: human-readable description + - api_success: whether API call succeeded + """ + cache_key = f"{latitude:.3f},{longitude:.3f}" + cached_data = _weather_cache.get(cache_key) + + if cached_data: + return cached_data["is_clear"], cached_data["info"] + + default_response = ( + True, + { + "cloud_cover": None, + "condition": "unknown", + "icon": WEATHER_ICONS["unknown"], + "description": "Weather API not configured", + "api_success": False, + }, + ) + + if return_default_response: + logger.warning("Returning default weather response") + return default_response + + if not api_key: + logger.warning("No OpenWeatherMap API key provided") + return default_response + + try: + params = { + "lat": latitude, + "lon": longitude, + "appid": api_key, + "units": "metric", + } + + response = requests.get(WEATHER_API_URL, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + cloud_cover = data.get("clouds", {}).get("all", 0) + weather_main = data.get("weather", [{}])[0].get("main", "Unknown").lower() + weather_desc = data.get("weather", [{}])[0].get("description", "Unknown") + + # Determine icon based on conditions + if weather_main in ["thunderstorm", "drizzle", "rain"]: + if "thunder" in weather_main: + icon = WEATHER_ICONS["thunderstorm"] + else: + icon = WEATHER_ICONS["rain"] + condition = weather_main + elif weather_main == "snow": + icon = WEATHER_ICONS["snow"] + condition = "snow" + elif weather_main == "clouds": + if cloud_cover < 30: + icon = WEATHER_ICONS["partly_cloudy"] + condition = "partly_cloudy" + else: + icon = WEATHER_ICONS["clouds"] + condition = "clouds" + elif weather_main == "clear": + icon = WEATHER_ICONS["clear"] + condition = "clear" + else: + icon = WEATHER_ICONS["unknown"] + condition = "unknown" + + cloud_threshold = int(os.getenv("CLOUD_COVER_THRESHOLD", 85)) + is_clear = cloud_cover < cloud_threshold + + weather_info = { + "cloud_cover": cloud_cover, + "condition": condition, + "icon": icon, + "description": weather_desc, + "api_success": True, + } + + # Cache the result + _weather_cache.set(cache_key, {"is_clear": is_clear, "info": weather_info}) + + logger.info( + f"Weather: {weather_desc}, cloud cover: {cloud_cover}%, clear: {is_clear}" + ) + return is_clear, weather_info + + except requests.RequestException as e: + logger.error(f"Weather API request failed: {str(e)}") + return True, { + "cloud_cover": None, + "condition": "unknown", + "icon": WEATHER_ICONS["unknown"], + "description": f"Weather API error: {str(e)}", + "api_success": False, + } + except Exception as e: + logger.error(f"Unexpected error fetching weather: {str(e)}") + return True, { + "cloud_cover": None, + "condition": "unknown", + "icon": WEATHER_ICONS["unknown"], + "description": f"Weather check failed: {str(e)}", + "api_success": False, + } diff --git a/static/app.js b/static/app.js index 779ae32a..dbdc5b3a 100644 --- a/static/app.js +++ b/static/app.js @@ -2,29 +2,49 @@ const COLUMN_NAMES = [ "id", "origin", "destination", - "alt_diff", - "az_diff", "time", + "angular_separation", "target_alt", "plane_alt", + "alt_diff", "target_az", "plane_az", + "az_diff", + "aircraft_elevation_km", "elevation_change", "direction", + "speed", + "distance_km", ]; const MS_IN_A_MIN = 60000; +const DEFAULT_MIN_ALT = 15; +const DEFAULT_INTERVAL_MINUTES = 10; + +// State tracking for toggles +var resultsVisible = false; +var mapVisible = false; + // Possibility levels const LOW_LEVEL = 1, MEDIUM_LEVEL = 2, HIGH_LEVEL = 3; var autoMode = false; -var target = getLocalStorageItem("target", "moon"); -var autoGoInterval = setInterval(go, 86400000); +var target = getLocalStorageItem("target", "auto"); +var autoGoInterval = setInterval(goFetch, 86400000); var refreshTimerLabelInterval = setInterval(refreshTimer, MS_IN_A_MIN); + // By default disable auto go and refresh timer label clearInterval(autoGoInterval); clearInterval(refreshTimerLabelInterval); displayTarget(); +// While loading the page, play silently as answer to any click (Hack for Safari) +document.addEventListener('click', function unlockAudio() { + const audio = document.getElementById('alertSound'); + audio.play().then(() => audio.pause()); + document.removeEventListener('click', unlockAudio); +}, { once: true }); + + function savePosition() { let lat = document.getElementById("latitude"); let latitude = parseFloat(lat.value); @@ -32,6 +52,8 @@ function savePosition() { let longitude = parseFloat(long.value); let elev = document.getElementById("elevation"); let elevation = parseFloat(elev.value); + let minAlt = document.getElementById("minAltitude"); + let minAltitude = parseFloat(minAlt.value) || DEFAULT_MIN_ALT; if(isNaN(latitude) || isNaN(longitude) || isNaN(elevation)) { alert("Please, type all your coordinates. Use MAPS.ie or Google Earth"); @@ -41,25 +63,46 @@ function savePosition() { localStorage.setItem("latitude", latitude); localStorage.setItem("longitude", longitude); localStorage.setItem("elevation", elevation); + localStorage.setItem("minAltitude", minAltitude); alert("Position saved in local storage!"); } -function loadPosition() { - if(isNaN(localStorage.getItem("latitude"))) { - console.log("not position saved in local storage"); +function loadPositionAndBbox() { + const savedLat = localStorage.getItem("latitude"); + const savedLon = localStorage.getItem("longitude"); + const savedElev = localStorage.getItem("elevation"); + const savedMinAlt = localStorage.getItem("minAltitude"); + const savedBoundingBox = localStorage.getItem("boundingBox"); + + if (savedLat === null || savedLat === "" || savedLat === "null") { + console.log("No position saved in local storage"); + document.getElementById("minAltitude").value = DEFAULT_MIN_ALT; // Default return; } - let lat = document.getElementById("latitude"); - let long = document.getElementById("longitude"); - let elev = document.getElementById("elevation"); + document.getElementById("latitude").value = savedLat; + document.getElementById("longitude").value = savedLon; + document.getElementById("elevation").value = savedElev; + document.getElementById("minAltitude").value = savedMinAlt || DEFAULT_MIN_ALT; - lat.value = localStorage.getItem("latitude"); - long.value = localStorage.getItem("longitude"); - elev.value = localStorage.getItem("elevation"); + // Load saved bounding box + if (savedBoundingBox) { + try { + window.boundingBox = JSON.parse(savedBoundingBox); + console.log("Bounding box loaded from local storage:", window.boundingBox); + } + catch (e) { + console.error("Error parsing saved bounding box:", e); + } + } - console.log("Position loaded from local storage"); + // Load ignore weather check + const weatherCheckBox = document.getElementById('checkWeather'); + const checkWeather = localStorage.getItem('checkWeather'); + if (checkWeather === 'true') weatherCheckBox.checked = true; + + console.log("Position loaded from local storage:", savedLat, savedLon, savedElev, "minAlt:", savedMinAlt); } function getLocalStorageItem(key, defaultValue) { @@ -73,9 +116,18 @@ function clearPosition() { document.getElementById("latitude").value = ""; document.getElementById("longitude").value = ""; document.getElementById("elevation").value = ""; + document.getElementById("minAltitude").value = DEFAULT_MIN_ALT.toString(); + + // reset bounding box + window.boundingBox = null; } function go() { + // Refresh flight data + const resultsDiv = document.getElementById("results"); + const mapContainer = document.getElementById("mapContainer"); + + // Validate coordinates first let lat = document.getElementById("latitude"); let latitude = parseFloat(lat.value); @@ -84,12 +136,40 @@ function go() { return; } + // Show results and map if not already visible + if (!resultsVisible) { + resultsVisible = true; + mapVisible = true; + resultsDiv.style.display = 'block'; + mapContainer.style.display = 'block'; + } + + // Always fetch fresh data + fetchFlights(); +} + +function goFetch() { + // Internal function for auto mode - just fetches without toggling + let lat = document.getElementById("latitude"); + let latitude = parseFloat(lat.value); + + if(isNaN(latitude)) { + return; + } + + // Auto-show results if in auto mode + if (autoMode && !resultsVisible) { + resultsVisible = true; + mapVisible = true; + document.getElementById("results").style.display = 'block'; + document.getElementById("mapContainer").style.display = 'block'; + } + fetchFlights(); } function auto() { if(autoMode == true) { - document.getElementById("goBtn").style.display = 'inline-block'; document.getElementById("autoBtn").innerHTML = 'Auto'; document.getElementById("autoGoNote").innerHTML = ""; @@ -98,9 +178,19 @@ function auto() { clearInterval(refreshTimerLabelInterval); } else { - document.getElementById("goBtn").style.display = 'none'; - - let freq = prompt("Enter a frequency in minutes, recommended 15"); + // Get saved frequency or choose default value + const savedFreq = localStorage.getItem("frequency") || DEFAULT_INTERVAL_MINUTES; + + let freq = prompt( + `Enter refresh interval in minutes\n` + + `Recommended: 6-10 min for continuous monitoring`, + savedFreq + ); + + // User cancelled + if (freq === null) { + return; + } try { freq = parseInt(freq); @@ -116,10 +206,10 @@ function auto() { localStorage.setItem("frequency", freq); document.getElementById("autoBtn").innerHTML = "Auto " + freq + " min β΄΅"; - document.getElementById("autoGoNote").innerHTML = `Auto check every ${freq} minute(s).`; + document.getElementById("autoGoNote").innerHTML = `Auto-refresh every ${freq} m.`; autoMode = true; - autoGoInterval = setInterval(go, MS_IN_A_MIN * freq); + autoGoInterval = setInterval(goFetch, MS_IN_A_MIN * freq); refreshTimerLabelInterval = setInterval(refreshTimer, MS_IN_A_MIN); } } @@ -139,45 +229,157 @@ function fetchFlights() { let latitude = document.getElementById("latitude").value; let longitude = document.getElementById("longitude").value; let elevation = document.getElementById("elevation").value; + let adsbProvider = document.getElementById("adsbProvider").value; let hasVeryPossibleTransits = false; const bodyTable = document.getElementById('flightData'); let alertNoResults = document.getElementById("noResults"); - let alertTargetUnderHorizon = document.getElementById("targetUnderHorizon"); bodyTable.innerHTML = ''; alertNoResults.innerHTML = ''; - alertTargetUnderHorizon = ''; + let checkWeather = localStorage.getItem('checkWeather') === "true"; - const endpoint_url = ( + const minAltitude = document.getElementById("minAltitude").value || DEFAULT_MIN_ALT; + let endpoint_url = ( `/flights?target=${encodeURIComponent(target)}` + `&latitude=${encodeURIComponent(latitude)}` + `&longitude=${encodeURIComponent(longitude)}` + `&elevation=${encodeURIComponent(elevation)}` - + `&send-notification=${autoMode}` + + `&min_altitude=${encodeURIComponent(minAltitude)}` + + `&send_notification=${autoMode}` + + `&adsb_provider=${adsbProvider}` + + `&check_weather=${checkWeather}` ); + // Add custom bounding box if user has edited it + if (window.boundingBox) { + endpoint_url += `&bbox_lat_lower_left=${encodeURIComponent(window.boundingBox.latLowerLeft)}`; + endpoint_url += `&bbox_lon_lower_left=${encodeURIComponent(window.boundingBox.lonLowerLeft)}`; + endpoint_url += `&bbox_lat_upper_right=${encodeURIComponent(window.boundingBox.latUpperRight)}`; + endpoint_url += `&bbox_lon_upper_right=${encodeURIComponent(window.boundingBox.lonUpperRight)}`; + } + + // Show loading spinner + document.getElementById("loadingSpinner").style.display = "block"; + document.getElementById("results").style.display = "none"; + fetch(endpoint_url) .then(response => response.json()) .then(data => { + // Hide loading spinner + document.getElementById("loadingSpinner").style.display = "none"; + document.getElementById("results").style.display = "block"; if(data.flights.length == 0) { - alertNoResults.innerHTML = "No flights!" + alertNoResults.innerHTML = "No flights!"; + + if(mapVisible) clearExistingAircraftMarkers(); } - if(data.targetCoordinates.altitude < 0) { - alertNoResults.innerHTML = "The " + target + " is under horizon! No flights to check..." + // Display tracking status - Sun and Moon with weather + renderTrackingStatus(data); + + // Display coordinates for targets (alt/az) + renderTargetCoordinates(data.targetCoordinates); + + // Check if any targets are trackable + if(data.trackingTargets && data.trackingTargets.length === 0) { + alertNoResults.innerHTML = "No targets available for tracking (below horizon or weather)"; } - data.flights.forEach(item => { + // Deduplicate flights by ID for display (keep the ones with lower angular separation considering both targets) + const uniqueFlights = deduplicateFlights(data.flights); + console.log(`Dedupe: ${data.flights.length} flights -> ${uniqueFlights.length} unique`); + + uniqueFlights.forEach(item => { const row = document.createElement('tr'); + // Store normalized flight ID and possibility level for cross-referencing + const normalizedId = String(item.id).trim().toUpperCase(); + const possibilityLevel = item.is_possible_transit === 1 ? parseInt(item.possibility_level) : 0; + row.setAttribute('data-flight-id', normalizedId); + row.setAttribute('data-possibility', possibilityLevel); + + // Click handler: normal click flashes, Cmd/Ctrl+click toggles tracking + row.addEventListener('click', function(e) { + // flash aircraft on map + if (typeof flashAircraftMarker === 'function') { + flashAircraftMarker(normalizedId); + } + }); + + // Add target emoji as first column + const targetCell = document.createElement("td"); + if (item.target === "moon") targetCell.textContent = "πŸŒ™"; + else if (item.target === "sun") targetCell.textContent = "β˜€οΈ"; + else targetCell.textContent = ""; + row.appendChild(targetCell); + COLUMN_NAMES.forEach(column => { const val = document.createElement("td"); + const value = item[column]; - if(column == "direction") val.textContent = item[column] + "Β°"; - else if(item[column] == "N/D") val.textContent = item[column] + " ⚠️"; - else val.textContent = item[column]; + if (value === null || value === undefined) { + val.textContent = ""; + } + else if (column === "id") { + // Show "ID (TYPE)" format + const aircraftType = item.aircraft_type || ""; + val.textContent = aircraftType && aircraftType !== "N/A" ? `${value} (${aircraftType})` : value; + } + else if (column === "time") { + // Format ETA as mm:ss + const totalSeconds = Math.round(value * 60); + const mins = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + val.textContent = `${mins}:${secs.toString().padStart(2, '0')}`; + } + else if (column === "aircraft_elevation_km") { + // Show GPS altitude in km with comma formatting + val.textContent = value.toLocaleString('en-US') + " km"; + } + else if (column === "distance_km") { + // Show distance in kilometers with one decimal place + val.textContent = value.toFixed(1) + " km"; + } + else if (column === "direction") { + val.textContent = Math.round(value) + "Β°"; + } + else if (column === "speed") { + // Show speed in km/h, rounded to whole number + val.textContent = Math.round(value) + " km/h"; + } + else if (column === "alt_diff" || column === "az_diff" || column === "angular_separation") { + val.textContent = value + "ΒΊ"; + // Color code large angle differences + if (Math.abs(value) > 10) { + val.style.color = "#888"; // Gray for large differences + } + } + else if (column === "target_alt" || column === "target_az") { + // Always show target values, color code negative/invalid + const numValue = value.toFixed(1); + val.textContent = numValue + "ΒΊ"; + if (value < 0) { + val.style.color = "#888"; // Gray for below horizon + val.style.fontStyle = "italic"; + } + } + else if (column === "plane_alt" || column === "plane_az") { + // Always show plane values, color code negative/invalid + const numValue = value.toFixed(1); + val.textContent = numValue + "ΒΊ"; + if (value < 0) { + val.style.color = "#888"; // Gray for negative angles + val.style.fontStyle = "italic"; + } + } + else if (value === "N/D") { + val.textContent = value + " ⚠️"; + } + else { + val.textContent = value; + } row.appendChild(val); }); @@ -194,46 +396,216 @@ function fetchFlights() { bodyTable.appendChild(row); }); - renderTargetCoordinates(data.targetCoordinates); if(autoMode == true && hasVeryPossibleTransits == true) soundAlert(); + + // Save bounding box if previously there's no bbox + if(!window.boundingBox) { + window.boundingBox = data.boundingBox; + localStorage.setItem("boundingBox", JSON.stringify(window.boundingBox)); + } + + // Always update map visualization when data is fetched (use deduplicated flights) + if(mapVisible) { + const mapData = {...data, flights: uniqueFlights}; + updateMapVisualization( + mapData, parseFloat(latitude), parseFloat(longitude), parseFloat(elevation), window.boundingBox + ); + + // Update altitude display + updateAltitudeDisplay(data.flights); + } + }) + .catch(error => { + // Hide loading spinner on error + document.getElementById("loadingSpinner").style.display = "none"; + document.getElementById("results").style.display = "block"; + alert("Error getting flight data. Check console for details."); + console.error("Error:", error); }); } +function renderTrackingStatus(data) { + let trackingParts = []; + + // Show Sun status + if(data.targetCoordinates && data.targetCoordinates.sun) { + let isTracking = data.trackingTargets && data.trackingTargets.includes('sun'); + let status = isTracking ? "Tracking" : "Not tracking"; + trackingParts.push(`β˜€οΈ ${status}`); + } + + // Show Moon status + if(data.targetCoordinates && data.targetCoordinates.moon) { + let isTracking = data.trackingTargets && data.trackingTargets.includes('moon'); + let status = isTracking ? "Tracking" : "Not tracking"; + trackingParts.push(`πŸŒ™ ${status}`); + } + + // Weather status + if(data.weather && data.weather.cloud_cover !== null) { + trackingParts.push(`☁️ ${data.weather.cloud_cover}% clouds`); + } + + // test mode + if(data.isTestMode) { + trackingParts.push("πŸ§ͺ") + } + + document.getElementById("trackingStatus").innerHTML = trackingParts.join("    "); +} + +function renderTargetCoordinates(targetCoordinates) { + let time_ = (new Date()).toLocaleTimeString(); + let coordParts = []; + + // Always show Sun coordinates + if(targetCoordinates && targetCoordinates.sun) { + let coords = targetCoordinates.sun; + let altStr = coords.altitude !== null && coords.altitude !== undefined ? coords.altitude.toFixed(1) : "β€”"; + let azStr = coords.azimuthal !== null && coords.azimuthal !== undefined ? coords.azimuthal.toFixed(1) : "β€”"; + coordParts.push(`β˜€οΈ Alt: ${altStr}Β° Az: ${azStr}Β°`); + } + + // Always show Moon coordinates + if(targetCoordinates && targetCoordinates.moon) { + let coords = targetCoordinates.moon; + let altStr = coords.altitude !== null && coords.altitude !== undefined ? coords.altitude.toFixed(1) : "β€”"; + let azStr = coords.azimuthal !== null && coords.azimuthal !== undefined ? coords.azimuthal.toFixed(1) : "β€”"; + coordParts.push(`πŸŒ™ Alt: ${altStr}Β° Az: ${azStr}Β°`); + } + + // Display time when the coordinates where checked + if(coordParts.length > 0) { + coordParts.push(`⏰ ${time_}`); + } + + document.getElementById("targetCoordinates").innerHTML = coordParts.join("    "); +} + +function deduplicateFlights(flights) { + const seenFlights = {}; + + flights.forEach(flight => { + // Normalize ID (trim whitespace, consistent case) + const id = String(flight.id).trim().toUpperCase(); + if (!seenFlights[id]) { + seenFlights[id] = flight; + } + else { + // Keep the one with lower angular separation + const existing = seenFlights[id]; + if (flight.angular_separation < existing.angular_separation) { + seenFlights[id] = flight; + } + } + }); + + return Object.values(seenFlights); +} + function highlightPossibleTransit(possibilityLevel, row) { if(possibilityLevel == LOW_LEVEL) row.classList.add("possibleTransitHighlightLow"); else if(possibilityLevel == MEDIUM_LEVEL) row.classList.add("possibleTransitHighlightMedium"); else if(possibilityLevel == HIGH_LEVEL) row.classList.add("possibleTransitHighlightHigh"); } +function updateAltitudeDisplay(flights) { + const barsContainer = document.getElementById("altitudeBars"); + + if (!flights || flights.length === 0) { + barsContainer.innerHTML = ""; + return; + } + + // Clear existing lines + barsContainer.innerHTML = ""; + + // Maximum altitude for scale (FL450 = 45,000 ft, 13.716 km) + const MAX_ALTITUDE_KM = 15; + + // Create a thin line for each aircraft + flights.forEach(flight => { + const altitude = flight.aircraft_elevation_km || 0; + + // Skip if altitude is invalid or above max + if (altitude > MAX_ALTITUDE_KM) return; + + // Calculate position from bottom (0 = ground, 100% = FL450) + // Clamp negative altitudes to 0% (bottom) + const clampedAltitude = Math.max(0, altitude); + const percentFromBottom = (clampedAltitude / MAX_ALTITUDE_KM) * 100; + + // Create line element + const line = document.createElement("div"); + line.style.position = "absolute"; + line.style.bottom = percentFromBottom + "%"; + line.style.left = "0"; + line.style.right = "0"; + line.style.height = "2px"; + line.style.cursor = "pointer"; + line.style.transition = "height 0.2s, opacity 0.2s"; + line.title = flight.id; + + // Color based on possibility level + let color = "#666"; // Default gray for unlikely + const possibilityLevel = parseInt(flight.possibility_level || 0); + if (possibilityLevel === HIGH_LEVEL) { + color = "#32CD32"; // Green + } + else if (possibilityLevel === MEDIUM_LEVEL) { + color = "#FF8C00"; // Orange + } + else if (possibilityLevel === LOW_LEVEL) { + color = "#FFD700"; // Yellow + } + line.style.background = color; + + // Hover effect + line.addEventListener('mouseenter', () => { + line.style.height = "4px"; + line.style.opacity = "1"; + }); + line.addEventListener('mouseleave', () => { + line.style.height = "2px"; + line.style.opacity = "0.9"; + }); + + // Add click handler to flash aircraft on map + const normalizedId = flight.id.replace(/[^a-zA-Z0-9]/g, '_'); + line.addEventListener('click', () => { + if (typeof flashAircraftMarker === 'function') { + flashAircraftMarker(normalizedId); + } + }); + + line.style.opacity = "0.9"; + barsContainer.appendChild(line); + }); +} + function toggleTarget() { if(target == "moon") target = "sun"; + else if(target == "sun") target = "auto"; else target = "moon"; document.getElementById("targetCoordinates").innerHTML = ""; + document.getElementById("trackingStatus").innerHTML = ""; displayTarget(); resetResultsTable(); } -function renderTargetCoordinates(coordinates) { - let alt = coordinates.altitude; - let az = coordinates.azimuthal; - let time_ = (new Date()).toLocaleTimeString(); - const coordinates_str = "altitude: " + alt + "Β° azimuthal: " + az + "Β° (" + time_ + ")"; - - document.getElementById("targetCoordinates").innerHTML = coordinates_str; -} - function displayTarget() { if(target == "moon") { document.getElementById("targetIcon").innerHTML = "πŸŒ™"; } - else { + else if(target == "sun") { document.getElementById("targetIcon").innerHTML = "β˜€οΈ"; } - + else { + document.getElementById("targetIcon").innerHTML = "πŸŒ™β˜€οΈ"; + } localStorage.setItem("target", target); - document.getElementById("targetLabel").innerHTML = target; } function resetResultsTable() { @@ -243,4 +615,11 @@ function resetResultsTable() { function soundAlert() { const audio = document.getElementById('alertSound'); audio.play(); -} \ No newline at end of file +} + +// Check weather preference +function toggleCheckWeather() { + const checkbox = document.getElementById('checkWeather'); + localStorage.setItem('checkWeather', checkbox.checked); + console.log('Check weather:', checkbox.checked); +} diff --git a/static/gallery.css b/static/gallery.css new file mode 100644 index 00000000..616ebfce --- /dev/null +++ b/static/gallery.css @@ -0,0 +1,221 @@ +/* Gallery grid */ +.gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; + padding: 12px; + max-width: 1400px; + margin: 0 auto; +} + +.gallery-card { + background: #1a1e24; + border: 1px solid #2c3038; + border-radius: 6px; + overflow: hidden; + cursor: pointer; + transition: border-color 0.15s, transform 0.15s; +} + +.gallery-card:hover { + border-color: #039c9c; + transform: translateY(-2px); +} + +.gallery-card img { + width: 100%; + height: 160px; + object-fit: cover; + display: block; + background: #222; +} + +.gallery-card-body { + padding: 8px 10px; +} + +.gallery-card-title { + font-size: 0.85em; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 3px; +} + +.gallery-card-sub { + font-size: 0.75em; + color: #888; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gallery-card-target { + font-size: 1em; + margin-right: 4px; +} + +.gallery-empty { + color: #666; + margin-top: 60px; + font-size: 1em; +} + +/* Controls */ +#filterTarget, +#sortOrder { + padding: 4px 6px; + border: 1px solid #444; + border-radius: 3px; + background: #222; + color: white; + font-size: 0.85em; + cursor: pointer; +} + +/* Lightbox */ +.lightbox { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.85); + z-index: 1000; + align-items: center; + justify-content: center; + padding: 16px; +} + +.lightbox.open { + display: flex; +} + +.lightbox-content { + background: #1a1e24; + border: 1px solid #2c3038; + border-radius: 8px; + max-width: 860px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + display: flex; + flex-direction: column; + position: relative; + padding: 20px; + gap: 16px; +} + +@media (min-width: 640px) { + .lightbox-content { + flex-direction: row; + align-items: flex-start; + } +} + +.lightbox-content img { + width: 100%; + max-width: 420px; + border-radius: 4px; + object-fit: contain; + flex-shrink: 0; + background: #111; +} + +.lightbox-close { + position: absolute; + top: 10px; + right: 12px; + background: none; + border: none; + color: #aaa; + font-size: 1.6em; + cursor: pointer; + line-height: 1; + padding: 0; +} + +.lightbox-close:hover { + color: white; +} + +.lightbox-meta { + flex: 1; + min-width: 0; +} + +.meta-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85em; + margin-bottom: 12px; +} + +.meta-table td { + padding: 4px 6px; + vertical-align: top; +} + +.meta-table td:first-child { + color: #888; + white-space: nowrap; + width: 110px; +} + +.meta-table tr:nth-child(odd) td { + background: rgba(255,255,255,0.03); +} + +.lightbox-actions { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} + +.btn-danger { + background: #7a2020; +} + +.btn-danger:hover { + background: #963030; +} + +/* Edit / Upload form */ +.edit-form { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 0.85em; +} + +.edit-form label { + display: flex; + flex-direction: column; + gap: 3px; + color: #aaa; + text-align: left; +} + +.edit-form input, +.edit-form select { + padding: 5px 8px; + border: 1px solid #444; + border-radius: 3px; + background: #222; + color: white; + font-size: 1em; +} + +.upload-preview { + width: 100%; + max-height: 200px; + object-fit: contain; + border-radius: 4px; + background: #111; + margin-bottom: 4px; +} + +.upload-error { + color: #ea4365; + font-size: 0.85em; +} diff --git a/static/gallery.js b/static/gallery.js new file mode 100644 index 00000000..4f9a24db --- /dev/null +++ b/static/gallery.js @@ -0,0 +1,275 @@ +let allImages = []; +let currentImage = null; + +// Load gallery on page load +loadGallery(); + +function loadGallery() { + document.getElementById('loadingSpinner').style.display = 'block'; + document.getElementById('galleryGrid').innerHTML = ''; + document.getElementById('emptyState').style.display = 'none'; + + fetch('/gallery/list') + .then(r => r.json()) + .then(images => { + allImages = images; + document.getElementById('loadingSpinner').style.display = 'none'; + renderGallery(); + }) + .catch(err => { + document.getElementById('loadingSpinner').style.display = 'none'; + console.error('Error loading gallery:', err); + }); +} + +function renderGallery() { + const filterTarget = document.getElementById('filterTarget').value; + const sortOrder = document.getElementById('sortOrder').value; + + let images = allImages.slice(); + + if (filterTarget) { + images = images.filter(img => img.metadata.target === filterTarget); + } + + images.sort((a, b) => { + const ta = a.metadata.transit_date || a.metadata.upload_date || ''; + const tb = b.metadata.transit_date || b.metadata.upload_date || ''; + return sortOrder === 'oldest' ? ta.localeCompare(tb) : tb.localeCompare(ta); + }); + + const grid = document.getElementById('galleryGrid'); + const empty = document.getElementById('emptyState'); + const count = document.getElementById('imageCount'); + + grid.innerHTML = ''; + + count.textContent = `${images.length} image${images.length !== 1 ? 's' : ''}`; + + if (images.length === 0) { + empty.style.display = 'block'; + return; + } + + empty.style.display = 'none'; + + images.forEach(img => { + const card = document.createElement('div'); + card.className = 'gallery-card'; + card.onclick = () => openLightbox(img); + + const targetEmoji = img.metadata.target === 'moon' ? 'πŸŒ™' + : img.metadata.target === 'sun' ? 'β˜€οΈ' + : ''; + + const flightLabel = img.metadata.flight_id || img.filename; + const dateLabel = img.metadata.transit_date || ''; + + card.innerHTML = ` + ${flightLabel} + `; + + grid.appendChild(card); + }); +} + +// ── Lightbox ────────────────────────────────────────────────────────────────── + +function openLightbox(img) { + currentImage = img; + + document.getElementById('lightboxImg').src = 'static/' + img.path; + document.getElementById('lightboxImg').alt = img.metadata.flight_id || img.filename; + + populateMetaTable(img.metadata); + + document.getElementById('lightboxMetaView').style.display = 'block'; + document.getElementById('lightboxMetaEdit').style.display = 'none'; + + document.getElementById('lightbox').classList.add('open'); +} + +function closeLightbox(event) { + if (event && event.target !== document.getElementById('lightbox')) return; + document.getElementById('lightbox').classList.remove('open'); + currentImage = null; +} + +function populateMetaTable(meta) { + const rows = [ + ['Flight', meta.flight_id], + ['Aircraft', meta.aircraft_type], + ['Target', targetLabel(meta.target)], + ['Transit date', meta.transit_date || ''], + ['Uploaded', meta.upload_date ? new Date(meta.upload_date).toLocaleString() : ''], + ['Caption', meta.caption], + ['Equipment', meta.equipment], + ['Transit date', meta.transit_date || ''], + ]; + + const table = document.getElementById('lightboxMetaTable'); + table.innerHTML = rows + .filter(([, v]) => v) + .map(([k, v]) => `${k}${v}`) + .join(''); +} + +function targetLabel(target) { + if (target === 'moon') return 'πŸŒ™ Moon'; + if (target === 'sun') return 'β˜€οΈ Sun'; + return target || ''; +} + +function todayISO() { + return new Date().toISOString().slice(0, 10); +} + +// ── Edit metadata ───────────────────────────────────────────────────────────── + +function openEditMode() { + if (!currentImage) return; + const meta = currentImage.metadata; + const form = document.getElementById('editForm'); + + form.flight_id.value = meta.flight_id || ''; + form.aircraft_type.value = meta.aircraft_type || ''; + form.target.value = meta.target || ''; + form.caption.value = meta.caption || ''; + form.equipment.value = meta.equipment || ''; + form.transit_date.value = meta.transit_date || todayISO(); + + document.getElementById('lightboxMetaView').style.display = 'none'; + document.getElementById('lightboxMetaEdit').style.display = 'block'; +} + +function closeEditMode() { + document.getElementById('lightboxMetaView').style.display = 'block'; + document.getElementById('lightboxMetaEdit').style.display = 'none'; +} + +function submitEdit(event) { + event.preventDefault(); + if (!currentImage) return; + + const form = document.getElementById('editForm'); + const data = new FormData(form); + + fetch(`/gallery/update/${currentImage.path}`, { method: 'POST', body: data }) + .then(r => r.json()) + .then(result => { + if (result.success) { + currentImage.metadata = { ...currentImage.metadata, ...result.metadata }; + // Update in allImages array too + const idx = allImages.findIndex(i => i.path === currentImage.path); + if (idx !== -1) allImages[idx].metadata = currentImage.metadata; + + populateMetaTable(currentImage.metadata); + closeEditMode(); + renderGallery(); + } else { + alert('Error saving: ' + (result.error || 'Unknown error')); + } + }) + .catch(err => { + console.error('Error updating metadata:', err); + alert('Error saving metadata.'); + }); +} + +// ── Delete ──────────────────────────────────────────────────────────────────── + +function confirmDelete() { + if (!currentImage) return; + const label = currentImage.metadata.flight_id || currentImage.filename; + if (!confirm(`Delete image "${label}"? This cannot be undone.`)) return; + + fetch(`/gallery/delete/${currentImage.path}`, { method: 'DELETE' }) + .then(r => r.json()) + .then(result => { + if (result.success) { + allImages = allImages.filter(i => i.path !== currentImage.path); + document.getElementById('lightbox').classList.remove('open'); + currentImage = null; + renderGallery(); + } else { + alert('Error deleting: ' + (result.error || 'Unknown error')); + } + }) + .catch(err => { + console.error('Error deleting image:', err); + alert('Error deleting image.'); + }); +} + +// ── Upload modal ────────────────────────────────────────────────────────────── + +function openUploadModal() { + document.getElementById('uploadForm').reset(); + document.getElementById('uploadTransitDate').value = todayISO(); + document.getElementById('uploadPreviewWrap').style.display = 'none'; + document.getElementById('uploadError').style.display = 'none'; + document.getElementById('uploadBtn').disabled = false; + document.getElementById('uploadBtn').textContent = 'Upload'; + document.getElementById('uploadModal').classList.add('open'); +} + +function closeUploadModal(event) { + if (event && event.target !== document.getElementById('uploadModal')) return; + document.getElementById('uploadModal').classList.remove('open'); +} + +// Image preview +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('uploadFile').addEventListener('change', function() { + const file = this.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = e => { + document.getElementById('uploadPreview').src = e.target.result; + document.getElementById('uploadPreviewWrap').style.display = 'block'; + }; + reader.readAsDataURL(file); + }); +}); + +function submitUpload(event) { + event.preventDefault(); + + const form = document.getElementById('uploadForm'); + const errorEl = document.getElementById('uploadError'); + const btn = document.getElementById('uploadBtn'); + + errorEl.style.display = 'none'; + + const data = new FormData(form); + + btn.disabled = true; + btn.textContent = 'Uploading…'; + + fetch('/gallery/upload', { method: 'POST', body: data }) + .then(r => r.json()) + .then(result => { + btn.disabled = false; + btn.textContent = 'Upload'; + if (result.success) { + document.getElementById('uploadModal').classList.remove('open'); + loadGallery(); + } else { + errorEl.textContent = result.error || 'Upload failed.'; + errorEl.style.display = 'block'; + } + }) + .catch(err => { + btn.disabled = false; + btn.textContent = 'Upload'; + console.error('Upload error:', err); + errorEl.textContent = 'Network error during upload.'; + errorEl.style.display = 'block'; + }); +} diff --git a/static/images/logo.svg b/static/images/logo.svg new file mode 100644 index 00000000..d47a2f0c --- /dev/null +++ b/static/images/logo.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Flymoon + + + + + Transit Tracker + + diff --git a/static/main.css b/static/main.css index 3f9d9028..8eca3201 100644 --- a/static/main.css +++ b/static/main.css @@ -1,32 +1,178 @@ body { text-align: center; - margin: auto 0; + margin: 0; + padding: 8px; background-color: #101418; color: white; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; } +/* Compact header row */ +.header-row { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 4px; +} + +.whiteLink { + color: white; +} + +.logo { + height: 40px; + width: auto; + vertical-align: middle; + margin-right: 10px; +} + +.target-icon { + font-size: 1.4em; + cursor: pointer; +} + +.status-text { + color: #888; + font-size: 0.85em; +} + +#trackingStatus { + flex: 1; + text-align: center; +} + +.coords-text { + color: #aaa; + font-size: 0.8em; + margin: 5px 0 !important; +} + +/* Controls row */ +.controls-row { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.control-box { + display: inline-block; + text-align: center; +} + +.control-label { + font-size: 0.7em; + color: #aaa; + margin-top: -2px; +} + +#checkWeather { + width: 18px; + height: 18px; + cursor: pointer; + margin-bottom: 10px; +} + +.controls-row input { + width: 70px; + padding: 4px 6px; + border: 1px solid #444; + border-radius: 3px; + background: #222; + color: white; + font-size: 0.85em; +} + +.btn-sm { + padding: 4px 8px; + border: none; + border-radius: 3px; + background: #4e6060; + color: white; + cursor: pointer; + font-size: 0.9em; +} + +.btn-sm:hover { + background: #5a7070; +} + +.btn-primary { + padding: 6px 14px; + border: none; + border-radius: 3px; + background: #039c9c; + color: white; + cursor: pointer; + font-size: 0.95em; + font-weight: bold; +} + +.btn-primary:hover { + background: #04b5b5; +} + +.separator { + color: #444; + margin: 0 4px; +} + +#loadingSpinner { + display: none; + text-align: center; + padding: 20px; +} + +#loadingSpinner > p { + margin-top: 10px; + color: #666; +} + +#spinnerAnimation { + display: inline-block; + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Results table */ #results { overflow-x: auto; - margin: 8px; + margin: 8px 0; + display: none; /* Hidden by default */ } #resultsTable { border-collapse: collapse; border-spacing: 0; width: 100%; + font-size: 0.85em; } #resultsTable th:first-child { - border-top-left-radius: 10px; + border-top-left-radius: 6px; } #resultsTable th:last-child { - border-top-right-radius: 10px; + border-top-right-radius: 6px; } #resultsTable th, td { text-align: left; - padding: 6px; + padding: 4px 6px; } #resultsTable tr { @@ -34,53 +180,23 @@ body { color: black; } -.primary { - font-size: 1.5em; - border-radius: 4px; - background-color: #039c9c; - border: none; - color: white; - padding: 10px; - text-align: center; - text-decoration: none; - display: inline-block; - margin-top: 5px; - margin-bottom: 20px; - cursor: pointer; -} - -.secondary { - font-size: 1em; - border-radius: 3px; +#resultsTable thead > tr { background-color: #4e6060; - border: none; color: white; - padding: 5px; - text-align: center; - text-decoration: none; - display: inline-block; - margin-top: 5px; - margin-bottom: 15px; - cursor: pointer; } .alert { background-color: rgb(234, 67, 101); font-weight: bold; color: rgb(255, 255, 255); -} - -#resultsTable thead > tr { - background-color: #4e6060; - color: white; -} - -#targetIcon { - cursor: pointer; + padding: 4px 8px; + border-radius: 3px; + display: inline-block; + margin: 4px; } .possibleTransitHighlightHigh { - background-color: rgb(146, 254, 184) !important; + background-color: rgb(0, 230, 118) !important; /* Bright green for HIGH */ } .possibleTransitHighlightMedium { @@ -91,42 +207,279 @@ body { background-color: rgb(236, 224, 113) !important; } -.light-text { - color: rgb(213, 213, 213); - font-weight: normal; - font-style: oblique; +/* Map styles */ +#map { + position: relative; + z-index: 1; + height: 500px; + width: 100%; + background: #ddd; + flex: 1 !important; + margin: 20px 0 !important; } -.light-text-2 { - color: rgb(213, 213, 213); - font-weight: normal; +#mapContainer { + margin: 20px auto; + max-width: 1200px; + display: none; } -.center-div { - display: flex; - justify-content: center; - align-items: center; +#mapLegend { + border-radius: 4px; + color: #333; + padding: 10px !important; + background: #f0f0f0; + margin-bottom: 10px !important; } -.form-container { +#mapWithAlt { display: flex; - flex-direction: column; align-items: flex-start; - max-width: 300px; + gap: 0; +} + +/* Ensure Leaflet container renders properly */ +.leaflet-container { + height: 100%; + width: 100%; } -.form-group { +/* Leaflet icon overrides for dark theme */ +.leaflet-div-icon { + background: transparent; + border: none; +} + +.observer-icon, +.aircraft-icon, +.arrowhead-icon { + background: transparent; + border: none; +} + +/* Flash animation for table rows - 3 distinct flashes with clear gaps */ +@keyframes flash-row { + 0% { opacity: 1; } + 10% { opacity: 0.15; } + 15% { opacity: 1; } + 40% { opacity: 0.15; } + 45% { opacity: 1; } + 70% { opacity: 0.15; } + 75% { opacity: 1; } + 100% { opacity: 1; } +} + +.flash-row { + animation: flash-row 1.2s linear; +} + +/* Persistent highlight for selected aircraft row */ +#resultsTable tbody tr.selected-row { + border-left: 3px solid #87CEEB !important; +} + +/* Flash animation for map markers - 3 distinct flashes with clear gaps */ +@keyframes flash-marker { + 0% { + transform: scale(1); + opacity: 1; + } + 10% { + transform: scale(2); + opacity: 1; + } + 15% { + transform: scale(1); + opacity: 1; + } + 40% { + transform: scale(2); + opacity: 1; + } + 45% { + transform: scale(1); + opacity: 1; + } + 70% { + transform: scale(2); + opacity: 1; + } + 75% { + transform: scale(1); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.flash-marker { + animation: flash-marker 1.2s linear; +} + +/* Clickable table rows */ +#resultsTable tbody tr { + cursor: pointer; +} + +#resultsTable tbody tr:hover { + filter: brightness(0.9); +} + +/* Tracking mode indicator */ +.tracking-row { + outline: 3px solid cyan; + outline-offset: -3px; + animation: tracking-pulse 1s infinite; +} + +@keyframes tracking-pulse { + 0%, 100% { outline-color: cyan; } + 50% { outline-color: transparent; } +} + +/* Attribution footer */ +.attribution-footer { + margin-top: 40px; + padding: 20px 10px; + text-align: center; + color: #888; + font-size: 0.75em; + border-top: 1px solid #333; +} + +.attribution-footer p { + margin: 5px 0; +} + +/* Responsive - stack on mobile */ +@media (max-width: 600px) { + .attribution-footer { + font-size: 0.7em; + } + + .attribution-footer p { + margin: 8px 0; + } +} + +/* Altitude Overlay */ +#altitudeOverlay { + position: relative; + width: 60px; + height: 500px; + background: rgba(26, 26, 26, 0.95); + border-left: 2px solid #444; + padding: 5px; + margin-top: 20px; + margin-bottom: 20px; + flex-shrink: 0; + align-self: flex-start; +} + +.altitude-scale { + position: absolute; + left: 3px; + top: 30px; + bottom: 10px; + width: 20px; + border-left: 1px solid #666; +} + +.altitude-tick { + position: absolute; + left: 2px; + color: #888; + font-size: 0.55em; + transform: translateY(-50%); +} + +#altitudeBars { + position: absolute; + top: 30px; + bottom: 10px; + left: 25px; + right: 0; +} + +.altitude-bar { + position: relative; + height: 18px; + margin-bottom: 3px; + border-radius: 3px; display: flex; align-items: center; - margin-bottom: 15px; + justify-content: space-between; + padding: 0 4px; + cursor: pointer; + transition: transform 0.2s; } -.form-group label { - flex: 1; - min-width: 80px; - text-align: left; +.altitude-bar:hover { + transform: translateX(-5px); + filter: brightness(1.2); } -.form-group input { - flex: 2; +.altitude-bar-id { + font-size: 0.65em; + color: white; + font-weight: bold; + text-shadow: 0 0 3px black; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 50px; +} + +.altitude-bar-value { + font-size: 0.6em; + color: white; + text-shadow: 0 0 3px black; +} + +/* Mobile responsive */ +@media (max-width: 768px) { + #altitudeOverlay { + width: 50px; + } + .altitude-tick { + font-size: 0.5em; + } +} + +.legend-item { + margin-left: 10px; +} + +#legendItemBbox { + display: inline-block; + width: 16px; + height: 12px; + border: 2px dashed red; + vertical-align: middle; +} + +#legendItemMoon { + display: inline-block; + width: 20px; + height: 4px; + background: #FF4500; + vertical-align: middle; +} + +#legendItemSun { + display: inline-block; + width: 20px; + height: 4px; + background: #4169E1; + vertical-align: middle; +} + +#legendItemTrack { + display: inline-block; + width: 20px; + height: 3px; + background: #32CD32; + vertical-align: middle; } \ No newline at end of file diff --git a/static/map.js b/static/map.js new file mode 100644 index 00000000..ef35cb3c --- /dev/null +++ b/static/map.js @@ -0,0 +1,468 @@ +// Map visualization for Flymoon +// Shows observer location, bounding box, aircraft positions, and azimuth arrows +let map = null; +let observerMarker = null; +let boundingBoxLayer = null; +let azimuthArrows = {}; // Store arrows by target name +let aircraftMarkers = {}; +let mapInitialized = false; +let boundingBoxUserEdited = false; +let currentRouteLayer = null; // Currently displayed route/track + +// Arrow colors for each target +const ARROW_COLORS = { + sun: '#FF4500', // Orange-red + moon: '#4169E1' // Royal blue +}; + +// Color scheme for possibility levels +const COLORS = { + LOW: '#FFD700', // Yellow/Gold + MEDIUM: '#FF8C00', // Dark Orange + HIGH: '#32CD32', // Lime Green + DEFAULT: '#808080' // Gray +}; + +// Track currently selected row +let selectedRowId = null; + +// Flash a table row by flight ID and keep it highlighted +function flashTableRow(flightId) { + const row = document.querySelector(`tr[data-flight-id="${flightId}"]`); + if (row) { + // Remove highlight from previously selected row + if (selectedRowId && selectedRowId !== flightId) { + const prevRow = document.querySelector(`tr[data-flight-id="${selectedRowId}"]`); + if (prevRow) { + prevRow.classList.remove('selected-row'); + } + } + + // Flash animation + row.classList.remove('flash-row'); + void row.offsetWidth; // Trigger reflow + row.classList.add('flash-row'); + + // Add persistent highlight + row.classList.add('selected-row'); + selectedRowId = flightId; + + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +} + +// Flash an aircraft marker by flight ID +function flashAircraftMarker(flightId) { + const marker = aircraftMarkers[flightId]; + if (marker) { + const element = marker.getElement(); + if (element) { + // Apply animation to the inner div, not the positioned container + const innerDiv = element.querySelector('div'); + if (innerDiv) { + innerDiv.classList.remove('flash-marker'); + void innerDiv.offsetWidth; // Trigger reflow + innerDiv.classList.add('flash-marker'); + } + } + // Pan to marker + map.panTo(marker.getLatLng()); + } +} + +function initializeMap(centerLat, centerLon) { + if (mapInitialized) { + return; + } + + map = L.map('map', { + editable: true + }).setView([centerLat, centerLon], 9); + + // Add OpenStreetMap tiles + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19 + }).addTo(map); + + mapInitialized = true; +} + +function updateObserverMarker(lat, lon, elevation) { + if (!map) return; + + // Remove existing marker + if (observerMarker) { + map.removeLayer(observerMarker); + } + + // Create custom icon for observer + const observerIcon = L.divIcon({ + html: 'πŸ“', + iconSize: [30, 30], + className: 'observer-icon' + }); + + observerMarker = L.marker([lat, lon], { icon: observerIcon }) + .addTo(map) + .bindPopup(`Observer
Lat: ${lat.toFixed(4)}Β°
Lon: ${lon.toFixed(4)}Β°
Elev: ${elevation}m`); + + // Center map on observer + map.setView([lat, lon], map.getZoom()); +} + +function updateBoundingBox(latLowerLeft, lonLowerLeft, latUpperRight, lonUpperRight) { + if (!map) return; + + // Skip if user has manually edited the bounding box + if (boundingBoxUserEdited && boundingBoxLayer) { + return; + } + + // Remove existing bounding box + if (boundingBoxLayer) { + map.removeLayer(boundingBoxLayer); + } + + // Create rectangle for bounding box + const bounds = [ + [latLowerLeft, lonLowerLeft], + [latUpperRight, lonUpperRight] + ]; + + boundingBoxLayer = L.rectangle(bounds, { + color: '#FF0000', + weight: 2, + fillOpacity: 0.1, + dashArray: '5, 10' + }).addTo(map).bindPopup('Search Bounding Box
Drag corners to resize'); + + // Enable editing (draggable corners) + if (boundingBoxLayer.enableEdit) { + boundingBoxLayer.enableEdit(); + + // Track when user edits the bounding box + boundingBoxLayer.on('editable:vertex:dragend', function() { + boundingBoxUserEdited = true; + + // Save the new bounding box coordinates + const bounds = boundingBoxLayer.getBounds(); + const newBoundingBox = { + latLowerLeft: bounds.getSouth(), + lonLowerLeft: bounds.getWest(), + latUpperRight: bounds.getNorth(), + lonUpperRight: bounds.getEast() + }; + window.boundingBox = newBoundingBox; + localStorage.setItem("boundingBox", JSON.stringify(window.boundingBox)); + console.log("Bounding box updated:", newBoundingBox); + }); + } + + // Fit map to show both observer and bounding box + const extendedBounds = L.latLngBounds(bounds); + map.fitBounds(extendedBounds, { padding: [50, 50] }); +} + +function clearAzimuthArrows() { + // Remove all existing arrows + Object.values(azimuthArrows).forEach(arrow => { + if (arrow) map.removeLayer(arrow); + }); + azimuthArrows = {}; +} + +function updateAzimuthArrow(observerLat, observerLon, azimuth, altitude, targetName) { + if (!map) return; + + // Remove existing arrow for this target + if (azimuthArrows[targetName]) { + map.removeLayer(azimuthArrows[targetName]); + } + + // Calculate endpoint for arrow (15km in azimuth direction) + const distance = 15; // km + const endPoint = calculateDestination(observerLat, observerLon, azimuth, distance); + + // Create arrow polyline + const arrowPoints = [ + [observerLat, observerLon], + [endPoint.lat, endPoint.lon] + ]; + + const targetIcon = targetName === 'moon' ? 'πŸŒ™' : 'β˜€οΈ'; + const color = ARROW_COLORS[targetName] || '#FF4500'; + + azimuthArrows[targetName] = L.polyline(arrowPoints, { + color: color, + weight: 6, + opacity: 0.9 + }).addTo(map).bindPopup(`${targetIcon} ${targetName}
Altitude: ${altitude.toFixed(1)}Β°
Azimuth: ${azimuth.toFixed(1)}Β°`); +} + + +function clearExistingAircraftMarkers() { + Object.values(aircraftMarkers).forEach(marker => { + map.removeLayer(marker); + }); + aircraftMarkers = {}; +} + + +function updateAircraftMarkers(flights, observerLat, observerLon) { + if (!map) return; + + clearExistingAircraftMarkers(); + + // Add new aircraft markers + flights.forEach(flight => { + // Calculate current position (use flight data directly) + // For future position visualization, we'd need to add predicted coordinates + const flightId = flight.id; + + // Determine color based on possibility level + let color = COLORS.DEFAULT; + if (flight.is_possible_transit === 1) { + const level = parseInt(flight.possibility_level); + if (level === 1) color = COLORS.LOW; + else if (level === 2) color = COLORS.MEDIUM; + else if (level === 3) color = COLORS.HIGH; + } + + // Use diamond for transit aircraft (NTDS style), airplane emoji for others + // Airplane emoji points NE (~45Β°), so subtract 45 to align with compass heading + const isTransit = flight.is_possible_transit === 1; + const rotation = (flight.direction - 45); + + const aircraftIcon = L.divIcon({ + html: isTransit + ? `
β—†
` + : `
✈️
`, + iconSize: [36, 36], + iconAnchor: [18, 18], // Center the icon on coordinates + className: 'aircraft-icon' + }); + + // Add marker if we have coordinates (check for undefined/null, not falsy) + if (flight.latitude !== undefined && flight.latitude !== null && + flight.longitude !== undefined && flight.longitude !== null) { + const marker = L.marker([flight.latitude, flight.longitude], { icon: aircraftIcon }) + .addTo(map); + + // Add strong shadow for visibility + marker.getElement()?.style.setProperty('filter', `drop-shadow(0 0 8px ${color}) drop-shadow(0 0 4px rgba(0,0,0,0.8))`); + + // Store normalized ID for cross-referencing + const normalizedId = String(flightId).trim().toUpperCase(); + marker.flightId = normalizedId; + + // Click to flash table row + marker.on('click', function() { + flashTableRow(normalizedId); + }); + + aircraftMarkers[normalizedId] = marker; + } + }); +} + +function displayRouteTrack(data, flightId) { + if (!map) return; + + const layerGroup = L.layerGroup(); + + console.log('Route/Track data for', flightId, ':', data); + + // Display route (blue dashed) + if (data.route && !data.route.error) { + console.log('Route data:', data.route); + + // Check different possible response structures + const waypoints = data.route.waypoints || data.route.route_waypoints || []; + + if (waypoints.length > 0) { + const routePoints = waypoints + .filter(pt => pt.latitude != null && pt.longitude != null) + .map(pt => [pt.latitude, pt.longitude]); + + if (routePoints.length > 0) { + console.log('Drawing route with', routePoints.length, 'points'); + const routeLine = L.polyline(routePoints, { + color: '#4169E1', + weight: 3, + dashArray: '10, 10', + opacity: 0.7 + }); + layerGroup.addLayer(routeLine); + routeLine.bindPopup('πŸ“ Planned Route (' + routePoints.length + ' waypoints)'); + } + else { + console.log('Route has waypoints but no valid lat/lon coordinates'); + } + } + else { + console.log('No waypoints in route data. Route may not be available for this flight.'); + } + } + else if (data.route && data.route.error) { + console.log('Route error:', data.route.error); + } + else { + console.log('No route data available'); + } + + // Display track (green solid with dots) + if (data.track && !data.track.error) { + console.log('Track data:', data.track); + + const positions = data.track.positions || []; + + if (positions.length > 0) { + const trackPoints = positions + .filter(pt => pt.latitude != null && pt.longitude != null) + .map(pt => [pt.latitude, pt.longitude]); + + if (trackPoints.length > 0) { + console.log('Drawing track with', trackPoints.length, 'positions'); + const trackLine = L.polyline(trackPoints, { + color: '#32CD32', + weight: 3, + opacity: 0.8 + }); + layerGroup.addLayer(trackLine); + trackLine.bindPopup('✈️ Historical Track (' + trackPoints.length + ' positions)'); + + // Add position dots every 10th point + positions.forEach((pt, idx) => { + if (idx % 10 === 0 && pt.latitude != null && pt.longitude != null) { + const dot = L.circleMarker([pt.latitude, pt.longitude], { + radius: 3, + fillColor: '#32CD32', + color: 'white', + weight: 1, + fillOpacity: 0.8 + }); + layerGroup.addLayer(dot); + } + }); + } + else { + console.log('Track has positions but no valid lat/lon coordinates'); + } + } + else { + console.log('No positions in track data'); + } + } + else if (data.track && data.track.error) { + console.log('Track error:', data.track.error); + } + else { + console.log('No track data available'); + } + + layerGroup.addTo(map); + layerGroup.flightId = flightId; + currentRouteLayer = layerGroup; +} + +// Haversine formula to calculate destination point given start, bearing, and distance +function calculateDestination(lat, lon, bearing, distance) { + const R = 6371; // Earth's radius in km + const d = distance / R; // Angular distance + const brng = bearing * Math.PI / 180; // Convert to radians + const lat1 = lat * Math.PI / 180; + const lon1 = lon * Math.PI / 180; + + const lat2 = Math.asin( + Math.sin(lat1) * Math.cos(d) + + Math.cos(lat1) * Math.sin(d) * Math.cos(brng) + ); + + const lon2 = lon1 + Math.atan2( + Math.sin(brng) * Math.sin(d) * Math.cos(lat1), + Math.cos(d) - Math.sin(lat1) * Math.sin(lat2) + ); + + return { + lat: lat2 * 180 / Math.PI, + lon: lon2 * 180 / Math.PI + }; +} + +function toggleMap() { + const mapContainer = document.getElementById('mapContainer'); + const altOverlay = document.getElementById('altitudeOverlay'); + const isHidden = mapContainer.style.display === 'none'; + + if (isHidden) { + mapVisible = true; + mapContainer.style.display = 'block'; + if (altOverlay) altOverlay.style.display = 'block'; + + // Initialize map if not already done + const lat = parseFloat(document.getElementById('latitude').value); + const lon = parseFloat(document.getElementById('longitude').value); + + if (!isNaN(lat) && !isNaN(lon)) { + if (!mapInitialized) { + initializeMap(lat, lon); + } + // Refresh map display + setTimeout(() => { + if (map) map.invalidateSize(); + }, 100); + } + else { + alert('Please enter your coordinates first'); + mapVisible = false; + mapContainer.style.display = 'none'; + if (altOverlay) altOverlay.style.display = 'none'; + } + } + else { + mapVisible = false; + mapContainer.style.display = 'none'; + if (altOverlay) altOverlay.style.display = 'none'; + } +} + +// Update map with all data from API response +function updateMapVisualization(data, observerLat, observerLon, observerElev, bbox) { + if (!map || !mapInitialized) { + initializeMap(observerLat, observerLon); + } + + updateObserverMarker(observerLat, observerLon, observerElev); + + // Update bounding box if provided + if (bbox) { + updateBoundingBox( + data.boundingBox.latLowerLeft, + data.boundingBox.lonLowerLeft, + data.boundingBox.latUpperRight, + data.boundingBox.lonUpperRight + ); + } + + // Update azimuth arrows - one for each trackable target + clearAzimuthArrows(); + if (data.targetCoordinates && data.trackingTargets) { + // Show arrow for each target that is currently being tracked (above horizon) + data.trackingTargets.forEach(targetName => { + const coords = data.targetCoordinates[targetName]; + if (coords && coords.azimuthal !== undefined && coords.altitude !== undefined) { + updateAzimuthArrow(observerLat, observerLon, coords.azimuthal, coords.altitude, targetName); + } + }); + } + else if (data.targetCoordinates && data.targetCoordinates.azimuthal !== undefined) { + // Single target mode (legacy) + updateAzimuthArrow(observerLat, observerLon, data.targetCoordinates.azimuthal, data.targetCoordinates.altitude || 0, target); + } + + // Update aircraft markers + if (data.flights && data.flights.length > 0) { + updateAircraftMarkers(data.flights, observerLat, observerLon); + } +} diff --git a/templates/gallery.html b/templates/gallery.html new file mode 100644 index 00000000..7be735d5 --- /dev/null +++ b/templates/gallery.html @@ -0,0 +1,125 @@ + + + + + + Flymoon β€” Gallery + + + + + +
+ + Transit Image Gallery + + +
+ +
+
+ +
Filter target
+
+
+ +
Sort
+
+ | + +
+ +
+
+

Loading gallery...

+
+ + + + + + + + + + + + + + + + diff --git a/templates/index.html b/templates/index.html index 72a8f32e..5411b39d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -8,72 +8,154 @@ + + - -

✈️ Flymoon -

-

Target: -

-

+ +
+ + - + + πŸ“Έ Gallery +
-

My current position

-
-
-
- - -
-
- - -
-
- - -
-
+
+ + + | +
+ +
Observer Lat
+
+
+ +
Observer Lon
+
+
+ +
Elevation (m)
+
+
+ +
Min altitude (Β°)
+
+
+ +
Save
+
+
+ +
Clear
+
+ | +
+ +
Map
+
+
+ +
+ +
+ +
Check weather
+
+ | +
+ + +
+
+ + +
+
+

Loading flight data...

- - -
- - +

+ +
+
+
+ +
+
+
15km
+
10km
+
5km
+
0
+
+
+
+
+
+ Legend: + πŸ“ Observer + ✈️ Aircraft + β—† High + β—† Medium + β—† Low + Bounding box + Sun azimuth + Moon azimuth + Track +
+
- + + - - + + - + + + + +
idid (type) origin destinationβ–³altβ–³az ETAang sep target alt plane altβ–³alt target az plane azelev changeβ–³azelevβ–³elev dirspeeddist
-
-
ETA is in minutes.
-
Possible transits are highlighted by color: green for high probability, orange for medium, and yellow for low.
-
+

+
+

Flymoon 2.0

+

Concept and initial design by David Betancourt Montellano. Flymoon 2.0 includes contributions from Tom Harnish, whose ideas and pull requests helped improve the release 2.0.

+
+ + + + + + \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b