Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add service to publish past detections to BirdWeather #250

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
22 changes: 5 additions & 17 deletions scripts/birdnet_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import os.path
import re
import signal
import sys
import threading
from queue import Queue
from subprocess import CalledProcessError
Expand All @@ -12,13 +11,13 @@
from inotify.constants import IN_CLOSE_WRITE

from server import load_global_model, run_analysis
from utils.helpers import get_settings, ParseFileName, get_wav_files, ANALYZING_NOW
from utils.reporting import extract_detection, summary, write_to_file, write_to_db, apprise, bird_weather, heartbeat, \
update_json_file
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, \
post_current_detections_to_birdweather, heartbeat, update_json_file

shutdown = False

log = logging.getLogger(__name__)
log = logging.getLogger(os.path.splitext(os.path.basename(os.path.realpath(__file__)))[0])


def sig_handler(sig_num, curr_stack_frame):
Expand Down Expand Up @@ -115,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:
Expand All @@ -129,17 +128,6 @@ def handle_reporting_queue(queue):
log.info('handle_reporting_queue done')


def setup_logging():
logger = logging.getLogger()
formatter = logging.Formatter("[%(name)s][%(levelname)s] %(message)s")
handler = logging.StreamHandler(stream=sys.stdout)
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
global log
log = logging.getLogger('birdnet_analysis')


if __name__ == '__main__':
signal.signal(signal.SIGINT, sig_handler)
signal.signal(signal.SIGTERM, sig_handler)
Expand Down
157 changes: 157 additions & 0 deletions scripts/birdweather_past_publication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Publish past detections to BirdWeather."""

import datetime
import logging
import os
import sqlite3
from typing import Optional
import warnings

import librosa
import pandas as pd
from tzlocal import get_localzone
from utils.helpers import DB_PATH, get_settings, setup_logging, Detection
from utils.birdweather import get_birdweather_species_id, query_birdweather_detections, \
post_soundscape_to_birdweather, post_detection_to_birdweather

log = logging.getLogger(os.path.splitext(os.path.basename(os.path.realpath(__file__)))[0])


def get_last_run_time(script_name: str) -> Optional[datetime.datetime]:
"""Fetch the last run time for the given script from the database."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()

cursor.execute(
"SELECT last_run FROM scripts_metadata WHERE script_name = ?", (script_name,)
)
result = cursor.fetchone()

conn.close()

if result:
return datetime.datetime.fromisoformat(result[0])
return None


