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 homeassistant/components/blebox/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.3"],
"requirements": ["blebox-uniapi==2.5.4"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
2 changes: 1 addition & 1 deletion homeassistant/components/bluetooth/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"requirements": [
"bleak==3.0.2",
"bleak-retry-connector==4.6.1",
"bluetooth-adapters==2.1.0",
"bluetooth-adapters==2.2.0",
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.9",
Expand Down
23 changes: 9 additions & 14 deletions homeassistant/components/cloud/google_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,13 @@ async def on_hass_start(hass: HomeAssistant) -> None:
)
)

def should_expose(self, state: State) -> bool:
"""If a state object should be exposed."""
return self._should_expose_entity_id(state.entity_id)
def should_expose(self, entity_id: str) -> bool:
"""If an entity should be exposed."""
entity_filter: EntityFilter = self._config[CONF_FILTER]
if not entity_filter.empty_filter:
return entity_filter(entity_id)

return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)

def _should_expose_legacy(self, entity_id: str) -> bool:
"""If an entity ID should be exposed."""
Expand Down Expand Up @@ -308,14 +312,6 @@ def _should_expose_legacy(self, entity_id: str) -> bool:
and _supported_legacy(self.hass, entity_id)
)

def _should_expose_entity_id(self, entity_id: str) -> bool:
"""If an entity should be exposed."""
entity_filter: EntityFilter = self._config[CONF_FILTER]
if not entity_filter.empty_filter:
return entity_filter(entity_id)

return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)

