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
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ homeassistant.components.smhi.*
homeassistant.components.smlight.*
homeassistant.components.smtp.*
homeassistant.components.snooz.*
homeassistant.components.solaredge_modbus.*
homeassistant.components.solarlog.*
homeassistant.components.sonarr.*
homeassistant.components.spaceapi.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

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

2 changes: 1 addition & 1 deletion homeassistant/brands/solaredge.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"domain": "solaredge",
"name": "SolarEdge",
"integrations": ["solaredge", "solaredge_local"]
"integrations": ["solaredge", "solaredge_local", "solaredge_modbus"]
}
6 changes: 4 additions & 2 deletions homeassistant/components/bluesound/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,8 +538,9 @@ def rebuild_bluesound_group(self) -> list[str]:
if self.sync_status.leader is None and self.sync_status.followers is None:
return []

# An entry that is not loaded has no runtime data to read a status from
config_entries: list[BluesoundConfigEntry] = (
self.hass.config_entries.async_entries(DOMAIN)
self.hass.config_entries.async_loaded_entries(DOMAIN)
)
sync_status_list = [
x.runtime_data.coordinator.data.sync_status for x in config_entries
Expand Down Expand Up @@ -609,8 +610,9 @@ def _entity_ids_with_sync_status(self) -> dict[str, SyncStatus]:

entity_registry = er.async_get(self.hass)

# An entry that is not loaded has no runtime data to read a status from
config_entries: list[BluesoundConfigEntry] = (
self.hass.config_entries.async_entries(DOMAIN)
self.hass.config_entries.async_loaded_entries(DOMAIN)
)
for config_entry in config_entries:
entity_entries = er.async_entries_for_config_entry(
Expand Down
11 changes: 8 additions & 3 deletions homeassistant/components/hydrawise/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,16 @@ def _update_attrs(self) -> None:
@override
def _handle_coordinator_update(self) -> None:
"""Get the latest data and updates the state."""
# Guard against updates arriving after the controller has been removed
# Guard against updates arriving after what the entity reads on has gone
# but before the entity has been unsubscribed from the coordinator.
if self.controller.id not in self.coordinator.data.controllers:
data = self.coordinator.data
if (
self.controller.id not in data.controllers
or (self.zone_id is not None and self.zone_id not in data.zones)
or (self.sensor_id is not None and self.sensor_id not in data.sensors)
):
return
self.controller = self.coordinator.data.controllers[self.controller.id]
self.controller = data.controllers[self.controller.id]
self._update_attrs()
super()._handle_coordinator_update()

Expand Down
5 changes: 3 additions & 2 deletions homeassistant/components/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from homeassistant.helpers import config_entry_oauth2_flow, llm

from .application_credentials import authorization_server_context
from .const import CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
from .const import CONF_AUTHORIZATION_URL, CONF_SLUG, CONF_TOKEN_URL, DOMAIN
from .coordinator import ModelContextProtocolCoordinator, TokenManager
from .types import ModelContextProtocolConfigEntry

Expand Down Expand Up @@ -72,11 +72,12 @@ async def async_setup_entry(
coordinator = ModelContextProtocolCoordinator(hass, entry, token_manager)
await coordinator.async_config_entry_first_refresh()

api_id = f"{DOMAIN}-{entry.data.get(CONF_SLUG, entry.entry_id)}"
unsub = llm.async_register_api(
hass,
ModelContextProtocolAPI(
hass=hass,
id=f"{DOMAIN}-{entry.entry_id}",
id=api_id,
name=entry.title,
coordinator=coordinator,
),
Expand Down
64 changes: 61 additions & 3 deletions homeassistant/components/mcp/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@
AbstractOAuth2FlowHandler,
async_get_implementations,
)
from homeassistant.helpers.service_info.hassio import HassioServiceInfo

from . import async_get_config_entry_implementation
from .application_credentials import authorization_server_context
from .auth import AuthenticateHeader
from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN
from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_SLUG, CONF_TOKEN_URL, DOMAIN
from .coordinator import TokenManager, mcp_client

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -153,6 +154,7 @@ def __init__(self) -> None:
self.data: dict[str, Any] = {}
self.oauth_config: OAuthConfig | None = None
self.auth_header: AuthenticateHeader | None = None
self.addon_name: str = ""

@override
async def async_step_user(
Expand Down Expand Up @@ -189,6 +191,59 @@ async def async_step_user(
description_placeholders={"example_url": EXAMPLE_URL},
)

@override
async def async_step_hassio(
self, discovery_info: HassioServiceInfo
) -> ConfigFlowResult:
"""Handle discovery of an MCP server provided by an app."""
url = discovery_info.config.get(CONF_URL)
try:
# An unparsable URL, such as an unmatched IPv6 bracket, raises ValueError
url = cv.url(url)
except vol.Invalid, ValueError:
_LOGGER.debug(
"Ignoring discovery from app %s with invalid URL: %s",
discovery_info.slug,
url,
)
return self.async_abort(reason="invalid_discovery_info")

await self.async_set_unique_id(discovery_info.uuid)
self._abort_if_unique_id_configured(updates={CONF_URL: url})
self._async_abort_entries_match({CONF_URL: url})
self.data[CONF_URL] = url
self.data[CONF_SLUG] = discovery_info.slug
self.addon_name = discovery_info.name
return await self.async_step_hassio_confirm()

async def async_step_hassio_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the MCP server provided by an app."""
if user_input is None:
self._set_confirm_only()
return self.async_show_form(
step_id="hassio_confirm",
description_placeholders={"addon": self.addon_name},
)

try:
info = await validate_input(self.hass, self.data)
except TimeoutConnectError:
return self.async_abort(reason="timeout_connect")
except CannotConnect:
return self.async_abort(reason="cannot_connect")
except InvalidAuth as err:
self.auth_header = err.metadata
return await self.async_step_auth_discovery()
except MissingCapabilities:
return self.async_abort(reason="missing_capabilities")
except Exception:
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")

return self.async_create_entry(title=info["title"], data=self.data)

async def async_step_auth_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
Expand Down Expand Up @@ -326,12 +381,15 @@ async def token_manager() -> str:
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")

# Unique id based on the application credentials OAuth Client ID
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=config_entry_data
)
await self.async_set_unique_id(config_entry_data["auth_implementation"])
if self.unique_id is None:
# Unique id based on the application credentials OAuth Client ID. A
# discovered server keeps the Supervisor uuid instead, so that the
# entry is removed together with the app.
await self.async_set_unique_id(config_entry_data["auth_implementation"])
return self.async_create_entry(
title=info["title"],
data=config_entry_data,
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/mcp/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
CONF_AUTHORIZATION_URL = "authorization_url"
CONF_TOKEN_URL = "token_url"
CONF_SCOPE = "scope"
CONF_SLUG = "slug"
4 changes: 2 additions & 2 deletions homeassistant/components/mcp/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ rules:
status: exempt
comment: Integration does not have devices.
diagnostics: todo
discovery-update-info: todo
discovery: todo
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/mcp/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_discovery_info": "Invalid discovery information received",
"missing_capabilities": "The MCP server does not support a required capability (Tools)",
"reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
Expand All @@ -29,6 +30,10 @@
},
"title": "Choose how to authenticate with the MCP server"
},
"hassio_confirm": {
"description": "Do you want to configure Home Assistant to connect to the Model Context Protocol server provided by the app: {addon}?",
"title": "Model Context Protocol server via Home Assistant app"
},
"pick_implementation": {
"data": {
"implementation": "[%key:common::config_flow::data::implementation%]"
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/modbus/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
"requirements": [
"pymodbus==3.13.1",
"modbus-connection[tmodbus]==4.10.0",
"tmodbus==0.6.1"
"tmodbus==0.6.2"
]
}
25 changes: 24 additions & 1 deletion homeassistant/components/mystrom/sensor.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Support for myStrom sensors of switches/plugs."""

from collections.abc import Callable
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import Any, override

from pymystrom.exceptions import MyStromConnectionError
from pymystrom.pir import MyStromPir
from pymystrom.switch import MyStromSwitch

Expand All @@ -29,12 +31,17 @@
from .const import DOMAIN, MANUFACTURER
from .models import MyStromConfigEntry

_LOGGER = logging.getLogger(__name__)


@dataclass(frozen=True, kw_only=True)
class MyStromSensorEntityDescription[_DeviceT](SensorEntityDescription):
"""Class describing mystrom sensor entities."""

value_fn: Callable[[_DeviceT], float | None]
# Only needed where nothing else on the device polls; a switch is kept
# fresh by its own entity refreshing the shared device.
update_fn: Callable[[_DeviceT], Coroutine[Any, Any, None]] | None = None


SENSOR_TYPES_PIR: tuple[MyStromSensorEntityDescription[MyStromPir], ...] = (
Expand All @@ -50,6 +57,7 @@ class MyStromSensorEntityDescription[_DeviceT](SensorEntityDescription):
else None
)
),
update_fn=lambda device: device.get_temperatures(),
),
MyStromSensorEntityDescription(
key="illuminance",
Expand All @@ -61,6 +69,7 @@ class MyStromSensorEntityDescription[_DeviceT](SensorEntityDescription):
float(device.intensity) if device.intensity is not None else None
)
),
update_fn=lambda device: device.get_light(),
),
)

