Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7feaf71
Make TrackerEntity in_zones win over lat/long (#172313)
emontnemery May 27, 2026
98a7cc6
Reolink battery fast start (#171840)
starkillerOG May 27, 2026
efe0000
Bump pyvesync to 3.4.2 (#168402)
cdnninja May 27, 2026
a6fcc9f
Prefer external URL in WWW-Authenticate header for RFC 9728 (#169658)
allenporter May 27, 2026
3523a26
Add template device_tracker platform (#171732)
Petro31 May 27, 2026
31bce13
Bump actions/stale from 10.2.0 to 10.3.0 (#172319)
dependabot[bot] May 27, 2026
b9639ec
Update uv to 0.11.16 (#172344)
renovate[bot] May 27, 2026
3f6e323
Update ruff (#172343)
renovate[bot] May 27, 2026
c5e4e97
Ignore quirks in Tuya snapshot tests (#172329)
epenet May 27, 2026
00010a7
Bump tuya-device-handlers to 0.0.21 (#172315)
epenet May 27, 2026
3a4e697
Add entity option to associate scanner tracker with any zone (#172157)
emontnemery May 27, 2026
a4b9de8
Add instruction about hardcoded entity ids in tests (#172341)
abmantis May 27, 2026
eb72a72
Rename automation comments to note (#172312)
wendevlin May 27, 2026
b2d934f
Fix dead code and redundant assignment in isy994 integration (#171904)
SoundMatt May 27, 2026
f8a65a7
Rename trigger behavior options (#172348)
emontnemery May 27, 2026
75c52a3
Add missing template entity device_tracker translation (#172346)
Petro31 May 27, 2026
9744388
Fix duplicate hvac_modes in Tuya climate (#172352)
epenet May 27, 2026
3372bf4
Allow counter entities as source in trend (#171132)
jpbede May 27, 2026
73898c2
Fix weather lux unit in Qbus integration (#172326)
thomasddn May 27, 2026
ebd9934
Add repair to migrate away from multiprotocol/Multi-PAN (#168431)
agners May 27, 2026
5a73d78
refactor(ads): refactor local CONF_OPTIONS constant in select.py (#17…
robotsnh May 27, 2026
4bf3a5b
Adjust behavior of numerical condition and trigger between and outsid…
emontnemery May 27, 2026
403cb85
Bump frontend to 20260527.0 (#172355)
bramkragten May 27, 2026
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
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.

## Good practices

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/stale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
# - No PRs marked as no-stale
# - No issues (-1)
- name: 60 days stale PRs policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
Expand Down Expand Up @@ -67,7 +67,7 @@ jobs:
# - No issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: 90 days stale issues
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90
Expand Down Expand Up @@ -97,7 +97,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: Needs more information stale issues policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information"
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.13
rev: v0.15.14
hooks:
- id: ruff-check
args:
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.

## Good practices

Expand Down
5 changes: 1 addition & 4 deletions homeassistant/components/ads/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA,
SelectEntity,
)
from homeassistant.const import CONF_NAME
from homeassistant.const import CONF_NAME, CONF_OPTIONS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
Expand All @@ -19,9 +19,6 @@

DEFAULT_NAME = "ADS select"

# pylint: disable-next=home-assistant-duplicate-const
CONF_OPTIONS = "options"

PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_ADS_VAR): cv.string,
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/air_quality/triggers.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/alarm_control_panel/triggers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
fields: &trigger_common_fields
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/assist_satellite/triggers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/battery/triggers.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/climate/trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase,
Expand All @@ -26,7 +26,7 @@

CONF_HVAC_MODE = "hvac_mode"

HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_HVAC_MODE): vol.All(
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/climate/triggers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/counter/triggers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/cover/triggers.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/device_tracker/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
ATTR_LOCATION_NAME,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONF_CONSIDER_HOME,
CONF_NEW_DEVICE_DEFAULTS,
CONF_SCAN_INTERVAL,
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/device_tracker/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class SourceType(StrEnum):

CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults"

CONF_ASSOCIATED_ZONE: Final = "associated_zone"

ATTR_ATTRIBUTES: Final = "attributes"
ATTR_BATTERY: Final = "battery"
ATTR_DEV_ID: Final = "dev_id"
Expand Down
158 changes: 144 additions & 14 deletions homeassistant/components/device_tracker/entity.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Provide functionality to keep track of devices."""

import asyncio
from typing import Any, final
from typing import TYPE_CHECKING, Any, final

from propcache.api import cached_property

Expand All @@ -16,15 +16,27 @@
STATE_NOT_HOME,
EntityCategory,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.device_registry import (
DeviceInfo,
EventDeviceRegistryUpdatedData,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.util.hass_dict import HassKey

from .const import (
Expand All @@ -33,6 +45,7 @@
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
Expand Down Expand Up @@ -221,8 +234,8 @@ def in_zones(self) -> list[str] | None:
"""Return the entity_id of zones the device is currently in.

The list may be in any order; the base class sorts it by zone radius
and discards zones which do not exist. Ignored if latitude and
longitude are both set.
and discards zones which do not exist. Takes precedence over latitude
and longitude when set (including when set to an empty list).
"""
return self._attr_in_zones

Expand Down Expand Up @@ -252,11 +265,7 @@ def longitude(self) -> float | None:
@callback
def _async_write_ha_state(self) -> None:
"""Calculate active zones."""
if self.available and self.latitude is not None and self.longitude is not None:
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
elif (zones := self.in_zones) is not None:
if (zones := self.in_zones) is not None:
zone_states = sorted(
(
zone_state
Expand All @@ -270,6 +279,12 @@ def _async_write_ha_state(self) -> None:
None,
)
self.__in_zones = [z.entity_id for z in zone_states]
elif (
self.available and self.latitude is not None and self.longitude is not None
):
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
else:
self.__active_zone = None
self.__in_zones = None
Expand Down Expand Up @@ -317,14 +332,120 @@ class BaseScannerEntity(BaseTrackerEntity):
addresses being used to identify the device.
"""

_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None

async def async_internal_added_to_hass(self) -> None:
"""Call when the scanner entity is added to hass."""
await super().async_internal_added_to_hass()
if not self.registry_entry:
return
self._async_read_entity_options()

async def async_internal_will_remove_from_hass(self) -> None:
"""Call when the scanner entity is about to be removed from hass."""
await super().async_internal_will_remove_from_hass()
if not self.registry_entry:
return
if self._scanner_option_associated_zone_unsub is not None:
self._scanner_option_associated_zone_unsub()
self._scanner_option_associated_zone_unsub = None
self._async_clear_associated_zone_issue()

@callback
def async_registry_entry_updated(self) -> None:
"""Run when the entity registry entry has been updated."""
self._async_read_entity_options()

@callback
def _async_read_entity_options(self) -> None:
"""Read entity options from the entity registry.

Called when the entity registry entry has been updated and before the
scanner entity is added to the state machine.
"""
assert self.registry_entry
if (scanner_options := self.registry_entry.options.get(DOMAIN)) and (
associated_zone := scanner_options.get(CONF_ASSOCIATED_ZONE)
):
new_zone = associated_zone
else:
new_zone = zone.ENTITY_ID_HOME

if new_zone == self._scanner_option_associated_zone:
return

# Tear down tracking for the previous zone.
if self._scanner_option_associated_zone_unsub is not None:
self._scanner_option_associated_zone_unsub()
self._scanner_option_associated_zone_unsub = None
self._async_clear_associated_zone_issue()

self._scanner_option_associated_zone = new_zone

# zone.home is always present so no tracking or issue handling needed.
if new_zone == zone.ENTITY_ID_HOME:
return

self._scanner_option_associated_zone_unsub = async_track_state_change_event(
self.hass, new_zone, self._async_associated_zone_state_changed
)
if self.hass.states.get(new_zone) is None:
self._async_create_associated_zone_issue()

@callback
def _async_associated_zone_state_changed(
self, event: Event[EventStateChangedData]
) -> None:
"""Open or clear the repair issue when the associated zone appears or disappears."""
if event.data["new_state"] is None:
self._async_create_associated_zone_issue()
else:
self._async_clear_associated_zone_issue()
self.async_write_ha_state()

@callback
def _async_create_associated_zone_issue(self) -> None:
"""Create a repair issue prompting the user to reconfigure the scanner."""
ir.async_create_issue(
self.hass,
DOMAIN,
self._associated_zone_issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="associated_zone_missing",
translation_placeholders={
"entity_id": self.entity_id,
"zone": self._scanner_option_associated_zone,
},
)

@callback
def _async_clear_associated_zone_issue(self) -> None:
"""Clear the associated-zone-missing repair issue if it exists."""
ir.async_delete_issue(self.hass, DOMAIN, self._associated_zone_issue_id)

@property
def _associated_zone_issue_id(self) -> str:
"""Return the issue id for the associated-zone-missing repair."""
if TYPE_CHECKING:
assert self.registry_entry
return f"associated_zone_missing_{self.registry_entry.id}"

@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.is_connected is None:
return None
if self.is_connected:
if not self.is_connected:
return STATE_NOT_HOME
associated_zone = self._scanner_option_associated_zone
if associated_zone == zone.ENTITY_ID_HOME:
return STATE_HOME
return STATE_NOT_HOME
if zone_state := self.hass.states.get(associated_zone):
return zone_state.name
# Configured zone has been removed; state is unknown.
return None

@property
def is_connected(self) -> bool | None:
Expand All @@ -341,9 +462,18 @@ def state_attributes(self) -> dict[str, Any]:
if not self.is_connected:
return attr

associated_zone = self._scanner_option_associated_zone
# If the configured zone has been removed, in_zones stays empty so the
# attribute does not claim membership in a zone that no longer exists.
if (
associated_zone != zone.ENTITY_ID_HOME
and self.hass.states.get(associated_zone) is None
):
return attr

attr[ATTR_IN_ZONES] = [
zone.ENTITY_ID_HOME,
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
associated_zone,
*zone.async_get_enclosing_zones(self.hass, associated_zone),
]

return attr
Expand Down
Loading
Loading