From 1917e8a877ca7de50f999f388f84f1276a76803b Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 17 Jul 2026 11:44:46 -0400 Subject: [PATCH] Add Harbor Sleep integration (#176171) --- CODEOWNERS | 2 + homeassistant/components/harbor/__init__.py | 43 +++ .../components/harbor/config_flow.py | 128 ++++++++ homeassistant/components/harbor/const.py | 13 + .../components/harbor/coordinator.py | 176 +++++++++++ homeassistant/components/harbor/entity.py | 37 +++ homeassistant/components/harbor/icons.json | 15 + homeassistant/components/harbor/manifest.json | 12 + .../components/harbor/quality_scale.yaml | 73 +++++ homeassistant/components/harbor/sensor.py | 97 ++++++ homeassistant/components/harbor/strings.json | 59 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + tests/components/harbor/__init__.py | 12 + tests/components/harbor/conftest.py | 118 +++++++ .../harbor/snapshots/test_init.ambr | 32 ++ .../harbor/snapshots/test_sensor.ambr | 289 ++++++++++++++++++ tests/components/harbor/test_config_flow.py | 231 ++++++++++++++ tests/components/harbor/test_init.py | 144 +++++++++ tests/components/harbor/test_sensor.py | 93 ++++++ 21 files changed, 1584 insertions(+) create mode 100644 homeassistant/components/harbor/__init__.py create mode 100644 homeassistant/components/harbor/config_flow.py create mode 100644 homeassistant/components/harbor/const.py create mode 100644 homeassistant/components/harbor/coordinator.py create mode 100644 homeassistant/components/harbor/entity.py create mode 100644 homeassistant/components/harbor/icons.json create mode 100644 homeassistant/components/harbor/manifest.json create mode 100644 homeassistant/components/harbor/quality_scale.yaml create mode 100644 homeassistant/components/harbor/sensor.py create mode 100644 homeassistant/components/harbor/strings.json create mode 100644 tests/components/harbor/__init__.py create mode 100644 tests/components/harbor/conftest.py create mode 100644 tests/components/harbor/snapshots/test_init.ambr create mode 100644 tests/components/harbor/snapshots/test_sensor.ambr create mode 100644 tests/components/harbor/test_config_flow.py create mode 100644 tests/components/harbor/test_init.py create mode 100644 tests/components/harbor/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index bfa4aad156b6..5b7f9b411a16 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -719,6 +719,8 @@ CLAUDE.md @home-assistant/core /tests/components/habitica/ @tr4nt0r /homeassistant/components/hanna/ @bestycame /tests/components/hanna/ @bestycame +/homeassistant/components/harbor/ @Lash-L @afgarcia86 +/tests/components/harbor/ @Lash-L @afgarcia86 /homeassistant/components/hardkernel/ @home-assistant/core /tests/components/hardkernel/ @home-assistant/core /homeassistant/components/hardware/ @home-assistant/core diff --git a/homeassistant/components/harbor/__init__.py b/homeassistant/components/harbor/__init__.py new file mode 100644 index 000000000000..1f10679688d0 --- /dev/null +++ b/homeassistant/components/harbor/__init__.py @@ -0,0 +1,43 @@ +"""The Harbor integration.""" + +from harbor.config import HarborCameraConfig + +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN, PLATFORMS +from .coordinator import HarborConfigEntry, HarborCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool: + """Set up Harbor from a config entry.""" + coordinator = HarborCoordinator( + hass, + entry, + HarborCameraConfig( + serial=entry.data[CONF_SERIAL], + cert_pem=entry.data[CONF_CERT_PEM], + key_pem=entry.data[CONF_KEY_PEM], + ip_address=entry.data[CONF_IP_ADDRESS], + ), + ) + await coordinator.async_start() + try: + await coordinator.async_wait_until_ready() + except TimeoutError as err: + await coordinator.async_shutdown() + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool: + """Unload a Harbor config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.async_shutdown() + return unload_ok diff --git a/homeassistant/components/harbor/config_flow.py b/homeassistant/components/harbor/config_flow.py new file mode 100644 index 000000000000..05d222fea54c --- /dev/null +++ b/homeassistant/components/harbor/config_flow.py @@ -0,0 +1,128 @@ +"""Config flow for Harbor.""" + +from typing import Any, override + +from harbor.config import HarborCameraConfig +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.helpers import selector + +from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN +from .coordinator import async_probe_camera + +SERIAL_LENGTH = 10 + +STEP_USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL): selector.TextSelector(selector.TextSelectorConfig()), + vol.Required(CONF_CERT_PEM): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Required(CONF_KEY_PEM): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Required(CONF_IP_ADDRESS): selector.TextSelector( + selector.TextSelectorConfig() + ), + } +) + + +def _validate_serial(value: str) -> bool: + """Validate the Harbor serial number.""" + return len(value) == SERIAL_LENGTH and value.isdigit() + + +def _validate_cert_pem(value: str) -> bool: + """Validate a Harbor client certificate PEM blob.""" + value = value.strip() + return value.startswith("-----BEGIN CERTIFICATE-----") and value.endswith( + "-----END CERTIFICATE-----" + ) + + +def _validate_key_pem(value: str) -> bool: + """Validate a Harbor private key PEM blob.""" + value = value.strip() + return value.startswith("-----BEGIN PRIVATE KEY-----") and value.endswith( + "-----END PRIVATE KEY-----" + ) + + +def _validate_credentials(cert_pem: str, key_pem: str) -> dict[str, str]: + """Validate cert/key PEM blobs and return any errors.""" + errors: dict[str, str] = {} + if not _validate_cert_pem(cert_pem): + errors[CONF_CERT_PEM] = "invalid_cert" + if not _validate_key_pem(key_pem): + errors[CONF_KEY_PEM] = "invalid_key" + return errors + + +class HarborConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Harbor.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_SCHEMA, + errors={}, + ) + + normalized = { + key: value.strip() if isinstance(value, str) else value + for key, value in user_input.items() + } + errors: dict[str, str] = {} + display_name: str | None = None + + serial = normalized[CONF_SERIAL] + if not _validate_serial(serial): + errors[CONF_SERIAL] = "invalid_serial" + + errors.update( + _validate_credentials(normalized[CONF_CERT_PEM], normalized[CONF_KEY_PEM]) + ) + + if not errors: + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + + config = HarborCameraConfig( + serial=serial, + cert_pem=normalized[CONF_CERT_PEM], + key_pem=normalized[CONF_KEY_PEM], + ip_address=normalized[CONF_IP_ADDRESS], + ) + try: + display_name = await async_probe_camera(config) + except TimeoutError: + errors["base"] = "cannot_connect" + + if errors: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_SCHEMA, + errors=errors, + ) + + entry_data: dict[str, Any] = { + CONF_SERIAL: serial, + CONF_CERT_PEM: normalized[CONF_CERT_PEM], + CONF_KEY_PEM: normalized[CONF_KEY_PEM], + CONF_IP_ADDRESS: normalized[CONF_IP_ADDRESS], + } + + return self.async_create_entry( + title=display_name or f"Camera {serial}", + data=entry_data, + ) diff --git a/homeassistant/components/harbor/const.py b/homeassistant/components/harbor/const.py new file mode 100644 index 000000000000..f9b5670e332b --- /dev/null +++ b/homeassistant/components/harbor/const.py @@ -0,0 +1,13 @@ +"""Constants for the Harbor integration.""" + +from homeassistant.const import Platform + +DOMAIN = "harbor" +MANUFACTURER = "Harbor" +MODEL = "Harbor Camera" + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +CONF_CERT_PEM = "cert_pem" +CONF_KEY_PEM = "key_pem" +CONF_SERIAL = "serial" diff --git a/homeassistant/components/harbor/coordinator.py b/homeassistant/components/harbor/coordinator.py new file mode 100644 index 000000000000..55afb751b663 --- /dev/null +++ b/homeassistant/components/harbor/coordinator.py @@ -0,0 +1,176 @@ +"""Coordinator for Harbor.""" + +import asyncio +import logging +from typing import Any, override +from uuid import uuid4 + +from harbor.config import HarborCameraConfig +from harbor.devices.camera import HarborCamera +from harbor.mqtt import DEFAULT_INITIAL_COMMANDS, HarborMQTTClient +from harbor.state import HarborDeviceState + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import instance_id +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, MANUFACTURER, MODEL + +LOGGER = logging.getLogger(__name__) + +type HarborConfigEntry = ConfigEntry[HarborCoordinator] + +# How long to wait for the first successful MQTT connection and the first +# device data to arrive before treating the camera as unreachable, both when +# validating the config flow and during setup. +CONNECT_TIMEOUT = 30.0 + + +async def _discard_message(topic: str, payload: Any) -> None: + """Ignore messages received while probing the connection.""" + + +async def async_probe_camera(config: HarborCameraConfig) -> str | None: + """Connect to a Harbor camera and return its friendly name, if any. + + Raises ``TimeoutError`` when no MQTT session can be established with the + camera. Returns the camera's configured display name, or ``None`` when the + camera is reachable but has no name (or does not answer the settings + request in time). + """ + connected = asyncio.Event() + + async def _on_connection_change(is_connected: bool) -> None: + if is_connected: + connected.set() + + client = HarborMQTTClient( + config=config, + # Subscribe to the responses topic so the get-settings reply can be + # matched to its pending request; without a subscription the reply + # never reaches the client and the request would time out. + topics=[f"cameras/{config.serial}/responses/#"], + message_handler=_discard_message, + client_id=f"{DOMAIN}-{config.serial}-probe-{uuid4().hex[:8]}", + on_connection_change=_on_connection_change, + connection_grace_period=0, + ) + await client.start() + try: + async with asyncio.timeout(CONNECT_TIMEOUT): + await connected.wait() + try: + settings = await client.get_settings() + except TimeoutError, ConnectionError: + return None + if settings.settings is None: + return None + return settings.settings.preference_display_name + finally: + await client.stop() + + +class HarborCoordinator(DataUpdateCoordinator[HarborDeviceState]): + """Own the MQTT transport and state for a single Harbor camera.""" + + config_entry: HarborConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: HarborConfigEntry, + config: HarborCameraConfig, + ) -> None: + """Initialize the Harbor coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"{DOMAIN}_{config.serial}", + ) + self._config = config + self.device = HarborCamera(config) + self.data = self.device.state + self.connected = False + self._ssl_context_cache: dict[str, Any] = {} + self._mqtt_client: HarborMQTTClient | None = None + self._connected_event = asyncio.Event() + self._data_event = asyncio.Event() + self._unsubscribe_updates = self.device.subscribe_updates( + self._handle_device_update + ) + + async def async_start(self) -> None: + """Start the Harbor MQTT client.""" + hass_instance_id = await instance_id.async_get(self.hass) + client_id = ( + f"{DOMAIN}-{hass_instance_id[:8]}-" + f"{self.config_entry.entry_id[:8]}-{self._config.serial}" + ) + self._mqtt_client = HarborMQTTClient( + config=self._config, + topics=self.device.get_topics(), + message_handler=self.device.handle_message, + client_id=client_id, + ssl_context_cache=self._ssl_context_cache, + on_connection_change=self._async_set_connected, + # Fetch the full settings snapshot on every (re)connection so the + # device name and settings-derived state populate immediately + # instead of waiting for the next heartbeat. + initial_commands=DEFAULT_INITIAL_COMMANDS, + ) + await self._mqtt_client.start() + + async def async_wait_until_ready(self) -> None: + """Wait for the first MQTT connection and the first device data. + + Registering entities only once the camera's first message has + arrived means the device registry sees the real name and firmware + from the start, instead of a placeholder that would otherwise + persist until the next reload. + + Raises ``TimeoutError`` if the camera does not connect and report + data in time. + """ + async with asyncio.timeout(CONNECT_TIMEOUT): + await self._connected_event.wait() + await self._data_event.wait() + + @override + async def async_shutdown(self) -> None: + """Stop the MQTT client and release device resources.""" + await super().async_shutdown() + if self._mqtt_client is not None: + await self._mqtt_client.stop() + self._mqtt_client = None + self._unsubscribe_updates() + self.device.shutdown() + + @property + def device_info(self) -> DeviceInfo: + """Return device info for the Harbor camera.""" + state = self.data + return DeviceInfo( + identifiers={(DOMAIN, state.serial)}, + manufacturer=MANUFACTURER, + model=MODEL, + name=state.display_name or f"{MODEL} {state.serial}", + serial_number=state.serial, + sw_version=state.os_version, + ) + + def _handle_device_update(self, state: HarborDeviceState) -> None: + """Mirror a library device update into Home Assistant.""" + self._data_event.set() + self.async_set_updated_data(state) + + async def _async_set_connected(self, connected: bool) -> None: + """Propagate the MQTT connection state to entity availability.""" + if connected: + self._connected_event.set() + if self.connected == connected: + return + self.connected = connected + self.async_update_listeners() diff --git a/homeassistant/components/harbor/entity.py b/homeassistant/components/harbor/entity.py new file mode 100644 index 000000000000..b04a3b3269bf --- /dev/null +++ b/homeassistant/components/harbor/entity.py @@ -0,0 +1,37 @@ +"""Base entities for Harbor.""" + +from typing import override + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import HarborCoordinator + + +class HarborEntity(CoordinatorEntity[HarborCoordinator]): + """Base Harbor entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HarborCoordinator, + unique_key: str, + ) -> None: + """Initialize the Harbor entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data.serial}_{unique_key}" + + @override + @property + def available(self) -> bool: + """Return if the entity is currently available.""" + if not self.coordinator.connected: + return False + return self.coordinator.data.last_seen is not None + + @override + @property + def device_info(self) -> DeviceInfo: + """Return the device info for the backing Harbor device.""" + return self.coordinator.device_info diff --git a/homeassistant/components/harbor/icons.json b/homeassistant/components/harbor/icons.json new file mode 100644 index 000000000000..50c18c3b3a7e --- /dev/null +++ b/homeassistant/components/harbor/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "num_viewers": { + "default": "mdi:account-eye" + }, + "stream_quality": { + "default": "mdi:signal" + }, + "wifi_strength": { + "default": "mdi:wifi" + } + } + } +} diff --git a/homeassistant/components/harbor/manifest.json b/homeassistant/components/harbor/manifest.json new file mode 100644 index 000000000000..a9f927b12828 --- /dev/null +++ b/homeassistant/components/harbor/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "harbor", + "name": "Harbor Sleep", + "codeowners": ["@Lash-L", "@afgarcia86"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/harbor", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["harbor"], + "quality_scale": "bronze", + "requirements": ["harbor-python==1.2.1"] +} diff --git a/homeassistant/components/harbor/quality_scale.yaml b/homeassistant/components/harbor/quality_scale.yaml new file mode 100644 index 000000000000..9fb9660e4441 --- /dev/null +++ b/homeassistant/components/harbor/quality_scale.yaml @@ -0,0 +1,73 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: This integration is push-based via MQTT and does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide additional 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 receive updates via the coordinator and do not subscribe to events directly. + 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: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + 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: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/harbor/sensor.py b/homeassistant/components/harbor/sensor.py new file mode 100644 index 000000000000..ee1d03260895 --- /dev/null +++ b/homeassistant/components/harbor/sensor.py @@ -0,0 +1,97 @@ +"""Sensor entities for Harbor.""" + +from typing import override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import HarborConfigEntry, HarborCoordinator +from .entity import HarborEntity + +PARALLEL_UPDATES = 0 + +CAMERA_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="num_viewers", + translation_key="num_viewers", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="bitrate", + translation_key="bitrate", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="wifi_strength", + translation_key="wifi_strength", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="stream_quality", + translation_key="stream_quality", + device_class=SensorDeviceClass.ENUM, + options=["excellent", "fair", "good", "poor"], + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HarborConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Harbor sensors from a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + HarborSensor(coordinator, description) for description in CAMERA_SENSORS + ) + + +class HarborSensor(HarborEntity, SensorEntity): + """A Harbor sensor entity.""" + + def __init__( + self, + coordinator: HarborCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the Harbor sensor.""" + self.entity_description = description + super().__init__(coordinator, description.key) + + @override + @property + def native_value(self) -> StateType: + """Return the current sensor value.""" + value = self.coordinator.data.values.get(self.entity_description.key) + if ( + self.entity_description.device_class == SensorDeviceClass.ENUM + and value == "unknown" + ): + # The library falls back to the literal string "unknown" for any + # enum value it doesn't recognize; surface that as no value + # rather than a bogus member of the options list. + return None + return value diff --git a/homeassistant/components/harbor/strings.json b/homeassistant/components/harbor/strings.json new file mode 100644 index 000000000000..1d4c0bae7c10 --- /dev/null +++ b/homeassistant/components/harbor/strings.json @@ -0,0 +1,59 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_cert": "The client certificate must be a valid PEM certificate", + "invalid_key": "The private key must be a valid PEM private key", + "invalid_serial": "The serial number must be exactly 10 digits" + }, + "step": { + "user": { + "data": { + "cert_pem": "Client certificate", + "ip_address": "[%key:common::config_flow::data::ip%]", + "key_pem": "Private key", + "serial": "Serial number" + }, + "data_description": { + "cert_pem": "Paste the client certificate from the Harbor app.", + "ip_address": "The local IP address of the Harbor device.", + "key_pem": "Paste the private key that matches the client certificate.", + "serial": "The 10-digit serial number printed on the Harbor device." + }, + "title": "Set up Harbor" + } + } + }, + "entity": { + "sensor": { + "bitrate": { + "name": "Bitrate" + }, + "num_viewers": { + "name": "Viewers", + "unit_of_measurement": "viewers" + }, + "stream_quality": { + "name": "Stream quality", + "state": { + "excellent": "Excellent", + "fair": "Fair", + "good": "Good", + "poor": "Poor" + } + }, + "wifi_strength": { + "name": "Wi-Fi strength", + "unit_of_measurement": "bars" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to the Harbor camera. It may be offline or unreachable." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 861f54cdad7e..5ef4c22897d6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -307,6 +307,7 @@ FLOWS = { "guntamatic", "habitica", "hanna", + "harbor", "harman_luxury", "harmony", "hdfury", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a4dfb7730d68..5b860e42de22 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2758,6 +2758,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "harbor": { + "name": "Harbor Sleep", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "hardkernel": { "name": "Hardkernel", "integration_type": "hardware", diff --git a/requirements_all.txt b/requirements_all.txt index fd15fa146412..b3ccff7f8185 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1230,6 +1230,9 @@ habluetooth==6.26.5 # homeassistant.components.hanna hanna-cloud==0.0.7 +# homeassistant.components.harbor +harbor-python==1.2.1 + # homeassistant.components.cloud hass-nabucasa==2.2.0 diff --git a/tests/components/harbor/__init__.py b/tests/components/harbor/__init__.py new file mode 100644 index 000000000000..592e9ed1a213 --- /dev/null +++ b/tests/components/harbor/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Harbor integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up the Harbor integration in Home Assistant.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/harbor/conftest.py b/tests/components/harbor/conftest.py new file mode 100644 index 000000000000..09b59dc3d250 --- /dev/null +++ b/tests/components/harbor/conftest.py @@ -0,0 +1,118 @@ +"""Common fixtures for the Harbor tests.""" + +from collections.abc import Awaitable, Callable, Generator +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.harbor.const import ( + CONF_CERT_PEM, + CONF_KEY_PEM, + CONF_SERIAL, + DOMAIN, +) +from homeassistant.const import CONF_IP_ADDRESS + +from tests.common import MockConfigEntry + +SERIAL = "1234567890" +CERT_PEM = "-----BEGIN CERTIFICATE-----\nMIIBdummy\n-----END CERTIFICATE-----" +KEY_PEM = "-----BEGIN PRIVATE KEY-----\nMIIBdummy\n-----END PRIVATE KEY-----" + +HEARTBEAT_TOPIC = f"cameras/{SERIAL}/events/heartbeat" +LIVEKIT_TOPIC = f"cameras/{SERIAL}/events/local_livekit_heartbeat" + +HEARTBEAT_PAYLOAD: dict[str, Any] = { + "temperature": 98.6, + "os_version": "1.2.3", + "settings": {"preference_display_name": "Nursery"}, +} +LIVEKIT_PAYLOAD: dict[str, Any] = { + "bitrate": 1234.5, + "network_bars": 3, + "stream_quality": "GOOD", + "viewers_by_identity_full": { + "viewer-1": {"identity": "alice"}, + "viewer-2": {"identity": "bob"}, + }, + "os_version": "1.2.3", + "app_version": "4.5.6", +} + + +def connection_callback( + mock_mqtt_client: AsyncMock, +) -> Callable[[bool], Awaitable[None]]: + """Return the on_connection_change callback the integration registered.""" + return mock_mqtt_client.call_args.kwargs["on_connection_change"] + + +async def emit_message( + mock_mqtt_client: AsyncMock, topic: str, payload: dict[str, Any] +) -> None: + """Deliver an MQTT message through the handler the integration registered.""" + await mock_mqtt_client.call_args.kwargs["message_handler"](topic, payload) + + +async def set_connected(mock_mqtt_client: AsyncMock, connected: bool) -> None: + """Drive the MQTT connection state the integration observes.""" + await connection_callback(mock_mqtt_client)(connected) + + +@pytest.fixture(autouse=True) +def mock_connect_timeout() -> Generator[None]: + """Patch the connect timeout so unreachable-camera tests run quickly.""" + with patch("homeassistant.components.harbor.coordinator.CONNECT_TIMEOUT", 0): + yield + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.harbor.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_mqtt_client() -> Generator[AsyncMock]: + """Mock the Harbor MQTT client, reporting a successful connection on start.""" + with patch( + "homeassistant.components.harbor.coordinator.HarborMQTTClient", + autospec=True, + ) as mock_client: + + async def _start() -> None: + await set_connected(mock_client, True) + # Setup waits for the first device message too; simulate the + # initial-commands response landing right after connect, the + # same way a real camera answers before any explicit test + # message. Empty so it doesn't set values tests don't expect. + await mock_client.call_args.kwargs["message_handler"](HEARTBEAT_TOPIC, {}) + + mock_client.return_value.start.side_effect = _start + # The config flow probes get-settings for the camera's friendly name; + # default to an unnamed camera so the title falls back to the serial. + mock_client.return_value.get_settings.return_value = SimpleNamespace( + settings=SimpleNamespace(preference_display_name=None) + ) + yield mock_client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock Harbor config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=SERIAL, + title=f"Camera {SERIAL}", + data={ + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) diff --git a/tests/components/harbor/snapshots/test_init.ambr b/tests/components/harbor/snapshots/test_init.ambr new file mode 100644 index 000000000000..101e73248cb6 --- /dev/null +++ b/tests/components/harbor/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'harbor', + '1234567890', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Harbor', + 'model': 'Harbor Camera', + 'model_id': None, + 'name': 'Nursery', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890', + 'sw_version': '1.2.3', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/harbor/snapshots/test_sensor.ambr b/tests/components/harbor/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..b24e5b1e26de --- /dev/null +++ b/tests/components/harbor/snapshots/test_sensor.ambr @@ -0,0 +1,289 @@ +# serializer version: 1 +# name: test_sensors[sensor.harbor_camera_1234567890_bitrate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.harbor_camera_1234567890_bitrate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bitrate', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bitrate', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bitrate', + 'unique_id': '1234567890_bitrate', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_bitrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'data_rate', + : 'Harbor Camera 1234567890 Bitrate', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_bitrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234.5', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_stream_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'excellent', + 'fair', + 'good', + 'poor', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.harbor_camera_1234567890_stream_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stream quality', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Stream quality', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stream_quality', + 'unique_id': '1234567890_stream_quality', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_stream_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Harbor Camera 1234567890 Stream quality', + : list([ + 'excellent', + 'fair', + 'good', + 'poor', + ]), + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_stream_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.harbor_camera_1234567890_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Harbor Camera 1234567890 Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.0', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_viewers-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.harbor_camera_1234567890_viewers', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Viewers', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Viewers', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'num_viewers', + 'unique_id': '1234567890_num_viewers', + 'unit_of_measurement': 'viewers', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_viewers-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Harbor Camera 1234567890 Viewers', + : , + : 'viewers', + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_viewers', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_wi_fi_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.harbor_camera_1234567890_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi strength', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi strength', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': '1234567890_wifi_strength', + 'unit_of_measurement': 'bars', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_wi_fi_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Harbor Camera 1234567890 Wi-Fi strength', + : , + : 'bars', + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_wi_fi_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/harbor/test_config_flow.py b/tests/components/harbor/test_config_flow.py new file mode 100644 index 000000000000..238d2a662749 --- /dev/null +++ b/tests/components/harbor/test_config_flow.py @@ -0,0 +1,231 @@ +"""Test the Harbor config flow.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.harbor.const import ( + CONF_CERT_PEM, + CONF_KEY_PEM, + CONF_SERIAL, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import CERT_PEM, KEY_PEM, SERIAL, set_connected + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_mqtt_client") +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mqtt_client: AsyncMock, +) -> None: + """Test the full user flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Camera {SERIAL}" + assert result["result"].unique_id == SERIAL + assert result["data"] == { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + } + client_id = mock_mqtt_client.call_args.kwargs["client_id"] + assert client_id.startswith(f"{DOMAIN}-{SERIAL}-probe-") + assert client_id != f"{DOMAIN}-{SERIAL}-probe" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_uses_friendly_name( + hass: HomeAssistant, mock_mqtt_client: AsyncMock +) -> None: + """Test the entry is titled with the camera's friendly name when set.""" + mock_mqtt_client.return_value.get_settings.return_value = SimpleNamespace( + settings=SimpleNamespace(preference_display_name="Nursery") + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nursery" + + +@pytest.mark.parametrize( + ("user_input", "error_field", "error"), + [ + ( + { + CONF_SERIAL: "123", + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_SERIAL, + "invalid_serial", + ), + ( + { + CONF_SERIAL: "abcdefghij", + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_SERIAL, + "invalid_serial", + ), + ( + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: "not a cert", + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_CERT_PEM, + "invalid_cert", + ), + ( + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: "not a key", + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_KEY_PEM, + "invalid_key", + ), + ], + ids=["short_serial", "non_digit_serial", "bad_cert", "bad_key"], +) +@pytest.mark.usefixtures("mock_mqtt_client", "mock_setup_entry") +async def test_user_flow_validation_errors( + hass: HomeAssistant, + user_input: dict[str, str], + error_field: str, + error: str, +) -> None: + """Test validation errors are surfaced and recoverable.""" + 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 + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {error_field: error} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the flow aborts when the serial 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"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_cannot_connect( + hass: HomeAssistant, + mock_mqtt_client: AsyncMock, +) -> None: + """Test the flow shows an error and recovers when the camera is unreachable.""" + # Start the probe client without ever reporting a successful connection. + mock_mqtt_client.return_value.start.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # A subsequent connection succeeds and the entry is created. + async def _start() -> None: + await set_connected(mock_mqtt_client, True) + + mock_mqtt_client.return_value.start.side_effect = _start + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/harbor/test_init.py b/tests/components/harbor/test_init.py new file mode 100644 index 000000000000..ef0d69148f2a --- /dev/null +++ b/tests/components/harbor/test_init.py @@ -0,0 +1,144 @@ +"""Test the Harbor integration setup and coordinator.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.harbor.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .conftest import ( + HEARTBEAT_PAYLOAD, + HEARTBEAT_TOPIC, + SERIAL, + emit_message, + set_connected, +) + +from tests.common import MockConfigEntry + +# The default test fixture reports no device data on connect, so the device +# keeps its placeholder name and the entity id derives from that. +_SENSOR = "sensor.harbor_camera_1234567890_temperature" + + +async def test_setup_and_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test a config entry loads, starts the client, and unloads cleanly.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_mqtt_client.return_value.start.called + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert mock_mqtt_client.return_value.stop.called + + +async def test_setup_uses_instance_scoped_mqtt_client_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test setup uses an MQTT client id unique to this HA instance.""" + await setup_integration(hass, mock_config_entry) + + client_id = mock_mqtt_client.call_args.kwargs["client_id"] + + assert client_id.startswith(f"{DOMAIN}-") + assert client_id.endswith(f"-{SERIAL}") + assert client_id != f"{DOMAIN}-{SERIAL}" + + +async def test_setup_retry_when_unreachable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test setup is retried when the camera never connects.""" + # Start the client without ever reporting a successful connection. + mock_mqtt_client.return_value.start.side_effect = None + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_mqtt_client.return_value.stop.called + + +async def test_setup_retry_when_no_data_arrives( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test setup is retried when the camera connects but never sends data.""" + + async def _start() -> None: + await set_connected(mock_mqtt_client, True) + + mock_mqtt_client.return_value.start.side_effect = _start + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_mqtt_client.return_value.stop.called + + +async def test_availability_follows_connection( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test entity availability tracks the MQTT connection.""" + await setup_integration(hass, mock_config_entry) + + # Setup waits for the first device message, so entities start available. + assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE + + # A repeated connected signal is a no-op and keeps entities available. + await set_connected(mock_mqtt_client, True) + await hass.async_block_till_done() + assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE + + # Losing the connection flips entities back to unavailable. + await set_connected(mock_mqtt_client, False) + await hass.async_block_till_done() + assert hass.states.get(_SENSOR).state == STATE_UNAVAILABLE + + # Reconnecting restores availability without needing fresh device data. + await set_connected(mock_mqtt_client, True) + await hass.async_block_till_done() + assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE + + +async def test_device_registry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the device adopts the name and firmware from the first message. + + Setup waits for that first message before registering entities, so the + device is correct from the start instead of needing a later reload. + """ + + async def _start() -> None: + await set_connected(mock_mqtt_client, True) + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + + mock_mqtt_client.return_value.start.side_effect = _start + + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL)}) + assert device == snapshot diff --git a/tests/components/harbor/test_sensor.py b/tests/components/harbor/test_sensor.py new file mode 100644 index 000000000000..79502050034a --- /dev/null +++ b/tests/components/harbor/test_sensor.py @@ -0,0 +1,93 @@ +"""Test the Harbor sensors.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import ( + HEARTBEAT_PAYLOAD, + HEARTBEAT_TOPIC, + LIVEKIT_PAYLOAD, + LIVEKIT_TOPIC, + emit_message, +) + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the Harbor sensors report their values.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + await emit_message(mock_mqtt_client, LIVEKIT_TOPIC, LIVEKIT_PAYLOAD) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_missing_values_are_unknown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test sensors without a value in the payload report unknown.""" + await setup_integration(hass, mock_config_entry) + + # Only the heartbeat arrives; sensors fed by the LiveKit message stay unknown. + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.harbor_camera_1234567890_temperature").state == "37.0" + ) + assert ( + hass.states.get("sensor.harbor_camera_1234567890_bitrate").state + == STATE_UNKNOWN + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_unexpected_enum_value_stays_valid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test a stream quality outside the declared options surfaces as unknown. + + The library maps unrecognized enum values onto its own "unknown" member; + the sensor treats that as no value rather than exposing "unknown" as a + literal enum option. + """ + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.harbor_camera_1234567890_stream_quality" + + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + await emit_message(mock_mqtt_client, LIVEKIT_TOPIC, LIVEKIT_PAYLOAD) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == "good" + + # The camera reports a stream quality outside the known set. + await emit_message( + mock_mqtt_client, + LIVEKIT_TOPIC, + {**LIVEKIT_PAYLOAD, "stream_quality": "DEGRADED"}, + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNKNOWN