From 773553e61b09b0bf28ab1953ee6bc53486dd499e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 17:34:40 +0200 Subject: [PATCH 01/10] Mitigate crash in ScannerEntity related to device registry changes (#176684) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/device_tracker/entity.py | 28 ++++-- .../components/device_tracker/test_entity.py | 91 +++++++++++++++++++ 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/device_tracker/entity.py b/homeassistant/components/device_tracker/entity.py index c12320dcb16e0..ffa8a1197fc63 100644 --- a/homeassistant/components/device_tracker/entity.py +++ b/homeassistant/components/device_tracker/entity.py @@ -707,19 +707,31 @@ async def async_internal_added_to_hass(self) -> None: await super().async_internal_added_to_hass() return + dev_reg = dr.async_get(self.hass) + # find_device_entry may return a synthesized pre-migration composite whose id is + # not a real device and can't be assigned to an entity; resolve it to the split + # owned by this config entry so we attach to a concrete device. + if device_entry.id not in dev_reg.devices: + device_entry = next( + ( + split + for split in dev_reg.async_get_devices_for_composite_device_id( + device_entry.id + ) + if split.config_entry_id == self.platform.config_entry.entry_id + ), + None, + ) + # Attach entry to device - if self.registry_entry.device_id != device_entry.id: + if ( + device_entry is not None + and self.registry_entry.device_id != device_entry.id + ): self.registry_entry = er.async_get(self.hass).async_update_entity( self.entity_id, device_id=device_entry.id ) - # Attach device to config entry - if self.platform.config_entry.entry_id not in device_entry.config_entries: - dr.async_get(self.hass).async_update_device( - device_entry.id, - add_config_entry_id=self.platform.config_entry.entry_id, - ) - # Do this last or else the entity registry update listener has been installed await super().async_internal_added_to_hass() diff --git a/tests/components/device_tracker/test_entity.py b/tests/components/device_tracker/test_entity.py index 230398378a0e2..c2ffa6bcfe4a6 100644 --- a/tests/components/device_tracker/test_entity.py +++ b/tests/components/device_tracker/test_entity.py @@ -3,6 +3,7 @@ from collections.abc import Generator from typing import Any +import attr import pytest from homeassistant.components.device_tracker import ( @@ -1611,6 +1612,96 @@ async def test_register_mac_ignored( assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION +async def test_scanner_entity_attaches_to_split_of_composite_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that a scanner entity attaches to its config entry's split device.""" + mac = TEST_MAC_ADDRESS + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + old_id = "composite00000000000000000000000" + own_split = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={(TEST_DOMAIN, "own")}, + ) + other_split = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={("other", "x")}, + ) + # Simulate a migration split: both devices share the pre-migration composite id + device_registry.devices[own_split.id] = attr.evolve( + own_split, composite_device_id=old_id + ) + device_registry.devices[other_split.id] = attr.evolve( + other_split, composite_device_id=old_id + ) + # async_get_device now resolves the shared MAC to the synthesized composite + composite = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert composite is not None + assert composite.id == old_id + assert old_id not in device_registry.devices + + scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") + scanner_entity.entity_id = "device_tracker.composite_scanner" + await create_mock_platform(hass, config_entry, [scanner_entity]) + + # Attached to its own split, not the un-assignable composite id + entity_entry = entity_registry.async_get("device_tracker.composite_scanner") + assert entity_entry is not None + assert entity_entry.device_id == own_split.id + + +async def test_scanner_entity_composite_device_without_own_split( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """A composite with no split owned by the scanner's config entry attaches nothing. + + The composite id is not a real device and can't be assigned to an entity, so with no + split to resolve to the entity is added without a device instead of raising. + """ + mac = TEST_MAC_ADDRESS + other_entry_1 = MockConfigEntry(domain="other_1") + other_entry_1.add_to_hass(hass) + other_entry_2 = MockConfigEntry(domain="other_2") + other_entry_2.add_to_hass(hass) + old_id = "composite00000000000000000000000" + # Both splits belong to other config entries, none to the scanner's + for entry, identifier in ((other_entry_1, "one"), (other_entry_2, "two")): + split = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={("other", identifier)}, + ) + device_registry.devices[split.id] = attr.evolve( + split, composite_device_id=old_id + ) + composite = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert composite is not None + assert composite.id == old_id + assert old_id not in device_registry.devices + + scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") + scanner_entity.entity_id = "device_tracker.composite_scanner" + await create_mock_platform(hass, config_entry, [scanner_entity]) + + # Added without a device rather than raising on the un-assignable composite id + entity_entry = entity_registry.async_get("device_tracker.composite_scanner") + assert entity_entry is not None + assert entity_entry.device_id is None + + async def test_connected_device_registered( hass: HomeAssistant, config_entry: MockConfigEntry, From 1917e8a877ca7de50f999f388f84f1276a76803b Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 17 Jul 2026 11:44:46 -0400 Subject: [PATCH 02/10] 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 bfa4aad156b65..5b7f9b411a16f 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 0000000000000..1f10679688d0c --- /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 0000000000000..05d222fea54c4 --- /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 0000000000000..f9b5670e332bd --- /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 0000000000000..55afb751b6635 --- /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 0000000000000..b04a3b3269bfa --- /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 0000000000000..50c18c3b3a7e5 --- /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 0000000000000..a9f927b12828c --- /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 0000000000000..9fb9660e44415 --- /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 0000000000000..ee1d03260895b --- /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 0000000000000..1d4c0bae7c108 --- /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 861f54cdad7e7..5ef4c22897d6f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -307,6 +307,7 @@ "guntamatic", "habitica", "hanna", + "harbor", "harman_luxury", "harmony", "hdfury", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a4dfb7730d683..5b860e42de22c 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 fd15fa1464120..b3ccff7f81859 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 0000000000000..592e9ed1a2133 --- /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 0000000000000..09b59dc3d2502 --- /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 0000000000000..101e73248cb6b --- /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 0000000000000..b24e5b1e26ded --- /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 0000000000000..238d2a662749a --- /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 0000000000000..ef0d69148f2aa --- /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 0000000000000..79502050034ac --- /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 From a868b498e2b6a02d97b5ba0db5ac1ad238972236 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 17:51:13 +0200 Subject: [PATCH 03/10] Deprecate passing add_helper_config_entry_to_device to async_handle_source_entity_changes (#176701) --- .../components/derivative/__init__.py | 1 - .../components/generic_hygrostat/__init__.py | 1 - .../components/generic_thermostat/__init__.py | 1 - .../components/history_stats/__init__.py | 1 - .../components/integration/__init__.py | 1 - .../components/mold_indicator/__init__.py | 1 - .../components/statistics/__init__.py | 1 - .../components/switch_as_x/__init__.py | 1 - .../components/threshold/__init__.py | 1 - homeassistant/components/trend/__init__.py | 1 - .../components/utility_meter/__init__.py | 1 - homeassistant/helpers/helper_integration.py | 41 ++++++------ tests/helpers/test_helper_integration.py | 63 ++++++++++++++++++- 13 files changed, 84 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index ce593e5f8f8c4..9814bb80b6d91 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -27,7 +27,6 @@ def set_source_entity_id_or_uuid(source_entity_id: str) -> None: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index 9af17b89c1ce5..9540869b2765d 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -105,7 +105,6 @@ def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None: # humidifier's device. async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index e2e997b9c11bf..75f552b2850a6 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -33,7 +33,6 @@ def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None: # heater's device. async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index ebfb13653254e..35745d6ebfbbd 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -78,7 +78,6 @@ async def source_entity_removed() -> None: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index eb8650dc6490c..1a0bf8401f769 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -29,7 +29,6 @@ def set_source_entity_id_or_uuid(source_entity_id: str) -> None: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py index d60b5f0c696d3..77bbb507849f5 100644 --- a/homeassistant/components/mold_indicator/__init__.py +++ b/homeassistant/components/mold_indicator/__init__.py @@ -37,7 +37,6 @@ def set_source_entity_id_or_uuid(source_entity_id: str) -> None: # to the humidity sensor's device. async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index 49dcb19ceb566..4de69276a9a23 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -35,7 +35,6 @@ async def source_entity_removed() -> None: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index ef0a5cc5e3a0b..e44aa0da3b1d7 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -60,7 +60,6 @@ async def source_entity_removed() -> None: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_get_parent_device_id(hass, entity_id), diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index 695d738596033..1be37133e03ea 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -27,7 +27,6 @@ def set_source_entity_id_or_uuid(source_entity_id: str) -> None: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index c5a8549e91c09..a3f721fe6689a 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -34,7 +34,6 @@ async def source_entity_removed() -> None: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index a0e2c77341c62..8fb244b18df8d 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -205,7 +205,6 @@ def set_source_entity_id_or_uuid(source_entity_id: str) -> None: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index c433040a6c569..ba9f5191b6c2d 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -7,17 +7,18 @@ from . import device_registry as dr, entity_registry as er from .event import async_track_entity_registry_updated_event +from .frame import ReportBehavior, report_usage def async_handle_source_entity_changes( hass: HomeAssistant, *, - add_helper_config_entry_to_device: bool = True, helper_config_entry_id: str, set_source_entity_id_or_uuid: Callable[[str], None], source_device_id: str | None, source_entity_id_or_uuid: str, source_entity_removed: Callable[[], Coroutine[Any, Any, None]] | None = None, + **kwargs: Any, ) -> CALLBACK_TYPE: """Handle changes to a helper entity's source entity. @@ -31,11 +32,9 @@ def async_handle_source_entity_changes( called. If the source entity is identified by a UUID, the helper config entry is reloaded. - Source entity moved to another device: The helper entity is updated to link - to the new device, and the helper config entry removed from the old device - and added to the new device. Then the helper config entry is reloaded. + to the new device. Then the helper config entry is reloaded. - Source entity removed from the device: The helper entity is updated to link - to no device, and the helper config entry removed from the old device. Then - the helper config entry is reloaded. + to no device. Then the helper config entry is reloaded. :param set_source_entity_id_or_uuid: A function which updates the source entity ID or UUID, e.g., in the helper config entry options. @@ -43,6 +42,22 @@ def async_handle_source_entity_changes( is removed. This can be used to clean up any resources related to the source entity or ask the user to select a new source entity. """ + if "add_helper_config_entry_to_device" in kwargs: + del kwargs["add_helper_config_entry_to_device"] + # Adding the helper's config entry to the source device is no longer supported + # now that a device belongs to a single config entry; the helper entities link to + # the source device via their device_id instead. + report_usage( + "calls async_handle_source_entity_changes with " + "add_helper_config_entry_to_device, which no longer has any effect", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", + ) + if kwargs: + raise TypeError( + "async_handle_source_entity_changes() got unexpected keyword arguments " + f"{', '.join(map(repr, kwargs))}" + ) async def async_registry_updated( event: Event[er.EventEntityRegistryUpdatedData], @@ -89,9 +104,8 @@ async def async_registry_updated( # No need to do any cleanup return - # The source entity has been moved to a different device, update the helper - # entities to link to the new device and the helper device to include the - # helper config entry + # The source entity has been moved to a different device; relink the helper + # entities to the new device. for helper_entity in entity_registry.entities.get_entries_for_config_entry_id( helper_config_entry_id ): @@ -100,17 +114,6 @@ async def async_registry_updated( helper_entity.entity_id, device_id=source_entity_entry.device_id ) - if add_helper_config_entry_to_device: - if source_entity_entry.device_id is not None: - device_registry.async_update_device( - source_entity_entry.device_id, - add_config_entry_id=helper_config_entry_id, - ) - - device_registry.async_update_device( - source_device_id, remove_config_entry_id=helper_config_entry_id - ) - source_device_id = source_entity_entry.device_id # Reload the config entry so the helper entity is recreated with diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 7b6d713419ced..77b6ae2d2dfb6 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -1,7 +1,7 @@ """Tests for the helper entity helpers.""" from collections.abc import Generator -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -213,6 +213,67 @@ def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: return events +@pytest.mark.parametrize("add_helper_config_entry_to_device", [True, False]) +async def test_async_handle_source_entity_changes_deprecated_kwarg( + hass: HomeAssistant, + add_helper_config_entry_to_device: bool, +) -> None: + """The removed add_helper_config_entry_to_device kwarg is accepted but reported. + + It is swallowed by **kwargs so callers still passing it don't raise, and reported on + its presence rather than its value, since it no longer has any effect either way. + """ + with patch("homeassistant.helpers.helper_integration.report_usage") as report_usage: + unsub = async_handle_source_entity_changes( + hass, + helper_config_entry_id="helper_config_entry_id", + set_source_entity_id_or_uuid=Mock(), + source_device_id=None, + source_entity_id_or_uuid="sensor.test", + add_helper_config_entry_to_device=add_helper_config_entry_to_device, + ) + unsub() + + report_usage.assert_called_once() + assert "add_helper_config_entry_to_device" in report_usage.call_args[0][0] + + +async def test_async_handle_source_entity_changes_rejects_unknown_kwarg( + hass: HomeAssistant, +) -> None: + """An unknown keyword argument still raises, as it did before **kwargs was added. + + **kwargs only exists to swallow the deprecated add_helper_config_entry_to_device; + anything else (e.g. a misspelling) must not be silently accepted. + """ + with pytest.raises(TypeError, match="unexpected keyword arguments 'unknown_kwarg'"): + async_handle_source_entity_changes( + hass, + helper_config_entry_id="helper_config_entry_id", + set_source_entity_id_or_uuid=Mock(), + source_device_id=None, + source_entity_id_or_uuid="sensor.test", + unknown_kwarg=True, + ) + + +async def test_async_handle_source_entity_changes_without_deprecated_kwarg( + hass: HomeAssistant, +) -> None: + """Not passing the removed add_helper_config_entry_to_device kwarg is not reported.""" + with patch("homeassistant.helpers.helper_integration.report_usage") as report_usage: + unsub = async_handle_source_entity_changes( + hass, + helper_config_entry_id="helper_config_entry_id", + set_source_entity_id_or_uuid=Mock(), + source_device_id=None, + source_entity_id_or_uuid="sensor.test", + ) + unsub() + + report_usage.assert_not_called() + + @pytest.mark.parametrize("source_entity_removed", [None]) @pytest.mark.parametrize("use_entity_registry_id", [True, False]) @pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") From 7f493398c9807b59e7894b02b6d0528e7237e8de Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 17 Jul 2026 18:12:42 +0200 Subject: [PATCH 04/10] Update frontend to 20260624.6 (#176711) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pylint/plugins/pylint_home_assistant/generated/mdi_icons.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index db81df79958a2..19724c2850439 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260624.5"] + "requirements": ["home-assistant-frontend==20260624.6"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9d1303105b2cd..176976e770e54 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.26.5 hass-nabucasa==2.2.0 hassil==3.8.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260624.5 +home-assistant-frontend==20260624.6 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 8cdb0231c569e..bee392ebe525d 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260624.5" +FRONTEND_VERSION: Final[str] = "20260624.6" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index b3ccff7f81859..ceb7f8943a281 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1281,7 +1281,7 @@ hole==0.9.2 holidays==0.100 # homeassistant.components.frontend -home-assistant-frontend==20260624.5 +home-assistant-frontend==20260624.6 # homeassistant.components.conversation home-assistant-intents==2026.6.24 From 24a1d5956223760076e611e148ba74c88cf84fc5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 17 Jul 2026 18:14:07 +0200 Subject: [PATCH 05/10] Refresh add-on update entities after store reload through Supervisor API proxy (#176648) Co-authored-by: Claude --- .../components/hassio/coordinator.py | 5 ++ .../components/hassio/websocket_api.py | 14 ++++++ tests/components/hassio/test_websocket_api.py | 50 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 191d3b6e23381..0dd9e2da0187e 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -1411,6 +1411,11 @@ async def _async_refresh( log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error ) + async def async_refresh_after_store_reload(self) -> None: + """Refresh addon data when the store was already reloaded externally.""" + async with self._debounced_refresh.async_lock(): + await super()._async_refresh(log_failures=True) + async def force_addon_info_data_refresh(self, addon_slug: str) -> None: """Force refresh of addon info data for a specific addon.""" try: diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index ed3034437e1fb..dea7dbfbd45ae 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -20,6 +20,7 @@ from .config import HassioUpdateParametersDict from .const import ( + ADDONS_COORDINATOR, ATTR_DATA, ATTR_ENDPOINT, ATTR_METHOD, @@ -59,6 +60,10 @@ r")$" ) +# Endpoint that reloads the add-on store. Afterwards the add-on update +# entities must be refreshed so they don't report stale update information. +STORE_RELOAD_ENDPOINT = "/store/reload" + _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -159,6 +164,15 @@ async def websocket_supervisor_api( # sensitive information and the frontend does not require it for ingress. if not connection.user.is_admin and WS_ADDONS_INFO_ENDPOINT.match(command): data.pop("options", None) + # Await so the frontend only sees the reload finish once the add-on + # update entities reflect the reloaded store. + if ( + command == STORE_RELOAD_ENDPOINT + and msg[ATTR_METHOD] == "post" + and (coordinator := hass.data.get(ADDONS_COORDINATOR)) + ): + await coordinator.async_refresh_after_store_reload() + connection.send_result(msg[WS_ID], data) diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index df4700467bcea..3b666f9e430f6 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -375,6 +375,56 @@ async def test_websocket_non_admin_user( assert msg["error"]["message"] == "Unauthorized" +async def test_websocket_store_reload_refreshes_update_entities( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, + addons_list: AsyncMock, +) -> None: + """Test add-on update entities refresh after a store reload via the API proxy.""" + addons_list.return_value = [ + replace( + addons_list.return_value[0], + update_available=False, + version_latest="2.0.0", + ) + ] + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component(hass, DOMAIN, {"hassio": {}}) + await hass.async_block_till_done() + + assert hass.states.get("update.test_update").state == "off" + + addons_list.return_value = [ + replace( + addons_list.return_value[0], + update_available=True, + version_latest="2.0.1", + ) + ] + aioclient_mock.post( + "http://127.0.0.1/store/reload", json={"result": "ok", "data": {}} + ) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json_auto_id( + { + WS_TYPE: WS_TYPE_API, + ATTR_ENDPOINT: "/store/reload", + ATTR_METHOD: "post", + } + ) + msg = await websocket_client.receive_json() + assert msg["success"] + + assert hass.states.get("update.test_update").state == "on" + supervisor_client.store.reload.assert_not_called() + + async def test_update_addon( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From 39e32ce0b2817f489cc47326eb9e8eb054715c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 17 Jul 2026 17:53:06 +0100 Subject: [PATCH 06/10] Add playwright to e2e tests workflow (#176520) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/e2e-tests.yml | 56 ++++++++++++++++++++------------- .gitignore | 4 +++ .prettierignore | 1 + tests/e2e/onboarding.spec.ts | 10 ++++++ tests/e2e/package.json | 13 ++++++++ tests/e2e/playwright.config.ts | 21 +++++++++++++ tests/e2e/pnpm-lock.yaml | 52 ++++++++++++++++++++++++++++++ 7 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/onboarding.spec.ts create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/playwright.config.ts create mode 100644 tests/e2e/pnpm-lock.yaml diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b3784dca600af..97fd2dfc6fdfd 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -31,7 +31,6 @@ jobs: runs-on: ubuntu-24.04-arm env: BASE_URL: http://localhost:8123 - CURL_OPTS: --silent --max-time 10 services: homeassistant: image: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} # zizmor: ignore[unpinned-images] @@ -44,28 +43,43 @@ jobs: --health-interval=5s --health-retries=60 steps: - - name: Check frontend is served - run: | - # Pre-onboarding, / redirects to /onboarding.html; --location follows it - status=$(curl $CURL_OPTS --location --output /dev/null --write-out '%{http_code}' "$BASE_URL/") - if [ "$status" -ne 200 ]; then - echo "::error::Expected HTTP 200 from frontend, got $status" - exit 1 - fi + - name: Check out code from GitHub + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - name: Check onboarding API responds - run: | - curl $CURL_OPTS --fail "$BASE_URL/api/onboarding" \ - | jq -e 'type == "array" and length > 0' + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: tests/e2e/package.json - - name: Check container is still running - env: - CONTAINER: ${{ job.services.homeassistant.id }} - run: | - if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then - echo "::error::Container is no longer running after checks" - exit 1 - fi + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: tests/e2e/pnpm-lock.yaml + + - name: Install E2E test dependencies + working-directory: tests/e2e + run: pnpm install --frozen-lockfile + + - name: Install Playwright browser + working-directory: tests/e2e + run: pnpm exec playwright install --with-deps chromium + + - name: Run Playwright E2E tests + working-directory: tests/e2e + run: pnpm exec playwright test + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report-${{ matrix.arch }} + path: | + tests/e2e/playwright-report/ + tests/e2e/test-results/ - name: Dump container logs if: always() diff --git a/.gitignore b/.gitignore index 9d8cbaf15e091..5fb2ad904d146 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,7 @@ pytest_buckets.txt .claude/worktrees/ .serena/ +# Playwright e2e tests +tests/e2e/node_modules/ +tests/e2e/playwright-report/ +tests/e2e/test-results/ diff --git a/.prettierignore b/.prettierignore index c632909966615..54c2d65e4d628 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ homeassistant/generated/* tests/components/lidarr/fixtures/initialize.js tests/components/lidarr/fixtures/initialize-wrong.js tests/fixtures/core/config/yaml_errors/ +tests/e2e/pnpm-lock.yaml diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts new file mode 100644 index 0000000000000..d6431b3d1ae64 --- /dev/null +++ b/tests/e2e/onboarding.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from "@playwright/test"; + +test("fresh instance redirects to onboarding and renders the UI", async ({ + page, +}) => { + await page.goto("/"); + + await expect(page).toHaveURL(/\/onboarding\.html/); + await expect(page.locator("ha-onboarding")).toBeVisible(); +}); diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000000000..bb69b43f05bd7 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "home-assistant-e2e-tests", + "version": "1.0.0", + "description": "End-to-end browser tests for Home Assistant Core", + "private": true, + "packageManager": "pnpm@11.13.0", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.61.1" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000000000..f130b643c4c0d --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = process.env.BASE_URL ?? "http://localhost:8123"; + +export default defineConfig({ + testDir: ".", + timeout: 30_000, + // Reruns a failed test once in CI to absorb transient startup flakiness. + retries: process.env.CI ? 1 : 0, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL, + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/tests/e2e/pnpm-lock.yaml b/tests/e2e/pnpm-lock.yaml new file mode 100644 index 0000000000000..51cd78654eff9 --- /dev/null +++ b/tests/e2e/pnpm-lock.yaml @@ -0,0 +1,52 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: 1.61.1 + version: 1.61.1 + +packages: + + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + + fsevents@2.3.2: + optional: true + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 From ebd70b0cd7d3487c4c22fb13fd547d5647e78e08 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:53:19 -0400 Subject: [PATCH 07/10] Adjust code owner list for Sonos (#176723) --- CODEOWNERS | 4 ++-- homeassistant/components/sonos/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 5b7f9b411a16f..5c6830e928823 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1719,8 +1719,8 @@ CLAUDE.md @home-assistant/core /tests/components/sonarr/ @ctalkington /homeassistant/components/songpal/ @rytilahti @shenxn /tests/components/songpal/ @rytilahti @shenxn -/homeassistant/components/sonos/ @jjlawren @peterager -/tests/components/sonos/ @jjlawren @peterager +/homeassistant/components/sonos/ @peterager @jjlawren +/tests/components/sonos/ @peterager @jjlawren /homeassistant/components/soundtouch/ @kroimon /tests/components/soundtouch/ @kroimon /homeassistant/components/spaceapi/ @fabaff diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 001f0c9e220e4..90d59e0c7db4f 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -2,7 +2,7 @@ "domain": "sonos", "name": "Sonos", "after_dependencies": ["plex", "spotify", "zeroconf", "media_source"], - "codeowners": ["@jjlawren", "@peterager"], + "codeowners": ["@peterager", "@jjlawren"], "config_flow": true, "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/sonos", From 24e1f1bd39286d6241899eee5e4de70124e33bab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:57:20 +0200 Subject: [PATCH 08/10] Bump actions/stale from 10.3.0 to 10.4.0 (#176591) --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 06f1638125f62..91798343783d6 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -42,7 +42,7 @@ jobs: # - Issues # - No issues marked as no-stale or help-wanted - name: 60 days stale PRs policy and 90 days stale issue policy - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: repo-token: ${{ steps.token.outputs.token }} remove-stale-when-updated: true From dc8eb62d41ade706fa27ccfa8b6365e5ef69986b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 17 Jul 2026 20:02:23 +0100 Subject: [PATCH 09/10] Review full branch diff against base in ha-review skills (#176712) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .claude/skills/ha-pr-reviewer/SKILL.md | 2 +- .claude/skills/ha-review/SKILL.md | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.claude/skills/ha-pr-reviewer/SKILL.md b/.claude/skills/ha-pr-reviewer/SKILL.md index 35c2ecd817813..05060b3de2f04 100644 --- a/.claude/skills/ha-pr-reviewer/SKILL.md +++ b/.claude/skills/ha-pr-reviewer/SKILL.md @@ -8,7 +8,7 @@ description: Reviews Home Assistant GitHub pull requests and provides feedback c ## Instructions: - Use 'gh pr view' to get the PR details and description. - Use 'gh pr diff' to see all the changes in the PR. -- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. +- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. Explicitly pass the PR's target/base branch to the `ha-review` skill (obtained via `gh pr view`) so it diffs against the correct base. - Run a subagent in parallel to check the PR review comments following the `ha-pr-comment-audit` skill. ## IMPORTANT: diff --git a/.claude/skills/ha-review/SKILL.md b/.claude/skills/ha-review/SKILL.md index f78cbe0dfd5f5..12e7cb4318df2 100644 --- a/.claude/skills/ha-review/SKILL.md +++ b/.claude/skills/ha-review/SKILL.md @@ -5,6 +5,9 @@ description: Reviews Home Assistant code changes and provides constructive feedb # Review Code Changes +## Scope: +- Unless instructed otherwise, review the full branch changes against the target branch. Resolve the base to an available ref (prefer `upstream/`, then `origin/`, then local ``) and review `git diff "$(git merge-base "$BASE_REF" HEAD)"..HEAD`; use `dev` as the default base. + ## Analyze the code changes for: - Code quality and style consistency - Potential bugs or issues From c2d7c9ecd1804c19408723de25290de9b0bce92c Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Sat, 18 Jul 2026 03:38:07 +0800 Subject: [PATCH 10/10] Bump python-izone to 1.3.5 (#176687) Co-authored-by: Cursor --- homeassistant/components/izone/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/izone/manifest.json b/homeassistant/components/izone/manifest.json index da55de678ce93..caf5b87465c44 100644 --- a/homeassistant/components/izone/manifest.json +++ b/homeassistant/components/izone/manifest.json @@ -10,5 +10,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pizone"], - "requirements": ["python-izone==1.3.4"] + "requirements": ["python-izone==1.3.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index ceb7f8943a281..46f5c39ced23d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2692,7 +2692,7 @@ python-homewizard-energy==10.1.0 python-hpilo==4.4.3 # homeassistant.components.izone -python-izone==1.3.4 +python-izone==1.3.5 # homeassistant.components.joaoapps_join python-join-api==0.1.1