Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/ha-pr-reviewer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions .claude/skills/ha-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<base>`, then `origin/<base>`, then local `<base>`) 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
Expand Down
56 changes: 35 additions & 21 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/stale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 4 additions & 2 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion homeassistant/components/derivative/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
28 changes: 20 additions & 8 deletions homeassistant/components/device_tracker/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/frontend/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
1 change: 0 additions & 1 deletion homeassistant/components/generic_hygrostat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 0 additions & 1 deletion homeassistant/components/generic_thermostat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions homeassistant/components/harbor/__init__.py
Original file line number Diff line number Diff line change
@@ -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
128 changes: 128 additions & 0 deletions homeassistant/components/harbor/config_flow.py
Original file line number Diff line number Diff line change
@@ -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,
)
13 changes: 13 additions & 0 deletions homeassistant/components/harbor/const.py
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading