cleanup: factor out passing in settings

This commit is contained in:
frederik
2025-11-15 16:38:45 +01:00
parent d154b5f6f1
commit 1e2edbfd6e
5 changed files with 180 additions and 153 deletions
+2 -3
View File
@@ -38,6 +38,5 @@ if __name__ == "__main__":
'Lat': conf.getfloat('LATITUDE'), 'Lon': conf.getfloat('LONGITUDE'), 'Cutoff': conf.getfloat('CONFIDENCE'), 'Lat': conf.getfloat('LATITUDE'), 'Lon': conf.getfloat('LONGITUDE'), 'Cutoff': conf.getfloat('CONFIDENCE'),
'Sens': conf.getfloat('SENSITIVITY'), 'Overlap': conf.getfloat('OVERLAP')} 'Sens': conf.getfloat('SENSITIVITY'), 'Overlap': conf.getfloat('OVERLAP')}
notifications.sendAppriseNotifications(d['Sci_Name'], d['Com_Name'], d['Confidence'], round(d['Confidence'] * 100), d['File_Name'], d['Date'], notifications.sendAppriseNotifications(d['Sci_Name'], d['Com_Name'], d['Confidence'], round(d['Confidence'] * 100), d['File_Name'], d['Date'], d['Time'],
d['Time'], d['Week'], d['Lat'], d['Lon'], d['Cutoff'], d['Sens'], d['Overlap'], dict(conf)) d['Week'], d['Lat'], d['Lon'], d['Cutoff'], d['Sens'], d['Overlap'])
+77 -68
View File
@@ -3,14 +3,14 @@ import os
import socket import socket
import requests import requests
import html import html
import time as timeim import time
from .db import get_todays_count_for, get_this_weeks_count_for from .db import get_todays_count_for, get_this_weeks_count_for
from .helpers import get_settings
userDir = os.path.expanduser('~') userDir = os.path.expanduser('~')
APPRISE_CONFIG = userDir + '/BirdNET-Pi/apprise.txt' APPRISE_CONFIG = userDir + '/BirdNET-Pi/apprise.txt'
APPRISE_BODY = userDir + '/BirdNET-Pi/body.txt' APPRISE_BODY = userDir + '/BirdNET-Pi/body.txt'
DB_PATH = userDir + '/BirdNET-Pi/scripts/birds.db'
apobj = None apobj = None
images = {} images = {}
@@ -44,7 +44,7 @@ def notify(body, title, attached=""):
) )
def sendAppriseNotifications(sci_name, com_name, confidence, confidencepct, path, date, time, week, latitude, longitude, cutoff, sens, overlap, settings_dict): def sendAppriseNotifications(sci_name, com_name, confidence, confidencepct, path, date, time_of_day, week, latitude, longitude, cutoff, sens, overlap):
def render_template(template, reason=""): def render_template(template, reason=""):
ret = template.replace("$sciname", sci_name) \ ret = template.replace("$sciname", sci_name) \
.replace("$comname", com_name) \ .replace("$comname", com_name) \
@@ -53,7 +53,7 @@ def sendAppriseNotifications(sci_name, com_name, confidence, confidencepct, path
.replace("$listenurl", listenurl) \ .replace("$listenurl", listenurl) \
.replace("$friendlyurl", friendlyurl) \ .replace("$friendlyurl", friendlyurl) \
.replace("$date", str(date)) \ .replace("$date", str(date)) \
.replace("$time", str(time)) \ .replace("$time", str(time_of_day)) \
.replace("$week", str(week)) \ .replace("$week", str(week)) \
.replace("$latitude", str(latitude)) \ .replace("$latitude", str(latitude)) \
.replace("$longitude", str(longitude)) \ .replace("$longitude", str(longitude)) \
@@ -64,80 +64,89 @@ def sendAppriseNotifications(sci_name, com_name, confidence, confidencepct, path
.replace("$overlap", str(overlap)) \ .replace("$overlap", str(overlap)) \
.replace("$reason", reason) .replace("$reason", reason)
return ret return ret
# print(sendAppriseNotifications)
# print(settings_dict)
if os.path.exists(APPRISE_CONFIG) and os.path.getsize(APPRISE_CONFIG) > 0:
title = html.unescape(settings_dict.get('APPRISE_NOTIFICATION_TITLE')) if not should_notify(com_name):
f = open(APPRISE_BODY, 'r') return
body = f.read()
APPRISE_ONLY_NOTIFY_SPECIES_NAMES = settings_dict.get('APPRISE_ONLY_NOTIFY_SPECIES_NAMES') settings_dict = get_settings()
if APPRISE_ONLY_NOTIFY_SPECIES_NAMES is not None and APPRISE_ONLY_NOTIFY_SPECIES_NAMES.strip() != "": title = html.unescape(settings_dict.get('APPRISE_NOTIFICATION_TITLE'))
if any(bird.lower().replace(" ", "") in com_name.lower().replace(" ", "") for bird in APPRISE_ONLY_NOTIFY_SPECIES_NAMES.split(",")): f = open(APPRISE_BODY, 'r')
return body = f.read()
APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2 = settings_dict.get('APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2') websiteurl = settings_dict.get('BIRDNETPI_URL')
if APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2 is not None and APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2.strip() != "": if websiteurl is None or len(websiteurl) == 0:
if not any(bird.lower().replace(" ", "") in com_name.lower().replace(" ", "") for bird in APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2.split(",")): websiteurl = f"http://{socket.gethostname()}.local"
return
APPRISE_MINIMUM_SECONDS_BETWEEN_NOTIFICATIONS_PER_SPECIES = settings_dict.get('APPRISE_MINIMUM_SECONDS_BETWEEN_NOTIFICATIONS_PER_SPECIES') listenurl = websiteurl+"?filename="+path
if APPRISE_MINIMUM_SECONDS_BETWEEN_NOTIFICATIONS_PER_SPECIES != "0": friendlyurl = "[Listen here]("+listenurl+")"
if species_last_notified.get(com_name) is not None: image_url = ""
try:
if int(timeim.time()) - species_last_notified[com_name] < int(APPRISE_MINIMUM_SECONDS_BETWEEN_NOTIFICATIONS_PER_SPECIES):
return
except Exception as e:
print("APPRISE NOTIFICATION EXCEPTION: "+str(e))
return
# TODO: this all needs to be changed, we changed the caddy default to allow direct IP access, so birdnetpi.local shouldn't be relied on anymore if "$flickrimage" in body or "$image" in body:
try: if com_name not in images:
websiteurl = settings_dict.get('BIRDNETPI_URL') try:
if len(websiteurl) == 0: url = f"http://localhost/api/v1/image/{sci_name}"
raise ValueError('Blank URL') resp = requests.get(url=url, timeout=10).json()
except Exception: images[com_name] = resp['data']['image_url']
websiteurl = "http://"+socket.gethostname()+".local" except Exception as e:
print("IMAGE API ERROR: " + str(e))
image_url = images.get(com_name, "")
listenurl = websiteurl+"?filename="+path if settings_dict.get('APPRISE_NOTIFY_EACH_DETECTION') == "1":
friendlyurl = "[Listen here]("+listenurl+")" reason = "detection"
image_url = "" notify_body = render_template(body, reason)
notify_title = render_template(title, reason)
notify(notify_body, notify_title, image_url)
species_last_notified[com_name] = int(time.time())
if "$flickrimage" in body or "$image" in body: APPRISE_NOTIFICATION_NEW_SPECIES_DAILY_COUNT_LIMIT = 1 # Notifies the first N per day.
if com_name not in images: if settings_dict.get('APPRISE_NOTIFY_NEW_SPECIES_EACH_DAY') == "1":
try: numberDetections = get_todays_count_for(sci_name)
url = f"http://localhost/api/v1/image/{sci_name}" if 0 < numberDetections <= APPRISE_NOTIFICATION_NEW_SPECIES_DAILY_COUNT_LIMIT:
resp = requests.get(url=url, timeout=10).json() reason = "first time today"
images[com_name] = resp['data']['image_url'] notify_body = render_template(body, reason)
except Exception as e: notify_title = render_template(title, reason)
print("IMAGE API ERROR: "+str(e))
image_url = images.get(com_name, "")
if settings_dict.get('APPRISE_NOTIFY_EACH_DETECTION') == "1":
notify_body = render_template(body, "detection")
notify_title = render_template(title, "detection")
notify(notify_body, notify_title, image_url) notify(notify_body, notify_title, image_url)
species_last_notified[com_name] = int(timeim.time()) species_last_notified[com_name] = int(time.time())
APPRISE_NOTIFICATION_NEW_SPECIES_DAILY_COUNT_LIMIT = 1 # Notifies the first N per day. if settings_dict.get('APPRISE_NOTIFY_NEW_SPECIES') == "1":
if settings_dict.get('APPRISE_NOTIFY_NEW_SPECIES_EACH_DAY') == "1": numberDetections = get_this_weeks_count_for(sci_name)
numberDetections = get_todays_count_for(sci_name) if 0 < numberDetections <= 5:
if 0 < numberDetections <= APPRISE_NOTIFICATION_NEW_SPECIES_DAILY_COUNT_LIMIT: reason = f"only seen {numberDetections} times in last 7d"
print("send the notification") notify_body = render_template(body, reason)
notify_body = render_template(body, "first time today") notify_title = render_template(title, reason)
notify_title = render_template(title, "first time today") notify(notify_body, notify_title, image_url)
notify(notify_body, notify_title, image_url) species_last_notified[com_name] = int(time.time())
species_last_notified[com_name] = int(timeim.time())
if settings_dict.get('APPRISE_NOTIFY_NEW_SPECIES') == "1":
numberDetections = get_this_weeks_count_for(sci_name) def should_notify(com_name):
if 0 < numberDetections <= 5: settings_dict = get_settings()
reason = f"only seen {numberDetections} times in last 7d" if not (os.path.exists(APPRISE_CONFIG) and os.path.getsize(APPRISE_CONFIG) > 0):
notify_body = render_template(body, reason) return False
notify_title = render_template(title, reason)
notify(notify_body, notify_title, image_url) # check if this is an excluded species
species_last_notified[com_name] = int(timeim.time()) APPRISE_ONLY_NOTIFY_SPECIES_NAMES = settings_dict.get('APPRISE_ONLY_NOTIFY_SPECIES_NAMES')
if APPRISE_ONLY_NOTIFY_SPECIES_NAMES is not None and APPRISE_ONLY_NOTIFY_SPECIES_NAMES.strip() != "":
if any(bird.lower().replace(" ", "") in com_name.lower().replace(" ", "") for bird in APPRISE_ONLY_NOTIFY_SPECIES_NAMES.split(",")):
return False
# check if this is an included species
APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2 = settings_dict.get('APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2')
if APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2 is not None and APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2.strip() != "":
if not any(bird.lower().replace(" ", "") in com_name.lower().replace(" ", "") for bird in APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2.split(",")):
return False
# is it still too soon?
APPRISE_MINIMUM_SECONDS_BETWEEN_NOTIFICATIONS_PER_SPECIES = settings_dict.get('APPRISE_MINIMUM_SECONDS_BETWEEN_NOTIFICATIONS_PER_SPECIES')
if APPRISE_MINIMUM_SECONDS_BETWEEN_NOTIFICATIONS_PER_SPECIES != "0":
if species_last_notified.get(com_name) is not None:
try:
if int(time.time()) - species_last_notified[com_name] < int(APPRISE_MINIMUM_SECONDS_BETWEEN_NOTIFICATIONS_PER_SPECIES):
return False
except Exception as e:
print("APPRISE NOTIFICATION EXCEPTION: " + str(e))
return False
return True
if __name__ == "__main__": if __name__ == "__main__":
+1 -2
View File
@@ -159,8 +159,7 @@ def apprise(file: ParseFileName, detections: [Detection]):
try: try:
sendAppriseNotifications(detection.scientific_name, detection.common_name, str(detection.confidence), str(detection.confidence_pct), sendAppriseNotifications(detection.scientific_name, detection.common_name, str(detection.confidence), str(detection.confidence_pct),
os.path.basename(detection.file_name_extr), detection.date, detection.time, str(detection.week), os.path.basename(detection.file_name_extr), detection.date, detection.time, str(detection.week),
conf['LATITUDE'], conf['LONGITUDE'], conf['CONFIDENCE'], conf['SENSITIVITY'], conf['LATITUDE'], conf['LONGITUDE'], conf['CONFIDENCE'], conf['SENSITIVITY'], conf['OVERLAP'])
conf['OVERLAP'], dict(conf))
except BaseException as e: except BaseException as e:
log.exception('Error during Apprise:', exc_info=e) log.exception('Error during Apprise:', exc_info=e)
+18 -11
View File
@@ -13,16 +13,23 @@ class Settings(dict):
@classmethod @classmethod
def with_defaults(cls): def with_defaults(cls):
settings = { settings = {
'OVERLAP': 0.0, "OVERLAP": 0.0,
'LATITUDE': 50, "LATITUDE": 50,
'LONGITUDE': 5, "LONGITUDE": 5,
'CONFIDENCE': 0.7, "CONFIDENCE": 0.7,
'DATABASE_LANG': 'en', "DATABASE_LANG": "en",
'PRIVACY_THRESHOLD': 0, "PRIVACY_THRESHOLD": 0,
'EXTRACTION_LENGTH': 6, "EXTRACTION_LENGTH": 6,
'MODEL': 'BirdNET_GLOBAL_6K_V2.4_Model_FP16', "MODEL": "BirdNET_GLOBAL_6K_V2.4_Model_FP16",
'DATA_MODEL_VERSION': 1, "DATA_MODEL_VERSION": 1,
'SENSITIVITY': 1.25, "SENSITIVITY": 1.25,
'SF_THRESH': 0.003, "SF_THRESH": 0.003,
"APPRISE_NOTIFICATION_TITLE": "New backyard bird!",
"APPRISE_NOTIFY_EACH_DETECTION": "0",
"APPRISE_NOTIFY_NEW_SPECIES": "0",
"APPRISE_NOTIFY_NEW_SPECIES_EACH_DAY": "0",
"APPRISE_MINIMUM_SECONDS_BETWEEN_NOTIFICATIONS_PER_SPECIES": "0",
"APPRISE_ONLY_NOTIFY_SPECIES_NAMES": "",
"APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2": ""
} }
return cls(settings) return cls(settings)
+82 -69
View File
@@ -1,15 +1,20 @@
import os import os
import sqlite3 import sqlite3
from datetime import datetime
import unittest import unittest
from datetime import datetime
from unittest.mock import patch from unittest.mock import patch
from scripts.utils import db
from scripts.utils import notifications from scripts.utils import notifications
from scripts.utils.notifications import sendAppriseNotifications from scripts.utils.notifications import sendAppriseNotifications
from tests.helpers import Settings
class TestAppriseNotifications(unittest.TestCase): class TestAppriseNotifications(unittest.TestCase):
def setUp(self):
db.DB_PATH = self.db_file
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.db_file = "test.db" cls.db_file = "test.db"
@@ -59,32 +64,33 @@ class TestAppriseNotifications(unittest.TestCase):
if os.path.exists(self.apprise_config_file): if os.path.exists(self.apprise_config_file):
os.remove(self.apprise_config_file) os.remove(self.apprise_config_file)
def get_default_params(self):
return {
"sci_name": "Myiarchus crinitus",
"com_name": "Great Crested Flycatcher",
"confidence": "0.91",
"confidencepct": "91",
"path": "filename",
"date": "1666-06-06",
"time_of_day": "06:06:06",
"week": "06",
"latitude": "-1",
"longitude": "-1",
"cutoff": "0.7",
"sens": "1.25",
"overlap": "0.0"
}
@patch('scripts.utils.helpers._load_settings')
@patch('scripts.utils.notifications.notify') @patch('scripts.utils.notifications.notify')
def test_notifications(self, mock_notify): def test_notifications(self, mock_notify, mock_load_settings):
self.create_test_db() self.create_test_db()
self.create_apprise_config() self.create_apprise_config()
settings_dict = { notifications.DB_PATH = self.db_file
"APPRISE_NOTIFICATION_TITLE": "New backyard bird!", settings_dict = Settings.with_defaults()
"APPRISE_NOTIFY_EACH_DETECTION": "0",
"APPRISE_NOTIFY_NEW_SPECIES": "0", mock_load_settings.return_value = settings_dict
"APPRISE_NOTIFY_NEW_SPECIES_EACH_DAY": "0", sendAppriseNotifications(**self.get_default_params())
"APPRISE_MINIMUM_SECONDS_BETWEEN_NOTIFICATIONS_PER_SPECIES": "0"
}
sendAppriseNotifications("Myiarchus crinitus",
"Great Crested Flycatcher",
"0.91",
"91",
"filename",
"1666-06-06",
"06:06:06",
"06",
"-1",
"-1",
"0.7",
"1.25",
"0.0",
settings_dict,
self.db_file)
# No active apprise notifications configured. Confirm no notifications. # No active apprise notifications configured. Confirm no notifications.
self.assertEqual(mock_notify.call_count, 0) self.assertEqual(mock_notify.call_count, 0)
@@ -92,21 +98,8 @@ class TestAppriseNotifications(unittest.TestCase):
# Add daily notification. # Add daily notification.
mock_notify.reset_mock() mock_notify.reset_mock()
settings_dict["APPRISE_NOTIFY_NEW_SPECIES_EACH_DAY"] = "1" settings_dict["APPRISE_NOTIFY_NEW_SPECIES_EACH_DAY"] = "1"
sendAppriseNotifications("Myiarchus crinitus", mock_load_settings.return_value = settings_dict
"Great Crested Flycatcher", sendAppriseNotifications(**self.get_default_params())
"0.91",
"91",
"filename",
"1666-06-06",
"06:06:06",
"06",
"-1",
"-1",
"0.7",
"1.25",
"0.0",
settings_dict,
self.db_file)
self.assertEqual(mock_notify.call_count, 1) self.assertEqual(mock_notify.call_count, 1)
self.assertEqual( self.assertEqual(
@@ -117,21 +110,8 @@ class TestAppriseNotifications(unittest.TestCase):
# Add new species notification. # Add new species notification.
mock_notify.reset_mock() mock_notify.reset_mock()
settings_dict["APPRISE_NOTIFY_NEW_SPECIES"] = "1" settings_dict["APPRISE_NOTIFY_NEW_SPECIES"] = "1"
sendAppriseNotifications("Myiarchus crinitus", mock_load_settings.return_value = settings_dict
"Great Crested Flycatcher", sendAppriseNotifications(**self.get_default_params())
"0.91",
"91",
"filename",
"1666-06-06",
"06:06:06",
"06",
"-1",
"-1",
"0.7",
"1.25",
"0.0",
settings_dict,
self.db_file)
self.assertEqual(mock_notify.call_count, 2) self.assertEqual(mock_notify.call_count, 2)
self.assertEqual( self.assertEqual(
@@ -146,24 +126,57 @@ class TestAppriseNotifications(unittest.TestCase):
# Add each species notification. # Add each species notification.
mock_notify.reset_mock() mock_notify.reset_mock()
settings_dict["APPRISE_NOTIFY_EACH_DETECTION"] = "1" settings_dict["APPRISE_NOTIFY_EACH_DETECTION"] = "1"
sendAppriseNotifications("Myiarchus crinitus", mock_load_settings.return_value = settings_dict
"Great Crested Flycatcher", sendAppriseNotifications(**self.get_default_params())
"0.91",
"91",
"filename",
"1666-06-06",
"06:06:06",
"06",
"-1",
"-1",
"0.7",
"1.25",
"0.0",
settings_dict,
self.db_file)
self.assertEqual(mock_notify.call_count, 3) self.assertEqual(mock_notify.call_count, 3)
@patch('scripts.utils.helpers._load_settings')
@patch('scripts.utils.notifications.notify')
def test_notifications_excluded(self, mock_notify, mock_load_settings):
self.create_test_db()
self.create_apprise_config()
notifications.DB_PATH = self.db_file
settings_dict = Settings.with_defaults()
settings_dict["APPRISE_NOTIFY_EACH_DETECTION"] = "1"
settings_dict['APPRISE_ONLY_NOTIFY_SPECIES_NAMES'] = 'Quailfinch'
mock_load_settings.return_value = settings_dict
sendAppriseNotifications(**self.get_default_params())
# Not excluded. Confirm notifications.
self.assertEqual(mock_notify.call_count, 1)
mock_notify.reset_mock()
settings_dict['APPRISE_ONLY_NOTIFY_SPECIES_NAMES'] = 'Quailfinch,Great Crested Flycatcher'
mock_load_settings.return_value = settings_dict
sendAppriseNotifications(**self.get_default_params())
self.assertEqual(mock_notify.call_count, 0)
@patch('scripts.utils.helpers._load_settings')
@patch('scripts.utils.notifications.notify')
def test_notifications_included(self, mock_notify, mock_load_settings):
self.create_test_db()
self.create_apprise_config()
notifications.DB_PATH = self.db_file
settings_dict = Settings.with_defaults()
settings_dict["APPRISE_NOTIFY_EACH_DETECTION"] = "1"
settings_dict['APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2'] = 'Quailfinch'
mock_load_settings.return_value = settings_dict
sendAppriseNotifications(**self.get_default_params())
# No wanted species. Confirm no notifications.
self.assertEqual(mock_notify.call_count, 0)
mock_notify.reset_mock()
settings_dict['APPRISE_ONLY_NOTIFY_SPECIES_NAMES_2'] = 'Quailfinch,Great Crested Flycatcher'
mock_load_settings.return_value = settings_dict
sendAppriseNotifications(**self.get_default_params())
self.assertEqual(mock_notify.call_count, 1)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()