Expand Down Expand Up @@ -180,6 +189,20 @@ def native_value(self) -> float | None:
"""Return the value of the sensor."""
return self.entity_description.value_fn(self.device)

async def async_update(self) -> None:
"""Get the latest reading from the device."""
if (update_fn := self.entity_description.update_fn) is None:
return

try:
await update_fn(self.device)
except MyStromConnectionError:
if self.available:
self._attr_available = False
_LOGGER.error("No route to myStrom device")
else:
self._attr_available = True


class MyStromSwitchUptimeSensor(MyStromSensorBase):
"""Representation of a MyStrom Switch uptime sensor."""
Expand Down
17 changes: 12 additions & 5 deletions homeassistant/components/peblar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bo
system_information = await peblar.system_information()
api = await peblar.rest_api(enable=True, access_mode=AccessMode.READ_WRITE)
except PeblarConnectionError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryNotReady("Could not connect to Peblar charger") from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(err)},
) from err
except PeblarAuthenticationError as err:
raise ConfigEntryAuthFailed from err
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from err
except PeblarError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryNotReady(
"Unknown error occurred while connecting to Peblar charger"
translation_domain=DOMAIN,
translation_key="unknown_error",
translation_placeholders={"error": str(err)},
) from err

# Setup the data coordinators
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/peblar/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",
"quality_scale": "platinum",
"requirements": ["peblar==0.6.0"],
"requirements": ["peblar==1.0.1"],
"zeroconf": [{ "name": "pblr-*", "type": "_http._tcp.local." }]
}
10 changes: 9 additions & 1 deletion homeassistant/components/peblar/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,16 @@ def __init__(
coordinator=coordinator,
description=NumberEntityDescription(key="charge_current_limit"),
)
# Not the user's own charge limit: that is the value being set here,
# so using it as the ceiling would ratchet the slider down and never
# let it back up. The charger accepts up to its hardware rating, and
# reduces anything above the installation limit configured during
# commissioning, so the lower of the two is what can actually be set.
configuration = entry.runtime_data.user_configuration_coordinator.data
self._attr_native_max_value = configuration.user_defined_charge_limit_current
self._attr_native_max_value = min(
entry.runtime_data.system_information.hardware_max_current,
configuration.current_control_fixed_charge_current_limit,
)

@override
async def async_added_to_hass(self) -> None:
Expand Down
Loading
Loading