diff --git a/config_templates/birdnetpi.yaml b/config_templates/birdnetpi.yaml index 5dcad717..0297d526 100644 --- a/config_templates/birdnetpi.yaml +++ b/config_templates/birdnetpi.yaml @@ -121,10 +121,6 @@ notification_rules: [] notify_quiet_hours_start: "" # e.g., "22:00" for 10 PM (empty = no quiet hours) notify_quiet_hours_end: "" # e.g., "06:00" for 6 AM -# Flickr Integration -flickr_api_key: "" -flickr_filter_email: "" - # Localization and Species Display language: en # Language code for UI and species name translation species_display_mode: full # Options: "full" (Common Name (Scientific Name)), "common_name", "scientific_name" @@ -134,12 +130,6 @@ timezone: UTC enable_gps: false gps_update_interval: 5.0 -# Hardware Monitoring -hardware_check_interval: 10.0 -enable_audio_device_check: true -enable_system_resource_check: true -enable_gps_check: false - # Analysis Configuration privacy_threshold: 10.0 diff --git a/src/birdnetpi/audio/analysis.py b/src/birdnetpi/audio/analysis.py index 8a315ebb..0571840f 100644 --- a/src/birdnetpi/audio/analysis.py +++ b/src/birdnetpi/audio/analysis.py @@ -195,11 +195,11 @@ async def process_audio_chunk(self, audio_data_bytes: bytes) -> None: # Remove processed samples from buffer (keep overlap for continuity) # Clamp overlap to reasonable range (0.5 to 1.5 seconds, never >= buffer size) - overlap_seconds = min(self.config.analysis_overlap, 1.5) + overlap_seconds = min(self.config.audio_overlap, 1.5) if overlap_seconds >= 3.0: logger.warning( - "Invalid analysis_overlap %.1f seconds (must be < 3.0), using 1.0 second", - self.config.analysis_overlap, + "Invalid audio_overlap %.1f seconds (must be < 3.0), using 1.0 second", + self.config.audio_overlap, ) overlap_seconds = 1.0 overlap_samples = int(overlap_seconds * self.config.sample_rate) @@ -323,7 +323,7 @@ async def _send_detection_event( "species_confidence_threshold": self.config.species_confidence_threshold, "week": current_week, "sensitivity_setting": self.config.sensitivity_setting, - "overlap": self.config.analysis_overlap, + "overlap": self.config.audio_overlap, } # Try to send detection event to API diff --git a/src/birdnetpi/config/models.py b/src/birdnetpi/config/models.py index bae3be3c..9ecf18b5 100644 --- a/src/birdnetpi/config/models.py +++ b/src/birdnetpi/config/models.py @@ -75,7 +75,7 @@ class BirdNETConfig(BaseModel): audio_device_index: int = -1 # Default to -1 for system default or auto-detection sample_rate: int = 48000 # Default sample rate (BirdNET expects 48kHz) audio_channels: int = 1 # Default to mono (BirdNET processes mono audio) - analysis_overlap: float = 0.5 # Overlap in seconds between consecutive audio segments + audio_overlap: float = 0.5 # Overlap in seconds between consecutive audio segments # Logging settings logging: LoggingConfig = Field(default_factory=LoggingConfig) @@ -117,10 +117,6 @@ class BirdNETConfig(BaseModel): notify_quiet_hours_start: str = "" # "HH:MM" or empty for no quiet hours notify_quiet_hours_end: str = "" # "HH:MM" or empty for no quiet hours - # Flickr - flickr_api_key: str = "" - flickr_filter_email: str = "" - # Localization and Species Display language: str = "en" # Language code for UI and species name translation species_display_mode: str = "full" # Options: "full", "common_name", "scientific_name" @@ -129,12 +125,6 @@ class BirdNETConfig(BaseModel): # Field mode and GPS settings enable_gps: bool = False # Enable GPS tracking for field deployments gps_update_interval: float = 5.0 # GPS update interval in seconds - hardware_check_interval: float = 10.0 # Hardware monitoring interval in seconds - - # Hardware monitoring settings - enable_audio_device_check: bool = True # Enable audio device monitoring - enable_system_resource_check: bool = True # Enable system resource monitoring - enable_gps_check: bool = False # Enable GPS device monitoring # MQTT Integration settings enable_mqtt: bool = False # Enable MQTT publishing diff --git a/src/birdnetpi/config/versions/v1_9_0.py b/src/birdnetpi/config/versions/v1_9_0.py index 7c976331..60ac48a1 100644 --- a/src/birdnetpi/config/versions/v1_9_0.py +++ b/src/birdnetpi/config/versions/v1_9_0.py @@ -53,9 +53,6 @@ def defaults(self) -> dict[str, Any]: "apprise_only_notify_species_names": "", "apprise_weekly_report": False, "minimum_time_limit": 0, - # Flickr - "flickr_api_key": "", - "flickr_filter_email": "", # Localization "language": "en", "species_display_mode": "full", diff --git a/src/birdnetpi/config/versions/v2_0_0.py b/src/birdnetpi/config/versions/v2_0_0.py index a8f614ec..1a100a82 100644 --- a/src/birdnetpi/config/versions/v2_0_0.py +++ b/src/birdnetpi/config/versions/v2_0_0.py @@ -42,6 +42,7 @@ def defaults(self) -> dict[str, Any]: "audio_device_index": -1, "sample_rate": 48000, "audio_channels": 1, + "audio_overlap": 0.5, # Enhanced Logging Configuration "logging": { "level": "INFO", @@ -61,9 +62,6 @@ def defaults(self) -> dict[str, Any]: "notification_rules": [], "notify_quiet_hours_start": "", "notify_quiet_hours_end": "", - # Flickr - "flickr_api_key": "", - "flickr_filter_email": "", # Localization and Species Display "language": "en", "species_display_mode": "full", @@ -71,11 +69,6 @@ def defaults(self) -> dict[str, Any]: # Field mode and GPS settings "enable_gps": False, "gps_update_interval": 5.0, - "hardware_check_interval": 10.0, - # Hardware monitoring settings - "enable_audio_device_check": True, - "enable_system_resource_check": True, - "enable_gps_check": False, # MQTT Integration settings "enable_mqtt": False, "mqtt_broker_host": "localhost", diff --git a/src/birdnetpi/detections/models.py b/src/birdnetpi/detections/models.py index 7663a771..8ffa34e4 100644 --- a/src/birdnetpi/detections/models.py +++ b/src/birdnetpi/detections/models.py @@ -183,7 +183,10 @@ def serialize_model(self, serializer: object, info: object) -> dict[str, Any]: # Create a display service with the config and use it display_service = SpeciesDisplayService(config) # Always prefer translation (as recommended) - display_name = display_service.format_species_display(self, prefer_translation=True) + # Use format_full_species_display to respect species_display_mode setting + display_name = display_service.format_full_species_display( + self, prefer_translation=True + ) # Set both display_name and common_name for backward compatibility data["display_name"] = display_name diff --git a/src/birdnetpi/utils/helpers.py b/src/birdnetpi/utils/helpers.py new file mode 100644 index 00000000..1458329f --- /dev/null +++ b/src/birdnetpi/utils/helpers.py @@ -0,0 +1,28 @@ +"""General utility helper functions.""" + +from typing import TypeVar + +T = TypeVar("T") + + +def prefer(new_value: T | None, fallback: T) -> T: + """Return new_value if not None, otherwise return fallback. + + Useful for merging optional form fields with current config values. + + Args: + new_value: The potentially updated value + fallback: The fallback value to use if new_value is None + + Returns: + new_value if it's not None, otherwise fallback + + Example: + >>> prefer("updated", "original") + 'updated' + >>> prefer(None, "original") + 'original' + >>> prefer(False, True) # Works with falsy values + False + """ + return new_value if new_value is not None else fallback diff --git a/src/birdnetpi/web/routers/settings_view_routes.py b/src/birdnetpi/web/routers/settings_view_routes.py index da0c51cc..ca7d4843 100644 --- a/src/birdnetpi/web/routers/settings_view_routes.py +++ b/src/birdnetpi/web/routers/settings_view_routes.py @@ -20,6 +20,7 @@ from birdnetpi.system.log_reader import LogReaderService from birdnetpi.system.path_resolver import PathResolver from birdnetpi.system.status import SystemInspector +from birdnetpi.utils.helpers import prefer from birdnetpi.utils.language import get_user_language from birdnetpi.web.core.container import Container from birdnetpi.web.models.detections import DetectionEvent @@ -128,16 +129,13 @@ async def post_settings_view( audio_device_index: int = Form(...), sample_rate: int = Form(...), audio_channels: int = Form(...), - analysis_overlap: float = Form(...), + audio_overlap: float = Form(...), # External Services (optional - preserves existing if not provided) birdweather_id: str | None = Form(None), # New notification fields (JSON) - optional apprise_targets_json: str | None = Form(None), webhook_targets_json: str | None = Form(None), notification_rules_json: str | None = Form(None), - # Flickr (optional) - flickr_api_key: str | None = Form(None), - flickr_filter_email: str | None = Form(None), # Localization (optional) language: str | None = Form(None), species_display_mode: str | None = Form(None), @@ -145,10 +143,6 @@ async def post_settings_view( # Field Mode and GPS (optional) enable_gps: bool | None = Form(None), gps_update_interval: float | None = Form(None), - hardware_check_interval: float | None = Form(None), - enable_audio_device_check: bool | None = Form(None), - enable_system_resource_check: bool | None = Form(None), - enable_gps_check: bool | None = Form(None), # Analysis (optional) privacy_threshold: float | None = Form(None), # MQTT Integration (optional) @@ -227,11 +221,9 @@ async def post_settings_view( audio_device_index=audio_device_index, sample_rate=sample_rate, audio_channels=audio_channels, - analysis_overlap=analysis_overlap, + audio_overlap=audio_overlap, # External Services (preserve if not provided) - birdweather_id=birdweather_id - if birdweather_id is not None - else current_config.birdweather_id, + birdweather_id=prefer(birdweather_id, current_config.birdweather_id), # New Notification System (preserve if not provided) apprise_targets=apprise_targets, webhook_targets=webhook_targets, @@ -240,64 +232,27 @@ async def post_settings_view( notification_body_default=current_config.notification_body_default, notify_quiet_hours_start=current_config.notify_quiet_hours_start, notify_quiet_hours_end=current_config.notify_quiet_hours_end, - # Flickr (preserve if not provided) - flickr_api_key=flickr_api_key - if flickr_api_key is not None - else current_config.flickr_api_key, - flickr_filter_email=flickr_filter_email - if flickr_filter_email is not None - else current_config.flickr_filter_email, # Localization (preserve if not provided) - language=language if language is not None else current_config.language, - species_display_mode=species_display_mode - if species_display_mode is not None - else current_config.species_display_mode, - timezone=timezone if timezone is not None else current_config.timezone, + language=prefer(language, current_config.language), + species_display_mode=prefer(species_display_mode, current_config.species_display_mode), + timezone=prefer(timezone, current_config.timezone), # Field Mode and GPS (preserve if not provided) - enable_gps=enable_gps if enable_gps is not None else current_config.enable_gps, - gps_update_interval=gps_update_interval - if gps_update_interval is not None - else current_config.gps_update_interval, - hardware_check_interval=hardware_check_interval - if hardware_check_interval is not None - else current_config.hardware_check_interval, - enable_audio_device_check=enable_audio_device_check - if enable_audio_device_check is not None - else current_config.enable_audio_device_check, - enable_system_resource_check=enable_system_resource_check - if enable_system_resource_check is not None - else current_config.enable_system_resource_check, - enable_gps_check=enable_gps_check - if enable_gps_check is not None - else current_config.enable_gps_check, + enable_gps=prefer(enable_gps, current_config.enable_gps), + gps_update_interval=prefer(gps_update_interval, current_config.gps_update_interval), # Analysis (preserve if not provided) - privacy_threshold=privacy_threshold - if privacy_threshold is not None - else current_config.privacy_threshold, + privacy_threshold=prefer(privacy_threshold, current_config.privacy_threshold), # MQTT Integration (preserve if not provided) - enable_mqtt=enable_mqtt if enable_mqtt is not None else current_config.enable_mqtt, - mqtt_broker_host=mqtt_broker_host - if mqtt_broker_host is not None - else current_config.mqtt_broker_host, - mqtt_broker_port=mqtt_broker_port - if mqtt_broker_port is not None - else current_config.mqtt_broker_port, - mqtt_username=mqtt_username if mqtt_username is not None else current_config.mqtt_username, - mqtt_password=mqtt_password if mqtt_password is not None else current_config.mqtt_password, - mqtt_topic_prefix=mqtt_topic_prefix - if mqtt_topic_prefix is not None - else current_config.mqtt_topic_prefix, - mqtt_client_id=mqtt_client_id - if mqtt_client_id is not None - else current_config.mqtt_client_id, + enable_mqtt=prefer(enable_mqtt, current_config.enable_mqtt), + mqtt_broker_host=prefer(mqtt_broker_host, current_config.mqtt_broker_host), + mqtt_broker_port=prefer(mqtt_broker_port, current_config.mqtt_broker_port), + mqtt_username=prefer(mqtt_username, current_config.mqtt_username), + mqtt_password=prefer(mqtt_password, current_config.mqtt_password), + mqtt_topic_prefix=prefer(mqtt_topic_prefix, current_config.mqtt_topic_prefix), + mqtt_client_id=prefer(mqtt_client_id, current_config.mqtt_client_id), # Webhook Integration (preserve if not provided) - enable_webhooks=enable_webhooks - if enable_webhooks is not None - else current_config.enable_webhooks, + enable_webhooks=prefer(enable_webhooks, current_config.enable_webhooks), webhook_urls=webhook_urls_list, - webhook_events=webhook_events - if webhook_events is not None - else current_config.webhook_events, + webhook_events=prefer(webhook_events, current_config.webhook_events), # Update settings (always preserved from current config) updates=current_config.updates, ) diff --git a/src/birdnetpi/web/static/css/style.css b/src/birdnetpi/web/static/css/style.css index 8bd7f107..f4189414 100644 --- a/src/birdnetpi/web/static/css/style.css +++ b/src/birdnetpi/web/static/css/style.css @@ -901,7 +901,12 @@ h1 { font-weight: 500; } -/* Coordinate input */ +/* Coordinate section wrapper */ +.coordinate-section { + width: 100%; +} + +/* Coordinate input fields */ .coordinate-input { display: grid; grid-template-columns: 1fr 1fr; @@ -909,6 +914,21 @@ h1 { max-width: 400px; } +/* Make coordinate fields fit nicely */ +.coordinate-input > .coord-field { + max-width: 200px; +} + +/* Map widget takes full width of coordinate-section */ +.coordinate-section > .location-map-widget { + width: 100%; +} + +/* Coordinate inputs after map - no extra margin needed */ +.coordinate-section > .coordinate-input { + margin-top: 0; +} + .coord-field { display: flex; flex-direction: column; diff --git a/src/birdnetpi/web/templates/admin/settings.html.j2 b/src/birdnetpi/web/templates/admin/settings.html.j2 index 48582562..0e0a16b7 100644 --- a/src/birdnetpi/web/templates/admin/settings.html.j2 +++ b/src/birdnetpi/web/templates/admin/settings.html.j2 @@ -1,5 +1,12 @@ {% extends "base.html.j2" %} +{% block styles %} + + +{% endblock %} + {% block content %}
@@ -90,8 +97,8 @@
{{ _('Coordinates') }}
{{ _('For species list and sunrise/sunset') }}
-
-
- - -
-
- - +
+ + {% include 'components/location_map.html.j2' %} + +
+
+ + +
+
+ + +
@@ -531,6 +543,11 @@ {% endblock %} {% block scripts %} + + + diff --git a/tests/birdnetpi/audio/test_analysis.py b/tests/birdnetpi/audio/test_analysis.py index 01680d25..06e06608 100644 --- a/tests/birdnetpi/audio/test_analysis.py +++ b/tests/birdnetpi/audio/test_analysis.py @@ -25,7 +25,7 @@ def test_config_data(): return { "sample_rate": 48000, "audio_channels": 1, - "analysis_overlap": 0.5, + "audio_overlap": 0.5, "latitude": 63.4591, "longitude": -19.3647, "sensitivity_setting": 1.25, diff --git a/tests/birdnetpi/web/routers/test_detections_api_routes.py b/tests/birdnetpi/web/routers/test_detections_api_routes.py index cc0d0756..c2ecab5a 100644 --- a/tests/birdnetpi/web/routers/test_detections_api_routes.py +++ b/tests/birdnetpi/web/routers/test_detections_api_routes.py @@ -357,7 +357,7 @@ def test_get_paginated_detections_with_search(self, client, model_factory): assert response.status_code == 200 data = response.json() assert len(data["detections"]) == 1 - assert data["detections"][0]["common_name"] == "American Robin" + assert data["detections"][0]["common_name"] == "American Robin (Turdus migratorius)" @pytest.mark.parametrize( "mock_return,expected_status,expected_error", diff --git a/tests/birdnetpi/web/routers/test_settings_routes.py b/tests/birdnetpi/web/routers/test_settings_routes.py index c7a2613d..ea141c06 100644 --- a/tests/birdnetpi/web/routers/test_settings_routes.py +++ b/tests/birdnetpi/web/routers/test_settings_routes.py @@ -61,7 +61,7 @@ def test_config(): audio_device_index=0, sample_rate=48000, audio_channels=1, - analysis_overlap=0.5, + audio_overlap=0.5, model="BirdNET_GLOBAL_6K_V2.4_Model_FP16", metadata_model="BirdNET_GLOBAL_6K_V2.4_MData_Model_FP16", apprise_targets={}, @@ -211,7 +211,7 @@ def test_settings_post_saves_configuration(self, client_with_mocks): "audio_device_index": "1", "sample_rate": "48000", "audio_channels": "1", - "analysis_overlap": "0.5", + "audio_overlap": "0.5", } response = client.post("/admin/settings", data=form_data, follow_redirects=False) if response.status_code not in [302, 303]: @@ -237,7 +237,7 @@ def test_settings_post_creates_correct_config_object(self, client_with_mocks): "audio_device_index": "2", "sample_rate": "44100", "audio_channels": "2", - "analysis_overlap": "1.0", + "audio_overlap": "1.0", } response = client.post("/admin/settings", data=form_data, follow_redirects=False) assert response.status_code in [302, 303] @@ -264,7 +264,7 @@ def test_settings_post_handles_boolean_fields(self, client_with_mocks): "audio_device_index": "0", "sample_rate": "48000", "audio_channels": "1", - "analysis_overlap": "0.5", + "audio_overlap": "0.5", "enable_gps": "on", "enable_mqtt": "on", } @@ -288,7 +288,7 @@ def test_settings_post_handles_webhook_urls(self, client_with_mocks): "audio_device_index": "0", "sample_rate": "48000", "audio_channels": "1", - "analysis_overlap": "0.5", + "audio_overlap": "0.5", "webhook_urls": "http://example.com/hook1, http://example.com/hook2", } response = client.post("/admin/settings", data=form_data, follow_redirects=False) @@ -310,7 +310,7 @@ def test_settings_post_redirects_to_settings(self, client_with_mocks): "audio_device_index": "0", "sample_rate": "48000", "audio_channels": "1", - "analysis_overlap": "0.5", + "audio_overlap": "0.5", } response = client.post("/admin/settings", data=form_data, follow_redirects=False) assert response.status_code in [302, 303] @@ -330,7 +330,7 @@ def test_settings_post_preserves_unsubmitted_fields(self, client_with_mocks, tes "audio_device_index": "0", "sample_rate": "48000", "audio_channels": "1", - "analysis_overlap": "0.5", + "audio_overlap": "0.5", } response = client.post("/admin/settings", data=form_data, follow_redirects=False) assert response.status_code in [302, 303] diff --git a/tests/birdnetpi/web/routers/test_settings_view_rendering.py b/tests/birdnetpi/web/routers/test_settings_view_rendering.py index 73fa0340..243cf334 100644 --- a/tests/birdnetpi/web/routers/test_settings_view_rendering.py +++ b/tests/birdnetpi/web/routers/test_settings_view_rendering.py @@ -46,7 +46,7 @@ def sample_config(self): audio_device_index=0, sample_rate=48000, audio_channels=1, - analysis_overlap=0.5, + audio_overlap=0.5, model="BirdNET_GLOBAL_6K_V2.4_Model_FP16", metadata_model="BirdNET_GLOBAL_6K_V2.4_MData_Model_FP16", ) diff --git a/tests/e2e/test_settings_e2e.py b/tests/e2e/test_settings_e2e.py index 6e39dbc0..830b2913 100644 --- a/tests/e2e/test_settings_e2e.py +++ b/tests/e2e/test_settings_e2e.py @@ -182,7 +182,7 @@ def test_e2e_settings_form_submission_saves_changes(self, e2e_app, temp_data_dir "audio_device_index": "2", # Select USB Microphone "sample_rate": "48000", "audio_channels": "1", - "analysis_overlap": "0.5", + "audio_overlap": "0.5", "enable_gps": "on", # Enable GPS "birdweather_id": "test123", } @@ -234,7 +234,7 @@ def test_e2e_settings_roundtrip(self, e2e_app): "audio_device_index": "3", # Select Webcam Mic "sample_rate": "44100", "audio_channels": "2", - "analysis_overlap": "1.5", + "audio_overlap": "1.5", "webhook_urls": "http://hook1.example.com, http://hook2.example.com", } @@ -306,7 +306,7 @@ def test_e2e_settings_handles_concurrent_access(self, e2e_app): "audio_device_index": "0", "sample_rate": "48000", "audio_channels": "1", - "analysis_overlap": "0.5", + "audio_overlap": "0.5", } response3 = client.post("/admin/settings", data=form_data1, follow_redirects=False) @@ -327,7 +327,7 @@ def test_e2e_settings_handles_concurrent_access(self, e2e_app): "audio_device_index": "1", "sample_rate": "48000", "audio_channels": "1", - "analysis_overlap": "0.5", + "audio_overlap": "0.5", } response4 = client.post("/admin/settings", data=form_data2, follow_redirects=False) @@ -366,7 +366,7 @@ def test_e2e_settings_preserves_unmodified_fields(self, e2e_app): "audio_device_index": "-1", "sample_rate": "48000", "audio_channels": "1", - "analysis_overlap": "0.5", + "audio_overlap": "0.5", } response = client.post("/admin/settings", data=form_data, follow_redirects=False) diff --git a/tests/web/test_notification_rules_ui.py b/tests/web/test_notification_rules_ui.py index e097e25d..fcb1fce5 100644 --- a/tests/web/test_notification_rules_ui.py +++ b/tests/web/test_notification_rules_ui.py @@ -119,7 +119,7 @@ def test_post_settings_view_with_notification_rules( "audio_device_index": "-1", "sample_rate": "48000", "audio_channels": "1", - "analysis_overlap": "0.5", + "audio_overlap": "0.5", "birdweather_id": "", "apprise_targets_json": json.dumps({"discord": "discord://webhook/token"}), "webhook_targets_json": json.dumps({"home_assistant": "http://ha.local/webhook"}),