diff --git a/.strict-typing b/.strict-typing index c7a1b604fac7e5..a47e21aba26794 100644 --- a/.strict-typing +++ b/.strict-typing @@ -117,6 +117,7 @@ homeassistant.components.bang_olufsen.* homeassistant.components.bayesian.* homeassistant.components.besen.* homeassistant.components.binary_sensor.* +homeassistant.components.birdnet_go.* homeassistant.components.bitcoin.* homeassistant.components.blockchain.* homeassistant.components.blue_current.* diff --git a/CODEOWNERS b/CODEOWNERS index f25b6c4f272645..56df8f53d50f59 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -233,6 +233,8 @@ CLAUDE.md @home-assistant/core /tests/components/besen/ @moryoav /homeassistant/components/binary_sensor/ @home-assistant/core /tests/components/binary_sensor/ @home-assistant/core +/homeassistant/components/birdnet_go/ @TN-1 +/tests/components/birdnet_go/ @TN-1 /homeassistant/components/bizkaibus/ @UgaitzEtxebarria /homeassistant/components/blebox/ @bbx-a @swistakm @bkobus-bbx /tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx diff --git a/homeassistant/components/birdnet_go/__init__.py b/homeassistant/components/birdnet_go/__init__.py new file mode 100644 index 00000000000000..351e7697fcaf39 --- /dev/null +++ b/homeassistant/components/birdnet_go/__init__.py @@ -0,0 +1,33 @@ +"""The BirdNET-Go integration.""" + +from aiobirdnetgo import BirdNetGoClient + +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import BirdNetGoConfigEntry, BirdNetGoDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: BirdNetGoConfigEntry) -> bool: + """Set up BirdNET-Go from a config entry.""" + client = BirdNetGoClient( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + use_ssl=entry.data[CONF_SSL], + session=async_get_clientsession(hass), + ) + + coordinator = BirdNetGoDataUpdateCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: BirdNetGoConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/birdnet_go/config_flow.py b/homeassistant/components/birdnet_go/config_flow.py new file mode 100644 index 00000000000000..411a526b19406d --- /dev/null +++ b/homeassistant/components/birdnet_go/config_flow.py @@ -0,0 +1,103 @@ +"""Config flow for BirdNET-Go integration.""" + +from typing import Any, override + +from aiobirdnetgo import ( + BirdNetGoAuthenticationError, + BirdNetGoClient, + BirdNetGoConnectionError, + BirdNetGoError, + BirdNetGoTimeoutError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + BooleanSelector, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, +) + +from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN, LOGGER + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector(), + vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.All( + NumberSelector( + NumberSelectorConfig( + min=1, + max=65535, + step=1, + mode=NumberSelectorMode.BOX, + ) + ), + vol.Coerce(int), + ), + vol.Optional(CONF_SSL, default=False): BooleanSelector(), + } +) + + +class BirdNetGoConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for BirdNET-Go.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + raw_host = user_input[CONF_HOST].strip() + raw_port = int(user_input[CONF_PORT]) + raw_ssl = bool(user_input[CONF_SSL]) + + session = async_get_clientsession(self.hass) + try: + client = BirdNetGoClient( + host=raw_host, + port=raw_port, + use_ssl=raw_ssl, + session=session, + ) + await client.get_kpis() + except ValueError: + errors["base"] = "cannot_connect" + except BirdNetGoAuthenticationError: + errors["base"] = "auth_not_supported" + except BirdNetGoConnectionError, BirdNetGoTimeoutError: + errors["base"] = "cannot_connect" + except BirdNetGoError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception during BirdNET-Go setup") + errors["base"] = "unknown" + else: + if not errors: + host = client.host + port = client.port + use_ssl = client.use_ssl + user_input[CONF_HOST] = host + user_input[CONF_PORT] = port + user_input[CONF_SSL] = use_ssl + + unique_id = f"{host}:{port}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + title = f"{DEFAULT_NAME} ({host}:{port})" + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/birdnet_go/const.py b/homeassistant/components/birdnet_go/const.py new file mode 100644 index 00000000000000..7569df7ef7836c --- /dev/null +++ b/homeassistant/components/birdnet_go/const.py @@ -0,0 +1,12 @@ +"""Constants for the BirdNET-Go integration.""" + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "birdnet_go" +LOGGER = logging.getLogger(__package__) + +DEFAULT_NAME: Final = "BirdNET-Go" +DEFAULT_PORT: Final = 8080 +SCAN_INTERVAL: Final = timedelta(seconds=30) diff --git a/homeassistant/components/birdnet_go/coordinator.py b/homeassistant/components/birdnet_go/coordinator.py new file mode 100644 index 00000000000000..59ce0f7ae9b66a --- /dev/null +++ b/homeassistant/components/birdnet_go/coordinator.py @@ -0,0 +1,61 @@ +"""DataUpdateCoordinator for BirdNET-Go.""" + +from typing import override + +from aiobirdnetgo import ( + BirdNetGoAuthenticationError, + BirdNetGoClient, + BirdNetGoConnectionError, + BirdNetGoError, + BirdNetGoTimeoutError, + DashboardKPIs, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + +type BirdNetGoConfigEntry = ConfigEntry[BirdNetGoDataUpdateCoordinator] + + +class BirdNetGoDataUpdateCoordinator(DataUpdateCoordinator[DashboardKPIs]): + """Class to manage fetching BirdNET-Go data.""" + + config_entry: BirdNetGoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: BirdNetGoConfigEntry, + client: BirdNetGoClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{config_entry.title}", + update_interval=SCAN_INTERVAL, + ) + self.client = client + + @override + async def _async_update_data(self) -> DashboardKPIs: + """Fetch data from BirdNET-Go.""" + try: + return await self.client.get_kpis() + except BirdNetGoAuthenticationError as err: + raise ConfigEntryAuthFailed( + f"Authentication failed for {self.client.base_url}" + ) from err + except (BirdNetGoConnectionError, BirdNetGoTimeoutError) as err: + raise UpdateFailed( + f"Error communicating with BirdNET-Go at {self.client.base_url}: {err}" + ) from err + except BirdNetGoError as err: + raise UpdateFailed( + f"Unexpected error communicating with BirdNET-Go: {err}" + ) from err diff --git a/homeassistant/components/birdnet_go/icons.json b/homeassistant/components/birdnet_go/icons.json new file mode 100644 index 00000000000000..a96ed0c01935b7 --- /dev/null +++ b/homeassistant/components/birdnet_go/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "best_day_count": { + "default": "mdi:trophy-award" + }, + "detection_streak": { + "default": "mdi:calendar-range" + }, + "lifetime_species": { + "default": "mdi:counter" + }, + "today_detections": { + "default": "mdi:bird" + } + } + } +} diff --git a/homeassistant/components/birdnet_go/manifest.json b/homeassistant/components/birdnet_go/manifest.json new file mode 100644 index 00000000000000..790f81b920f592 --- /dev/null +++ b/homeassistant/components/birdnet_go/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "birdnet_go", + "name": "BirdNET-Go", + "codeowners": ["@TN-1"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/birdnet_go", + "integration_type": "service", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["aiobirdnetgo==0.1.5"] +} diff --git a/homeassistant/components/birdnet_go/quality_scale.yaml b/homeassistant/components/birdnet_go/quality_scale.yaml new file mode 100644 index 00000000000000..b9081845b0270b --- /dev/null +++ b/homeassistant/components/birdnet_go/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: There are no custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: There are no custom actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: Entities do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: There are no custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: There are no options flow parameters. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: There is no discovery. + discovery: + status: exempt + comment: There is no discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Station device is created at config entry setup and does not change at runtime. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: There are no repairable issues. + stale-devices: + status: exempt + comment: Station device is created at config entry setup and does not change at runtime. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/birdnet_go/sensor.py b/homeassistant/components/birdnet_go/sensor.py new file mode 100644 index 00000000000000..05be8b037e8e21 --- /dev/null +++ b/homeassistant/components/birdnet_go/sensor.py @@ -0,0 +1,100 @@ +"""Sensor platform for BirdNET-Go integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from aiobirdnetgo import DashboardKPIs + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import BirdNetGoConfigEntry, BirdNetGoDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class BirdNetGoSensorEntityDescription(SensorEntityDescription): + """Describes BirdNET-Go sensor entity.""" + + value_fn: Callable[[DashboardKPIs], int | float | None] + + +SENSOR_DESCRIPTIONS: tuple[BirdNetGoSensorEntityDescription, ...] = ( + BirdNetGoSensorEntityDescription( + key="today_detections", + translation_key="today_detections", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda kpis: kpis.today_detections, + ), + BirdNetGoSensorEntityDescription( + key="lifetime_species", + translation_key="lifetime_species", + state_class=SensorStateClass.TOTAL, + value_fn=lambda kpis: kpis.lifetime_species, + ), + BirdNetGoSensorEntityDescription( + key="detection_streak", + translation_key="detection_streak", + native_unit_of_measurement=UnitOfTime.DAYS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda kpis: kpis.detection_streak.days, + ), + BirdNetGoSensorEntityDescription( + key="best_day_count", + translation_key="best_day_count", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda kpis: kpis.best_day.count, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BirdNetGoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up BirdNET-Go sensors based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + BirdNetGoSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS + ) + + +class BirdNetGoSensor(CoordinatorEntity[BirdNetGoDataUpdateCoordinator], SensorEntity): + """Representation of a BirdNET-Go sensor.""" + + entity_description: BirdNetGoSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BirdNetGoDataUpdateCoordinator, + description: BirdNetGoSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer=DEFAULT_NAME, + entry_type=DeviceEntryType.SERVICE, + configuration_url=coordinator.client.base_url, + ) + + @property + @override + def native_value(self) -> int | float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/birdnet_go/strings.json b/homeassistant/components/birdnet_go/strings.json new file mode 100644 index 00000000000000..6795941c64d262 --- /dev/null +++ b/homeassistant/components/birdnet_go/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "auth_not_supported": "Authentication is not supported. Please disable authentication or connect directly to BirdNET-Go.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your BirdNET-Go station.", + "port": "The port number of your BirdNET-Go station.", + "ssl": "Whether to use HTTPS to connect to your BirdNET-Go station." + }, + "description": "Set up your BirdNET-Go station connection.", + "title": "Connect to BirdNET-Go" + } + } + }, + "entity": { + "sensor": { + "best_day_count": { + "name": "Best day detections (past year)" + }, + "detection_streak": { + "name": "Detection streak" + }, + "lifetime_species": { + "name": "Lifetime species" + }, + "today_detections": { + "name": "Today's detections" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 022efbd91edf29..63a06d35d8f353 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -102,6 +102,7 @@ "bang_olufsen", "bayesian", "besen", + "birdnet_go", "blebox", "blink", "blue_current", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b98961014781d4..e5fb76d67c206c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -750,6 +750,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "birdnet_go": { + "name": "BirdNET-Go", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "bitcoin": { "name": "Bitcoin", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 55d9509291bce8..44fd241d9bbe1f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -927,6 +927,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.birdnet_go.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.bitcoin.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 74285d09ff1c6e..6df0123534de1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -223,6 +223,9 @@ aioazuredevops==2.2.2 # homeassistant.components.baf aiobafi6==0.9.0 +# homeassistant.components.birdnet_go +aiobirdnetgo==0.1.5 + # homeassistant.components.aws # homeassistant.components.aws_s3 # homeassistant.components.cloudflare_r2 diff --git a/tests/components/birdnet_go/__init__.py b/tests/components/birdnet_go/__init__.py new file mode 100644 index 00000000000000..7ca6f2649735ef --- /dev/null +++ b/tests/components/birdnet_go/__init__.py @@ -0,0 +1 @@ +"""Tests for the BirdNET-Go integration.""" diff --git a/tests/components/birdnet_go/conftest.py b/tests/components/birdnet_go/conftest.py new file mode 100644 index 00000000000000..78fd3b1a660c5e --- /dev/null +++ b/tests/components/birdnet_go/conftest.py @@ -0,0 +1,67 @@ +"""Fixtures for BirdNET-Go integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aiobirdnetgo import BirdNetGoClient, DashboardKPIs +import pytest + +from homeassistant.components.birdnet_go.const import DEFAULT_PORT, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_kpis() -> DashboardKPIs: + """Return mock DashboardKPIs from JSON fixture.""" + return DashboardKPIs.from_dict(load_json_object_fixture("kpis.json", DOMAIN)) + + +@pytest.fixture +def mock_birdnet_client( + mock_kpis: DashboardKPIs, +) -> Generator[AsyncMock]: + """Mock BirdNetGoClient.""" + mock_instance = AsyncMock(spec=BirdNetGoClient) + mock_instance.host = "192.168.1.100" + mock_instance.port = 8080 + mock_instance.use_ssl = False + mock_instance.base_url = "http://192.168.1.100:8080" + mock_instance.get_kpis = AsyncMock(return_value=mock_kpis) + + def _create_client(*args: object, **kwargs: object) -> AsyncMock: + real_client = BirdNetGoClient(*args, **kwargs) # type: ignore[arg-type] + mock_instance.host = real_client.host + mock_instance.port = real_client.port + mock_instance.use_ssl = real_client.use_ssl + mock_instance.base_url = real_client.base_url + return mock_instance + + with ( + patch( + "homeassistant.components.birdnet_go.config_flow.BirdNetGoClient", + side_effect=_create_client, + ) as mock_client_cls, + patch( + "homeassistant.components.birdnet_go.BirdNetGoClient", + new=mock_client_cls, + ), + ): + mock_client_cls.return_value = mock_instance + yield mock_instance + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return mock ConfigEntry.""" + return MockConfigEntry( + domain=DOMAIN, + title="BirdNET-Go (192.168.1.100:8080)", + unique_id="192.168.1.100:8080", + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) diff --git a/tests/components/birdnet_go/fixtures/kpis.json b/tests/components/birdnet_go/fixtures/kpis.json new file mode 100644 index 00000000000000..aa2626ee478440 --- /dev/null +++ b/tests/components/birdnet_go/fixtures/kpis.json @@ -0,0 +1,12 @@ +{ + "lifetime_species": 42, + "today_detections": 138, + "best_day": { + "date": "2026-05-15", + "count": 420 + }, + "detection_streak": { + "days": 17, + "start_date": "2026-08-20" + } +} diff --git a/tests/components/birdnet_go/test_config_flow.py b/tests/components/birdnet_go/test_config_flow.py new file mode 100644 index 00000000000000..f97bb5570b1131 --- /dev/null +++ b/tests/components/birdnet_go/test_config_flow.py @@ -0,0 +1,335 @@ +"""Test the BirdNET-Go config flow.""" + +from unittest.mock import AsyncMock, patch + +from aiobirdnetgo import ( + BirdNetGoAuthenticationError, + BirdNetGoConnectionError, + BirdNetGoError, + BirdNetGoResponseError, +) + +from homeassistant.components.birdnet_go.const import DEFAULT_PORT, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_flow_user_success( + hass: HomeAssistant, mock_birdnet_client: AsyncMock +) -> None: + """Test successful user step configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "BirdNET-Go (192.168.1.100:8080)" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + } + assert isinstance(result["data"][CONF_PORT], int) + assert result["result"].unique_id == "192.168.1.100:8080" + + +async def test_flow_user_ipv6( + hass: HomeAssistant, mock_birdnet_client: AsyncMock +) -> None: + """Test user step with IPv6 address.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "[2001:db8::1]", + CONF_PORT: 8080, + CONF_SSL: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "BirdNET-Go (2001:db8::1:8080)" + assert result["data"] == { + CONF_HOST: "2001:db8::1", + CONF_PORT: 8080, + CONF_SSL: False, + } + assert result["result"].unique_id == "2001:db8::1:8080" + + +async def test_flow_user_port_normalization( + hass: HomeAssistant, mock_birdnet_client: AsyncMock +) -> None: + """Test user step normalizes float port from NumberSelector and host whitespace.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: " 192.168.1.100 ", + CONF_PORT: 8080.0, + CONF_SSL: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == "192.168.1.100" + assert result["data"][CONF_PORT] == 8080 + assert isinstance(result["data"][CONF_PORT], int) + + +async def test_flow_user_cannot_connect( + hass: HomeAssistant, mock_birdnet_client: AsyncMock +) -> None: + """Test user step with connection error.""" + mock_birdnet_client.get_kpis.side_effect = BirdNetGoConnectionError( + "Host unreachable" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Recover from error + mock_birdnet_client.get_kpis.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_flow_user_auth_not_supported( + hass: HomeAssistant, mock_birdnet_client: AsyncMock +) -> None: + """Test user step with authentication error returns auth_not_supported.""" + mock_birdnet_client.get_kpis.side_effect = BirdNetGoAuthenticationError( + "Authentication required" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "auth_not_supported"} + + +async def test_flow_user_invalid_url_value_error( + hass: HomeAssistant, +) -> None: + """Test user step when client construction raises ValueError.""" + with patch( + "homeassistant.components.birdnet_go.config_flow.BirdNetGoClient", + side_effect=ValueError("Invalid URL or port"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "http://bird.local:notaport", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_user_malformed_kpis( + hass: HomeAssistant, mock_birdnet_client: AsyncMock +) -> None: + """Test user step rejects malformed KPI response from non-BirdNET endpoint.""" + mock_birdnet_client.get_kpis.side_effect = BirdNetGoResponseError( + 200, "Malformed KPI response: missing required headline metrics" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_user_general_error( + hass: HomeAssistant, mock_birdnet_client: AsyncMock +) -> None: + """Test user step with general BirdNET-Go error.""" + mock_birdnet_client.get_kpis.side_effect = BirdNetGoError("General failure") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_user_unknown_exception( + hass: HomeAssistant, mock_birdnet_client: AsyncMock +) -> None: + """Test user step with unexpected exception.""" + mock_birdnet_client.get_kpis.side_effect = RuntimeError("Fatal memory error") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_flow_user_already_configured( + hass: HomeAssistant, + mock_birdnet_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting when unique ID is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_url_canonicalization_already_configured( + hass: HomeAssistant, + mock_birdnet_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting when a full URL resolves to an already configured unique ID.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "http://192.168.1.100:8080/api/", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_ipv6_canonicalization_already_configured( + hass: HomeAssistant, + mock_birdnet_client: AsyncMock, +) -> None: + """Test aborting when an expanded IPv6 address matches an existing compressed IPv6 entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="BirdNET-Go (2001:db8::1:8080)", + unique_id="2001:db8::1:8080", + data={ + CONF_HOST: "2001:db8::1", + CONF_PORT: 8080, + CONF_SSL: False, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "[2001:0db8:0000:0000:0000:0000:0000:0001]", + CONF_PORT: 8080, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/birdnet_go/test_init.py b/tests/components/birdnet_go/test_init.py new file mode 100644 index 00000000000000..760eb791d31b4c --- /dev/null +++ b/tests/components/birdnet_go/test_init.py @@ -0,0 +1,83 @@ +"""Test setup and unload of the BirdNET-Go integration.""" + +from unittest.mock import AsyncMock + +from aiobirdnetgo import ( + BirdNetGoAuthenticationError, + BirdNetGoConnectionError, + BirdNetGoError, +) + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_birdnet_client: AsyncMock, +) -> None: + """Test successful setup and unload of a config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state == ConfigEntryState.LOADED + assert mock_config_entry.runtime_data is not None + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_auth_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_birdnet_client: AsyncMock, +) -> None: + """Test config entry setup with authentication failure.""" + mock_birdnet_client.get_kpis.side_effect = BirdNetGoAuthenticationError( + "Invalid token" + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_birdnet_client: AsyncMock, +) -> None: + """Test config entry setup with connection failure.""" + mock_birdnet_client.get_kpis.side_effect = BirdNetGoConnectionError( + "Cannot reach host" + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_generic_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_birdnet_client: AsyncMock, +) -> None: + """Test config entry setup with generic BirdNET-Go error.""" + mock_birdnet_client.get_kpis.side_effect = BirdNetGoError("General error") + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/birdnet_go/test_sensor.py b/tests/components/birdnet_go/test_sensor.py new file mode 100644 index 00000000000000..4085b767a90983 --- /dev/null +++ b/tests/components/birdnet_go/test_sensor.py @@ -0,0 +1,58 @@ +"""Test BirdNET-Go sensors.""" + +from unittest.mock import AsyncMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_birdnet_client: AsyncMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test states and attributes of BirdNET-Go sensors.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.birdnet_go_192_168_1_100_8080_today_s_detections") + assert state is not None + assert state.state == "138" + assert state.attributes.get("state_class") == "total_increasing" + + state = hass.states.get("sensor.birdnet_go_192_168_1_100_8080_lifetime_species") + assert state is not None + assert state.state == "42" + + state = hass.states.get("sensor.birdnet_go_192_168_1_100_8080_detection_streak") + assert state is not None + assert state.state == "17" + assert state.attributes.get("unit_of_measurement") == "d" + + state = hass.states.get( + "sensor.birdnet_go_192_168_1_100_8080_best_day_detections_past_year" + ) + assert state is not None + assert state.state == "420" + assert state.attributes.get("state_class") == "measurement" + + entry = entity_registry.async_get( + "sensor.birdnet_go_192_168_1_100_8080_today_s_detections" + ) + assert entry is not None + assert entry.unique_id == f"{mock_config_entry.entry_id}_today_detections" + + device = device_registry.async_get_device_by_identifier( + ("birdnet_go", mock_config_entry.entry_id), mock_config_entry.entry_id + ) + assert device is not None + assert device.name == "BirdNET-Go (192.168.1.100:8080)" + assert device.manufacturer == "BirdNET-Go" + assert device.entry_type == dr.DeviceEntryType.SERVICE + assert device.configuration_url == "http://192.168.1.100:8080"