@property
def agent_user_id(self) -> str:
"""Return Agent User Id to use for query responses."""
Expand Down Expand Up @@ -467,7 +463,7 @@ def _handle_entity_registry_updated(

entity_id = event.data["entity_id"]

if not self._should_expose_entity_id(entity_id):
if not self.should_expose(entity_id):
return

self.async_schedule_google_sync_all()
Expand All @@ -490,8 +486,7 @@ def _handle_device_registry_updated(

# Check if any exposed entity uses the device area
if not any(
entity_entry.area_id is None
and self._should_expose_entity_id(entity_entry.entity_id)
entity_entry.area_id is None and self.should_expose(entity_entry.entity_id)
for entity_entry in er.async_entries_for_device(
er.async_get(self.hass), event.data["device_id"]
)
Expand Down
34 changes: 31 additions & 3 deletions homeassistant/components/device_tracker/config_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from propcache.api import cached_property

from homeassistant.components import zone
from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
Expand Down Expand Up @@ -207,6 +208,7 @@ class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):


CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
"in_zones",
"latitude",
"location_accuracy",
"location_name",
Expand All @@ -220,6 +222,7 @@ class TrackerEntity(
"""Base class for a tracked device."""

entity_description: TrackerEntityDescription
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
_attr_location_name: str | None = None
Expand All @@ -239,6 +242,16 @@ def force_update(self) -> bool:
"""All updates need to be written to the state machine if we're not polling."""
return not self.should_poll

@cached_property
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.
"""
return self._attr_in_zones

@cached_property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device.
Expand Down Expand Up @@ -269,6 +282,20 @@ def _async_write_ha_state(self) -> 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:
zone_states = sorted(
(
zone_state
for entity_id in zones
if (zone_state := self.hass.states.get(entity_id)) is not None
),
key=lambda z: z.attributes[ATTR_RADIUS],
)
self.__active_zone = next(
(z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)),
None,
)
self.__in_zones = [z.entity_id for z in zone_states]
else:
self.__active_zone = None
self.__in_zones = None
Expand All @@ -280,7 +307,9 @@ def state(self) -> str | None:
if self.location_name is not None:
return self.location_name

if self.latitude is not None and self.longitude is not None:
if (
self.latitude is not None and self.longitude is not None
) or self.__in_zones is not None:
zone_state = self.__active_zone
if zone_state is None:
state = STATE_NOT_HOME
Expand All @@ -296,11 +325,10 @@ def state(self) -> str | None:
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []}
attr.update(super().state_attributes)

if self.latitude is not None and self.longitude is not None:
attr[ATTR_IN_ZONES] = self.__in_zones or []
attr[ATTR_LATITUDE] = self.latitude
attr[ATTR_LONGITUDE] = self.longitude
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
Expand Down
8 changes: 4 additions & 4 deletions homeassistant/components/google_assistant/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ def get_agent_user_id_from_webhook(self, webhook_id):
"""

@abstractmethod
def should_expose(self, state) -> bool:
def should_expose(self, entity_id: str) -> bool:
"""Return if entity should be exposed."""

@abstractmethod
Expand Down Expand Up @@ -532,7 +532,7 @@ def __init__(

def __repr__(self) -> str:
"""Return the representation."""
return f"<GoogleEntity {self.state.entity_id}: {self.state.name}>"
return f"<GoogleEntity {self.entity_id}: {self.state.name}>"

@callback
def traits(self) -> list[trait._Trait]:
Expand All @@ -549,7 +549,7 @@ def traits(self) -> list[trait._Trait]:
@callback
def should_expose(self):
"""If entity should be exposed."""
return self.config.should_expose(self.state)
return self.config.should_expose(self.entity_id)

@callback
def should_expose_local(self) -> bool:
Expand Down Expand Up @@ -733,7 +733,7 @@ async def execute(self, data, command_payload):
if not executed:
raise SmartHomeError(
ERR_FUNCTION_NOT_SUPPORTED,
f"Unable to execute {command} for {self.state.entity_id}",
f"Unable to execute {command} for {self.entity_id}",
)

@callback
Expand Down
14 changes: 5 additions & 9 deletions homeassistant/components/google_assistant/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from homeassistant.components import webhook
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
Expand Down Expand Up @@ -157,17 +157,13 @@ def get_agent_user_id_from_webhook(self, webhook_id):

return None

def should_expose(self, state) -> bool:
def should_expose(self, entity_id: str) -> bool:
"""Return if entity should be exposed."""
expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT)
exposed_domains = self._config.get(CONF_EXPOSED_DOMAINS)

if state.attributes.get("view") is not None:
# Ignore entities that are views
return False

entity_registry = er.async_get(self.hass)
registry_entry = entity_registry.async_get(state.entity_id)
registry_entry = entity_registry.async_get(entity_id)
if registry_entry:
auxiliary_entity = (
registry_entry.entity_category is not None
Expand All @@ -176,10 +172,10 @@ def should_expose(self, state) -> bool:
else:
auxiliary_entity = False

explicit_expose = self.entity_config.get(state.entity_id, {}).get(CONF_EXPOSE)
explicit_expose = self.entity_config.get(entity_id, {}).get(CONF_EXPOSE)

domain_exposed_by_default = (
expose_by_default and state.domain in exposed_domains
expose_by_default and split_entity_id(entity_id)[0] in exposed_domains
)

# Expose an entity by default if the entity's domain is exposed by default
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/google_assistant/report_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def _async_entity_state_filter(data: EventStateChangedData) -> bool:
return bool(
hass.is_running
and (new_state := data["new_state"])
and google_config.should_expose(new_state)
and google_config.should_expose(new_state.entity_id)
and async_get_google_entity_if_supported_cached(
hass, google_config, new_state
)
Expand Down
103 changes: 98 additions & 5 deletions homeassistant/components/growatt_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"""

from collections.abc import Mapping
import datetime
from json import JSONDecodeError
import logging

Expand All @@ -34,7 +35,9 @@
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType

from .const import (
Expand All @@ -46,10 +49,13 @@
DEFAULT_PLANT_ID,
DEFAULT_URL,
DEPRECATED_URLS,
DEVICE_SCAN_INTERVAL,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
PLATFORMS,
SUPPORTED_DEVICE_TYPES,
V1_API_ERROR_NO_PRIVILEGE,
V1_DEVICE_TYPES,
)
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .models import GrowattRuntimeData
Expand Down Expand Up @@ -241,9 +247,6 @@ def _login_classic_api(
return login_response


V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"}


def get_device_list_v1(
api, config: Mapping[str, str]
) -> tuple[list[dict[str, str]], str]:
Expand Down Expand Up @@ -353,7 +356,7 @@ async def async_setup_entry(
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
)
for device in devices
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min", "sph"]
if device["deviceType"] in SUPPORTED_DEVICE_TYPES
}

# Perform the first refresh for the total coordinator
Expand All @@ -372,6 +375,96 @@ async def async_setup_entry(
# Set up all the entities
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

async def _async_scan_for_new_devices(_now: datetime.datetime) -> None:
"""Scan for new or removed devices and update HA accordingly."""
# Fetch current config (in case it was updated via reauth or options)
current_plant_id = config_entry.data[CONF_PLANT_ID]

total_coordinator = config_entry.runtime_data.total_coordinator
# Signal the coordinator to also fetch the device list on its next
# _sync_update_data run, then force an immediate refresh. This keeps
# the device_list call in the same executor thread as the existing
# login() + plant-overview call, so for Classic API there is no extra
# login and no thread-safety concern with the shared session.
total_coordinator.request_device_list_scan()
await total_coordinator.async_refresh()

if not total_coordinator.last_update_success:
_LOGGER.debug("Coordinator refresh failed during device scan, skipping")
return

current_devices = total_coordinator.device_list
if current_devices is None:
_LOGGER.debug(
"Device list not populated after coordinator refresh, skipping scan"
)
return

runtime_data = config_entry.runtime_data
current_device_sns = {device["deviceSn"] for device in current_devices}

# Remove stale devices
device_registry = dr.async_get(hass)
for device_entry in dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
):
device_domain_ids = {
identifier[1]
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
}
if not device_domain_ids:
continue
# Skip the plant "total" device
if current_plant_id in device_domain_ids:
continue
if device_domain_ids.isdisjoint(current_device_sns):
for device_sn in device_domain_ids:
if coordinator := runtime_data.devices.pop(device_sn, None):
await coordinator.async_shutdown()
device_registry.async_update_device(
device_entry.id,
remove_config_entry_id=config_entry.entry_id,
)

# Add new devices
new_coordinators: list[GrowattCoordinator] = []
for device in current_devices:
device_sn = device["deviceSn"]
device_type = device["deviceType"]
if device_sn in runtime_data.devices:
continue
if device_type not in SUPPORTED_DEVICE_TYPES:
_LOGGER.debug(
"New device %s with type %s is not supported, skipping",
device_sn,
device_type,
)
continue
coordinator = GrowattCoordinator(
hass, config_entry, device_sn, device_type, current_plant_id
)
await coordinator.async_refresh()
if not coordinator.last_update_success:
_LOGGER.debug("Failed to refresh new device %s, skipping", device_sn)
await coordinator.async_shutdown()
continue
runtime_data.devices[device_sn] = coordinator
new_coordinators.append(coordinator)

if new_coordinators:
async_dispatcher_send(
hass,
f"{DOMAIN}_new_device_{config_entry.entry_id}",
new_coordinators,
)

config_entry.async_on_unload(
async_track_time_interval(
hass, _async_scan_for_new_devices, DEVICE_SCAN_INTERVAL
)
)

return True


Expand Down
Loading
Loading