diff --git a/scripts/birdnet_analysis.py b/scripts/birdnet_analysis.py index df2921b5..5cdf305b 100644 --- a/scripts/birdnet_analysis.py +++ b/scripts/birdnet_analysis.py @@ -12,8 +12,8 @@ from server import load_global_model, run_analysis from utils.helpers import get_settings, ParseFileName, get_wav_files, ANALYZING_NOW, setup_logging -from utils.reporting import extract_detection, summary, write_to_file, write_to_db, apprise, bird_weather, heartbeat, \ - update_json_file +from utils.reporting import extract_detection, summary, write_to_file, write_to_db, apprise, \ + post_current_detections_to_birdweather, heartbeat, update_json_file shutdown = False @@ -114,7 +114,7 @@ def handle_reporting_queue(queue): write_to_file(file, detection) write_to_db(file, detection) apprise(file, detections) - bird_weather(file, detections) + post_current_detections_to_birdweather(file, detections) heartbeat() os.remove(file.file_name) except BaseException as e: diff --git a/scripts/utils/birdweather.py b/scripts/utils/birdweather.py new file mode 100644 index 00000000..f843b1ac --- /dev/null +++ b/scripts/utils/birdweather.py @@ -0,0 +1,123 @@ +"""Module to handle communication with the BirdWeather API.""" + +import requests +import logging +import datetime + +import gzip +from typing import Any, Dict, List, Optional +from .helpers import Detection + +log = logging.getLogger(__name__) + + +def get_birdweather_species_id(sci_name: str, com_name: str) -> int: + """Lookup a BirdWeather species ID based on the species scientific and common names.""" + species_url = "https://app.birdweather.com/api/v1/species/lookup" + try: + resp = requests.post( + url=species_url, + json={"species": [f"{sci_name}_{com_name}"]}, + timeout=20, + ) + data = resp.json() + if not data["success"] or len(data["species"]) != 1: + raise + species = next(iter(data["species"].values())) + return species["id"] + except Exception as e: + log.error(f"Couldn't find BirdWeather species ID for {sci_name}_{com_name}: {e}") + raise + + +def query_birdweather_detections( + birdweather_id: str, + species_id: int, + detection_datetime: datetime.datetime, +) -> List[Dict[str, Any]]: + """Query detections from the BirdWeather API for specific station, species and time.""" + detections_url = ( + f"https://app.birdweather.com/api/v1/stations/{birdweather_id}/detections" + ) + try: + resp = requests.get( + url=detections_url, + data={ + "speciesId": species_id, + "from": detection_datetime.isoformat(), + "to": detection_datetime.isoformat(), + }, + timeout=20, + ) + data = resp.json() + if not data["success"]: + raise + return data["detections"] + except Exception as e: + log.error(f"Could not lookup detections from BirdWeather: {e}") + raise + + +def post_soundscape_to_birdweather( + birdweather_id: str, detection_datetime: datetime.datetime, soundscape_file: str +) -> Optional[int]: + """Upload a soundscape file to BirdWeather.""" + soundscape_url = ( + f"https://app.birdweather.com/api/v1/stations/{birdweather_id}/" + f"soundscapes?timestamp={detection_datetime.isoformat()}" + ) + with open(soundscape_file, "rb") as f: + mp3_data = f.read() + gzip_mp3_data = gzip.compress(mp3_data) + try: + resp = requests.post( + url=soundscape_url, + data=gzip_mp3_data, + timeout=20, + headers={ + "Content-Type": "application/octet-stream", + "Content-Encoding": "gzip", + }, + ) + data = resp.json() + if not data.get("success"): + log.error(data.get("message")) + raise + return data["soundscape"]["id"] + except Exception as e: + log.error(f"Cannot POST soundscape: {e}") + return + + +def post_detection_to_birdweather( + detection: Detection, + soundscape_id: str, + soundscape_datetime: datetime.datetime, + birdweather_id: str, + latitude: float, + longitude: float, + model: str +): + """Upload a detection to BirdWeather.""" + + detection_url = f'https://app.birdweather.com/api/v1/stations/{birdweather_id}/detections' + + data = { + 'timestamp': detection.iso8601, + 'lat': latitude, + 'lon': longitude, + 'soundscapeId': soundscape_id, + 'soundscapeStartTime': (detection.start_datetime - soundscape_datetime).seconds, + 'soundscapeEndTime': (detection.stop_datetime - soundscape_datetime).seconds, + 'commonName': detection.common_name, + 'scientificName': detection.scientific_name, + 'algorithm': '2p4' if model == 'BirdNET_GLOBAL_6K_V2.4_Model_FP16' else 'alpha', + 'confidence': detection.confidence + } + + log.debug(data) + try: + response = requests.post(detection_url, json=data, timeout=20) + log.info("Detection POST Response Status - %d", response.status_code) + except Exception as e: + log.error("Cannot POST detection: %s", e) diff --git a/scripts/utils/reporting.py b/scripts/utils/reporting.py index ea8f920c..9ca458f8 100644 --- a/scripts/utils/reporting.py +++ b/scripts/utils/reporting.py @@ -1,5 +1,4 @@ import glob -import gzip import json import logging import os @@ -7,10 +6,12 @@ import subprocess from time import sleep +from tzlocal import get_localzone import requests from .helpers import get_settings, ParseFileName, Detection, DB_PATH from .notifications import sendAppriseNotifications +from .birdweather import post_soundscape_to_birdweather, post_detection_to_birdweather log = logging.getLogger(__name__) @@ -160,54 +161,32 @@ def apprise(file: ParseFileName, detections: [Detection]): species_apprised_this_run.append(detection.species) -def bird_weather(file: ParseFileName, detections: [Detection]): +def post_current_detections_to_birdweather(file: ParseFileName, detections: [Detection]): + """Post to BirdWeather detections that were just performed. + + This function relies on the .wav audio file temporarily stored in "StreamData" to post a + soundscape to BirdWeather. + """ conf = get_settings() if conf['BIRDWEATHER_ID'] == "": return if detections: - # POST soundscape to server - soundscape_url = (f'https://app.birdweather.com/api/v1/stations/' - f'{conf["BIRDWEATHER_ID"]}/soundscapes?timestamp={file.iso8601}') - - with open(file.file_name, 'rb') as f: - wav_data = f.read() - gzip_wav_data = gzip.compress(wav_data) - try: - response = requests.post(url=soundscape_url, data=gzip_wav_data, timeout=30, - headers={'Content-Type': 'application/octet-stream', 'Content-Encoding': 'gzip'}) - log.info("Soundscape POST Response Status - %d", response.status_code) - sdata = response.json() - except BaseException as e: - log.error("Cannot POST soundscape: %s", e) - return - if not sdata.get('success'): - log.error(sdata.get('message')) + soundscape_id = post_soundscape_to_birdweather( + conf["BIRDWEATHER_ID"], file.file_date.astimezone(get_localzone()), file.file_name + ) + if soundscape_id is None: return - soundscape_id = sdata['soundscape']['id'] - for detection in detections: - # POST detection to server - detection_url = f'https://app.birdweather.com/api/v1/stations/{conf["BIRDWEATHER_ID"]}/detections' - - data = { - 'timestamp': detection.iso8601, - 'lat': conf['LATITUDE'], - 'lon': conf['LONGITUDE'], - 'soundscapeId': soundscape_id, - 'soundscapeStartTime': (detection.start_datetime - file.file_date).seconds, - 'soundscapeEndTime': (detection.stop_datetime - file.file_date).seconds, - 'commonName': detection.common_name, - 'scientificName': detection.scientific_name, - 'algorithm': '2p4' if conf['MODEL'] == 'BirdNET_GLOBAL_6K_V2.4_Model_FP16' else 'alpha', - 'confidence': detection.confidence, - } - - log.debug(data) - try: - response = requests.post(detection_url, json=data, timeout=20) - log.info("Detection POST Response Status - %d", response.status_code) - except BaseException as e: - log.error("Cannot POST detection: %s", e) + + post_detection_to_birdweather( + detection, + soundscape_id, + file.file_date, + conf["BIRDWEATHER_ID"], + conf['LATITUDE'], + conf['LONGITUDE'], + conf['MODEL'], + ) def heartbeat():