def update_last_run_time(script_name: str):
"""Update the last run time for the given script to the current time in the database."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()

current_time = datetime.datetime.now().isoformat()

cursor.execute(
"""
INSERT INTO scripts_metadata (script_name, last_run) VALUES (?, ?)
ON CONFLICT(script_name) DO UPDATE SET last_run = excluded.last_run;
""",
(script_name, current_time),
)

conn.commit()
conn.close()


def get_detections_since(start_datetime: datetime.datetime) -> pd.DataFrame:
"""Get detections from the database that occurred after the specified date."""
conn = sqlite3.connect(DB_PATH)
query = (
"SELECT * FROM detections "
"WHERE DATETIME(Date || ' ' || Time) > "
f"DATETIME('{start_datetime.strftime('%Y-%m-%d %H:%M:%S')}')"
)
df = pd.read_sql_query(query, conn)
conn.close()
return df


def main():

conf = get_settings()
if conf["BIRDWEATHER_ID"] == "":
return

# Get detections since last run (defaults to 7 days if last run time is not found)
last_run_time = get_last_run_time(script_name=os.path.basename(os.path.realpath(__file__)))
if last_run_time is None:
last_run_time = datetime.datetime.now() - datetime.timedelta(days=7)
df = get_detections_since(last_run_time)

# Loop through recent detections
log.info(
f"Checking if recent detections are present in BirdWeather since {last_run_time}"
)
for detection_entry in df.itertuples():

detection_datetime = datetime.datetime.strptime(
f"{detection_entry.Date} {detection_entry.Time}", "%Y-%m-%d %H:%M:%S"
).astimezone(get_localzone())

try:
# Lookup detections present in BirdWeather at the time of this detection
species_id = get_birdweather_species_id(
detection_entry.Sci_Name, detection_entry.Com_Name
)
birdweather_detections = query_birdweather_detections(
conf["BIRDWEATHER_ID"],
species_id,
detection_datetime,
)
except Exception as e:
log.error(
f"Script {os.path.basename(os.path.realpath(__file__))} stopped due to error: {e}"
)
return

# This detection is not present in BirdWeather
if birdweather_detections == []:

log.info(f"Detection not in BirdWeather: {detection_entry.File_Name}")

# Post extracted audio to BirdWeather as soundscape
extracted_audio_file = os.path.join(
conf["EXTRACTED"],
"By_Date",
detection_datetime.strftime("%Y-%m-%d"),
detection_entry.Com_Name.replace(" ", "_").replace("'", ""),
detection_entry.File_Name,
)
soundscape_id = post_soundscape_to_birdweather(
conf["BIRDWEATHER_ID"],
detection_datetime,
extracted_audio_file,
)

# Get length of extracted audio file, will be useful to post detection to BirdWeather
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=FutureWarning)
soundscape_duration = librosa.get_duration(path=extracted_audio_file)

# Create an instance of Detection and post it to BirdWeather
# This Detection start and end times are equal to soundscape start and end times,
# because we're using an "extracted" audio file as soundscape
detection = Detection(
detection_datetime,
detection_datetime + datetime.timedelta(seconds=soundscape_duration),
f"{detection_entry.Sci_Name}_{detection_entry.Com_Name}",
detection_entry.Confidence,
)
post_detection_to_birdweather(
detection,
soundscape_id,
detection_datetime,
conf["BIRDWEATHER_ID"],
conf['LATITUDE'],
conf['LONGITUDE'],
conf['MODEL'],
)

update_last_run_time(script_name=os.path.basename(os.path.realpath(__file__)))


if __name__ == "__main__":

setup_logging()

main()
4 changes: 4 additions & 0 deletions scripts/createdb.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ CREATE TABLE IF NOT EXISTS detections (
File_Name VARCHAR(100) NOT NULL);
CREATE INDEX "detections_Com_Name" ON "detections" ("Com_Name");
CREATE INDEX "detections_Date_Time" ON "detections" ("Date" DESC, "Time" DESC);
DROP TABLE IF EXISTS scripts_metadata;
CREATE TABLE IF NOT EXISTS scripts_metadata (
script_name TEXT PRIMARY KEY,
last_run DATETIME);
EOF
chown $USER:$USER $HOME/BirdNET-Pi/scripts/birds.db
chmod g+w $HOME/BirdNET-Pi/scripts/birds.db
30 changes: 30 additions & 0 deletions scripts/install_helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,33 @@ install_tmp_mount() {
echo "tmp.mount is $STATE, skipping"
fi
}

install_birdweather_past_publication() {
cat << EOF > $HOME/BirdNET-Pi/templates/[email protected]
[Unit]
Description=BirdWeather Publication for %i interface
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=${USER}
ExecStartPre= /bin/sh -c 'n=0; until curl --silent --head --fail https://app.birdweather.com >/dev/null || [ \$n -ge 30 ]; do n=\$((n+1)); sleep 5; done;'
ExecStart=$PYTHON_VIRTUAL_ENV /usr/local/bin/birdweather_past_publication.py
EOF
cat << EOF > $HOME/BirdNET-Pi/templates/50-birdweather-past-publication
#!/bin/bash
UNIT_NAME="birdweather_past_publication@\$IFACE.service"
# Check if the service is active and then start it
if systemctl is-active --quiet "\$UNIT_NAME"; then
echo "\$UNIT_NAME is already running."
else
echo "Starting \$UNIT_NAME..."
systemctl start "\$UNIT_NAME"
fi
EOF
chmod +x $HOME/BirdNET-Pi/templates/50-birdweather-past-publication
chown root:root $HOME/BirdNET-Pi/templates/50-birdweather-past-publication
ln -sf $HOME/BirdNET-Pi/templates/50-birdweather-past-publication /etc/networkd-dispatcher/routable.d
ln -sf $HOME/BirdNET-Pi/templates/[email protected] /usr/lib/systemd/system
systemctl enable systemd-networkd
}
10 changes: 9 additions & 1 deletion scripts/install_services.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ install_depends() {
apt install -qqy caddy sqlite3 php-sqlite3 php-fpm php-curl php-xml php-zip php icecast2 \
pulseaudio avahi-utils sox libsox-fmt-mp3 alsa-utils ffmpeg \
wget curl unzip bc \
python3-pip python3-venv lsof net-tools inotify-tools
python3-pip python3-venv lsof net-tools inotify-tools networkd-dispatcher
}

set_hostname() {
Expand Down Expand Up @@ -387,6 +387,13 @@ install_weekly_cron() {

chown_things() {
chown -R $USER:$USER $HOME/Bird*

# Set ownership to root for the birdweather publication networkd-dispatcher script
BIRDWEATHER_PAST_DISPATCHER_SCRIPT="$HOME/BirdNET-Pi/templates/50-birdweather-past-publication"
if [ -f "$BIRDWEATHER_PAST_DISPATCHER_SCRIPT" ]; then
sudo chown root:root "$BIRDWEATHER_PAST_DISPATCHER_SCRIPT"
sudo chmod 755 "$BIRDWEATHER_PAST_DISPATCHER_SCRIPT"
fi
}

increase_caddy_timeout() {
Expand All @@ -409,6 +416,7 @@ install_services() {
install_Caddyfile
install_avahi_aliases
install_birdnet_analysis
install_birdweather_past_publication
install_birdnet_stats_service
install_recording_service
install_custom_recording_service # But does not enable
Expand Down
5 changes: 2 additions & 3 deletions scripts/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,8 @@ def run_analysis(file):
log.warning("Excluded as below Species Occurrence Frequency Threshold: %s", entry[0])
else:
d = Detection(
file.file_date,
time_slot.split(';')[0],
time_slot.split(';')[1],
file.file_date + datetime.timedelta(seconds=float(time_slot.split(';')[0])),
file.file_date + datetime.timedelta(seconds=float(time_slot.split(';')[1])),
entry[0],
entry[1],
)
Expand Down
26 changes: 26 additions & 0 deletions scripts/update_birdnet_snippets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ chmod g+r $HOME
# remove world-writable perms
chmod -R o-w ~/BirdNET-Pi/templates/*

# update database schema
$my_dir/update_db.sh

APT_UPDATED=0
PIP_UPDATED=0

Expand Down Expand Up @@ -147,6 +150,29 @@ if grep -q 'birdnet_server.service' "$HOME/BirdNET-Pi/templates/birdnet_analysis
systemctl daemon-reload && restart_services.sh
fi


# Ensure networkd-dispatcher is installed
if ! dpkg -s networkd-dispatcher >/dev/null 2>&1; then
echo "networkd-dispatcher is not installed. Installing it now..."
sudo apt update -qq
sudo apt install -qqy networkd-dispatcher
fi

# Add BirdWeather past publication service if not already installed
export PYTHON_VIRTUAL_ENV="$HOME/BirdNET-Pi/birdnet/bin/python3"
BIRDWEATHER_PAST_DISPATCHER_SCRIPT="$HOME/BirdNET-Pi/templates/50-birdweather-past-publication"
BIRDWEATHER_PAST_SERVICE_FILE="/usr/lib/systemd/system/[email protected]"
if [ ! -f "$BIRDWEATHER_PAST_DISPATCHER_SCRIPT" ] || [ ! -f "$BIRDWEATHER_PAST_SERVICE_FILE" ]; then
echo "Installing BirdWeather past publication service..."
install_birdweather_past_publication
fi
# Set ownership to root for the birdweather publication networkd-dispatcher script
if [ -f "$BIRDWEATHER_PAST_DISPATCHER_SCRIPT" ]; then
sudo chown root:root "$BIRDWEATHER_PAST_DISPATCHER_SCRIPT"
sudo chmod 755 "$BIRDWEATHER_PAST_DISPATCHER_SCRIPT"
fi


TMP_MOUNT=$(systemd-escape -p --suffix=mount "$RECS_DIR/StreamData")
if ! [ -f "$HOME/BirdNET-Pi/templates/$TMP_MOUNT" ]; then
install_birdnet_mount
Expand Down
44 changes: 44 additions & 0 deletions scripts/update_db.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash

DB_PATH="$HOME/BirdNET-Pi/scripts/birds.db"

echo "Checking database schema for updates"

# Check if the tables exist
DETECTIONS_TABLE_EXISTS=$(sqlite3 "$DB_PATH" "SELECT name FROM sqlite_master WHERE type='table' AND name='detections';")
SCRIPTS_MTD_TABLE_EXISTS=$(sqlite3 "$DB_PATH" "SELECT name FROM sqlite_master WHERE type='table' AND name='scripts_metadata';")

if [ -z "$DETECTIONS_TABLE_EXISTS" ]; then
echo "Table 'detections' does not exist. Creating table..."
sqlite3 "$DB_PATH" << EOF
CREATE TABLE IF NOT EXISTS detections (
Date DATE,
Time TIME,
Sci_Name VARCHAR(100) NOT NULL,
Com_Name VARCHAR(100) NOT NULL,
Confidence FLOAT,
Lat FLOAT,
Lon FLOAT,
Cutoff FLOAT,
Week INT,
Sens FLOAT,
Overlap FLOAT,
File_Name VARCHAR(100) NOT NULL);
CREATE INDEX "detections_Com_Name" ON "detections" ("Com_Name");
CREATE INDEX "detections_Date_Time" ON "detections" ("Date" DESC, "Time" DESC);
EOF
echo "Table 'detections' created successfully."
elif [ -z "$SCRIPTS_MTD_TABLE_EXISTS" ]; then
echo "Table 'scripts_metadata' does not exist. Creating table..."
sqlite3 "$DB_PATH" << EOF
CREATE TABLE IF NOT EXISTS scripts_metadata (
script_name TEXT PRIMARY KEY,
last_run DATETIME
);
EOF
echo "Table 'scripts_metadata' created successfully."
else
echo "Tables 'detections' and 'scripts_metadata' already exist. No changes made."
fi

echo "Database schema update complete."
Loading
Loading