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
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/.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
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/CODEOWNERS b/CODEOWNERS
index bfa4aad156b65..5c6830e928823 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
@@ -1717,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/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/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/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/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/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/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/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/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/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/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",
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/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/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/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 fd15fa1464120..46f5c39ced23d 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
@@ -1278,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
@@ -2689,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
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,
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
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,
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
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")