Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c4db422
Bump bimmer_connected to 0.17.3 (#151756)
rikroe Sep 5, 2025
0a3032e
Fix recognition of entity names in default agent with interpunction (…
arturpragacz Sep 5, 2025
aa4a110
Improve action descriptions in `homematic` (#151751)
NoRi2909 Sep 5, 2025
2ffd5f4
Update types packages (#151760)
cdce8p Sep 5, 2025
d232408
Remove unused class variables in Android TV Remote entity (#151743)
tronikos Sep 5, 2025
783c742
Add support for migrated Hue bridge (#151411)
marcelveldt Sep 5, 2025
0721ac6
Fix, entities stay unavailable after timeout error, Imeon inverter in…
Imeon-Energy Sep 5, 2025
caa0e35
Improve Android TV Remote tests by testing we can recover from errors…
tronikos Sep 5, 2025
71b8da6
Limit the scope of try except blocks in Android TV Remote (#151746)
tronikos Sep 5, 2025
34c45ea
Translate exceptions in Android TV Remote media player (#151744)
tronikos Sep 5, 2025
e1afadb
Fix enable/disable entity in modbus (#151626)
janiversen Sep 5, 2025
fa90077
Add myself as codeowner to Voice components (#151764)
arturpragacz Sep 5, 2025
c621f0c
Matter RVC ServiceArea EstimatedEndTime attribute (#151384)
lboue Sep 5, 2025
435926f
Update SharkIQ authentication method (#151046)
funkybunch Sep 5, 2025
63c8bfa
Fix support for Ecowitt soil moisture sensors (#151685)
blotus Sep 5, 2025
0fecf01
SFTP/SSH as remote Backup location (#135844)
maretodoric Sep 5, 2025
a4e086f
Add manual mode to the map of Overkiz to HVAC modes (#151438)
cmoran Sep 5, 2025
6c29d5d
Add entity info to device database analytics (#151670)
arturpragacz Sep 5, 2025
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 .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@ homeassistant.components.sensorpush_cloud.*
homeassistant.components.sensoterra.*
homeassistant.components.senz.*
homeassistant.components.sfr_box.*
homeassistant.components.sftp_storage.*
homeassistant.components.shell_command.*
homeassistant.components.shelly.*
homeassistant.components.shopping_list.*
Expand Down
18 changes: 10 additions & 8 deletions CODEOWNERS

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

124 changes: 90 additions & 34 deletions homeassistant/components/analytics/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@
get_instance as get_recorder_instance,
)
from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_DOMAIN,
BASE_PLATFORMS,
__version__ as HA_VERSION,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
Expand Down Expand Up @@ -389,66 +394,117 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:


async def async_devices_payload(hass: HomeAssistant) -> dict:
"""Return the devices payload."""
devices: list[dict[str, Any]] = []
"""Return detailed information about entities and devices."""
integrations_info: dict[str, dict[str, Any]] = {}

dev_reg = dr.async_get(hass)
# Devices that need via device info set
new_indexes: dict[str, int] = {}
via_devices: dict[str, str] = {}

seen_integrations = set()
# We need to refer to other devices, for example in `via_device` field.
# We don't however send the original device ids outside of Home Assistant,
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
device_id_mapping: dict[str, tuple[str, int]] = {}

for device in dev_reg.devices.values():
if not device.primary_config_entry:
for device_entry in dev_reg.devices.values():
if not device_entry.primary_config_entry:
continue

config_entry = hass.config_entries.async_get_entry(device.primary_config_entry)
config_entry = hass.config_entries.async_get_entry(
device_entry.primary_config_entry
)

if config_entry is None:
continue

seen_integrations.add(config_entry.domain)
integration_domain = config_entry.domain
integration_info = integrations_info.setdefault(
integration_domain, {"devices": [], "entities": []}
)

devices_info = integration_info["devices"]

device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))

new_indexes[device.id] = len(devices)
devices.append(
devices_info.append(
{
"integration": config_entry.domain,
"manufacturer": device.manufacturer,
"model_id": device.model_id,
"model": device.model,
"sw_version": device.sw_version,
"hw_version": device.hw_version,
"has_configuration_url": device.configuration_url is not None,
"via_device": None,
"entry_type": device.entry_type.value if device.entry_type else None,
"entities": [],
"entry_type": device_entry.entry_type,
"has_configuration_url": device_entry.configuration_url is not None,
"hw_version": device_entry.hw_version,
"manufacturer": device_entry.manufacturer,
"model": device_entry.model,
"model_id": device_entry.model_id,
"sw_version": device_entry.sw_version,
"via_device": device_entry.via_device_id,
}
)

if device.via_device_id:
via_devices[device.id] = device.via_device_id
# Fill out via_device with new device ids
for integration_info in integrations_info.values():
for device_info in integration_info["devices"]:
if device_info["via_device"] is None:
continue
device_info["via_device"] = device_id_mapping.get(device_info["via_device"])

for from_device, via_device in via_devices.items():
if via_device not in new_indexes:
continue
devices[new_indexes[from_device]]["via_device"] = new_indexes[via_device]
ent_reg = er.async_get(hass)

for entity_entry in ent_reg.entities.values():
integration_domain = entity_entry.platform
integration_info = integrations_info.setdefault(
integration_domain, {"devices": [], "entities": []}
)

devices_info = integration_info["devices"]
entities_info = integration_info["entities"]

entity_state = hass.states.get(entity_entry.entity_id)

entity_info = {
# LIMITATION: `assumed_state` can be overridden by users;
# we should replace it with the original value in the future.
# It is also not present, if entity is not in the state machine,
# which can happen for disabled entities.
"assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
if entity_state is not None
else None,
"capabilities": entity_entry.capabilities,
"domain": entity_entry.domain,
"entity_category": entity_entry.entity_category,
"has_entity_name": entity_entry.has_entity_name,
"original_device_class": entity_entry.original_device_class,
# LIMITATION: `unit_of_measurement` can be overridden by users;
# we should replace it with the original value in the future.
"unit_of_measurement": entity_entry.unit_of_measurement,
}

if (
((device_id := entity_entry.device_id) is not None)
and ((new_device_id := device_id_mapping.get(device_id)) is not None)
and (new_device_id[0] == integration_domain)
):
device_info = devices_info[new_device_id[1]]
device_info["entities"].append(entity_info)
else:
entities_info.append(entity_info)

integrations = {
domain: integration
for domain, integration in (
await async_get_integrations(hass, seen_integrations)
await async_get_integrations(hass, integrations_info.keys())
).items()
if isinstance(integration, Integration)
}

for device_info in devices:
if integration := integrations.get(device_info["integration"]):
device_info["is_custom_integration"] = not integration.is_built_in
for domain, integration_info in integrations_info.items():
if integration := integrations.get(domain):
integration_info["is_custom_integration"] = not integration.is_built_in
# Include version for custom integrations
if not integration.is_built_in and integration.version:
device_info["custom_integration_version"] = str(integration.version)
integration_info["custom_integration_version"] = str(
integration.version
)

return {
"version": "home-assistant:1",
"home_assistant": HA_VERSION,
"devices": devices,
"integrations": integrations_info,
}
45 changes: 25 additions & 20 deletions homeassistant/components/androidtv_remote/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,14 @@ async def async_step_user(
if user_input is not None:
self.host = user_input[CONF_HOST]
api = create_api(self.hass, self.host, enable_ime=False)
await api.async_generate_cert_if_missing()
try:
await api.async_generate_cert_if_missing()
self.name, self.mac = await api.async_get_name_and_mac()
except CannotConnect:
# Likely invalid IP address or device is network unreachable. Stay
# in the user step allowing the user to enter a different host.
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(format_mac(self.mac))
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
Expand All @@ -81,11 +86,10 @@ async def async_step_user(
},
)
self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
return await self._async_start_pair()
except (CannotConnect, ConnectionClosed):
# Likely invalid IP address or device is network unreachable. Stay
# in the user step allowing the user to enter a different host.
errors["base"] = "cannot_connect"
try:
return await self._async_start_pair()
except (CannotConnect, ConnectionClosed):
errors["base"] = "cannot_connect"
else:
user_input = {}
default_host = user_input.get(CONF_HOST, vol.UNDEFINED)
Expand All @@ -112,22 +116,9 @@ async def async_step_pair(
"""Handle the pair step."""
errors: dict[str, str] = {}
if user_input is not None:
pin = user_input["pin"]
try:
pin = user_input["pin"]
await self.api.async_finish_pairing(pin)
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), reload_even_if_entry_is_unchanged=True
)

return self.async_create_entry(
title=self.name,
data={
CONF_HOST: self.host,
CONF_NAME: self.name,
CONF_MAC: self.mac,
},
)
except InvalidAuth:
# Invalid PIN. Stay in the pair step allowing the user to enter
# a different PIN.
Expand All @@ -145,6 +136,20 @@ async def async_step_pair(
# them to enter a new IP address but we cannot do that for the zeroconf
# flow. Simpler to abort for both flows.
return self.async_abort(reason="cannot_connect")
else:
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), reload_even_if_entry_is_unchanged=True
)

return self.async_create_entry(
title=self.name,
data={
CONF_HOST: self.host,
CONF_NAME: self.name,
CONF_MAC: self.mac,
},
)
return self.async_show_form(
step_id="pair",
data_schema=STEP_PAIR_DATA_SCHEMA,
Expand Down
6 changes: 2 additions & 4 deletions homeassistant/components/androidtv_remote/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from androidtvremote2 import AndroidTVRemote, ConnectionClosed

from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.const import CONF_MAC, CONF_NAME
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
Expand All @@ -28,8 +28,6 @@ def __init__(
) -> None:
"""Initialize the entity."""
self._api = api
self._host = config_entry.data[CONF_HOST]
self._name = config_entry.data[CONF_NAME]
self._apps: dict[str, Any] = config_entry.options.get(CONF_APPS, {})
self._attr_unique_id = config_entry.unique_id
self._attr_is_on = api.is_on
Expand All @@ -39,7 +37,7 @@ def __init__(
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])},
identifiers={(DOMAIN, config_entry.unique_id)},
name=self._name,
name=config_entry.data[CONF_NAME],
manufacturer=device_info["manufacturer"],
model=device_info["model"],
)
Expand Down
12 changes: 10 additions & 2 deletions homeassistant/components/androidtv_remote/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,11 @@ async def async_play_media(
"""Play a piece of media."""
if media_type == MediaType.CHANNEL:
if not media_id.isnumeric():
raise ValueError(f"Channel must be numeric: {media_id}")
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_channel",
translation_placeholders={"media_id": media_id},
)
if self._channel_set_task:
self._channel_set_task.cancel()
self._channel_set_task = asyncio.create_task(
Expand All @@ -188,7 +192,11 @@ async def async_play_media(
self._send_launch_app_command(media_id)
return

raise ValueError(f"Invalid media type: {media_type}")
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_media_type",
translation_placeholders={"media_type": media_type},
)

async def async_browse_media(
self,
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/components/androidtv_remote/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@
"exceptions": {
"connection_closed": {
"message": "Connection to the Android TV device is closed"
},
"invalid_channel": {
"message": "Channel must be numeric: {media_id}"
},
"invalid_media_type": {
"message": "Invalid media type: {media_type}"
}
}
}
2 changes: 1 addition & 1 deletion homeassistant/components/assist_pipeline/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"domain": "assist_pipeline",
"name": "Assist pipeline",
"after_dependencies": ["repairs"],
"codeowners": ["@balloob", "@synesthesiam"],
"codeowners": ["@balloob", "@synesthesiam", "@arturpragacz"],
"dependencies": ["conversation", "stt", "tts", "wake_word"],
"documentation": "https://www.home-assistant.io/integrations/assist_pipeline",
"integration_type": "system",
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/assist_satellite/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"domain": "assist_satellite",
"name": "Assist Satellite",
"codeowners": ["@home-assistant/core", "@synesthesiam"],
"codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"],
"dependencies": ["assist_pipeline", "http", "stt", "tts"],
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/bmw_connected_drive/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
"requirements": ["bimmer-connected[china]==0.17.2"]
"requirements": ["bimmer-connected[china]==0.17.3"]
}
Loading
Loading