mirror of
https://github.com/home-assistant/core.git
synced 2025-09-20 10:29:26 +00:00
Compare commits
89 Commits
add-includ
...
2025.9.4
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f3b9bda876 | ||
![]() |
3f3aaa2815 | ||
![]() |
6dc7870779 | ||
![]() |
be83416c72 | ||
![]() |
c745ee18eb | ||
![]() |
cf907ae196 | ||
![]() |
8eee53036a | ||
![]() |
b37237d24b | ||
![]() |
950e758b62 | ||
![]() |
9cd940b7df | ||
![]() |
10b186a20d | ||
![]() |
757aec1c6b | ||
![]() |
0b159bdb9c | ||
![]() |
8728312e87 | ||
![]() |
bbb67db354 | ||
![]() |
265f5da21a | ||
![]() |
54859e8a83 | ||
![]() |
c87dba878d | ||
![]() |
8d8e008123 | ||
![]() |
b30667a469 | ||
![]() |
8920c548d5 | ||
![]() |
eac719f9af | ||
![]() |
3499ed7a98 | ||
![]() |
2c809d5903 | ||
![]() |
40988198f3 | ||
![]() |
ab5d1d27f1 | ||
![]() |
1c10b85fed | ||
![]() |
91a7db08ff | ||
![]() |
a764d54123 | ||
![]() |
dc09e33556 | ||
![]() |
14173bd9ec | ||
![]() |
d2e7537629 | ||
![]() |
9a165a64fe | ||
![]() |
9c749a6abc | ||
![]() |
2e33222c71 | ||
![]() |
ab1c2c4f70 | ||
![]() |
529219ae69 | ||
![]() |
d6ce71fa61 | ||
![]() |
e5b67d513a | ||
![]() |
a547179f66 | ||
![]() |
8c61788a7d | ||
![]() |
6b934d94db | ||
![]() |
d30ad82774 | ||
![]() |
4618b33e93 | ||
![]() |
d6299094db | ||
![]() |
087d9d30c0 | ||
![]() |
f07890cf5c | ||
![]() |
e5b78cc481 | ||
![]() |
12b409d8e1 | ||
![]() |
def5408db8 | ||
![]() |
f105b45ee2 | ||
![]() |
9d904c30a7 | ||
![]() |
99b047939f | ||
![]() |
3a615908ee | ||
![]() |
baff541f46 | ||
![]() |
6d8c35cfe9 | ||
![]() |
b8d9883e74 | ||
![]() |
c3c65af450 | ||
![]() |
3af8616764 | ||
![]() |
64ec4609c5 | ||
![]() |
c78bc26b83 | ||
![]() |
0c093646c9 | ||
![]() |
1b27acdde0 | ||
![]() |
9dafc0e02f | ||
![]() |
0091dafcb0 | ||
![]() |
b387acffb7 | ||
![]() |
36b3133fa2 | ||
![]() |
fe01e96012 | ||
![]() |
0b56ec16ed | ||
![]() |
ca79f4c963 | ||
![]() |
9a43f2776d | ||
![]() |
0cda883b56 | ||
![]() |
ae58e633f0 | ||
![]() |
06480bfd9d | ||
![]() |
625f586945 | ||
![]() |
7dbeaa475d | ||
![]() |
dff3d5f8af | ||
![]() |
89c335919a | ||
![]() |
2bb4573357 | ||
![]() |
7037ce989c | ||
![]() |
bfdd2053ba | ||
![]() |
fcc3f92f8c | ||
![]() |
8710267d53 | ||
![]() |
85b6adcc9a | ||
![]() |
beec6e86e0 | ||
![]() |
3dacffaaf9 | ||
![]() |
d90f2a1de1 | ||
![]() |
b6c9217429 | ||
![]() |
7fc8da6769 |
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -1690,8 +1690,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vegehub/ @ghowevege
|
||||
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
||||
/tests/components/velbus/ @Cereal2nd @brefra
|
||||
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
|
||||
/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio
|
||||
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew
|
||||
/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew
|
||||
/homeassistant/components/venstar/ @garbled1 @jhollowe
|
||||
/tests/components/venstar/ @garbled1 @jhollowe
|
||||
/homeassistant/components/versasense/ @imstevenxyz
|
||||
|
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"requirements": ["accuweather==4.2.0"],
|
||||
"requirements": ["accuweather==4.2.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -36,22 +35,7 @@ async def async_setup_entry(
|
||||
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
|
||||
)
|
||||
|
||||
sdk_doors = await client.get_doors()
|
||||
|
||||
# Convert SDK GarageDoor objects to integration GarageDoor objects
|
||||
doors = [
|
||||
GarageDoor(
|
||||
{
|
||||
"device_id": door.device_id,
|
||||
"door_number": door.door_number,
|
||||
"name": door.name,
|
||||
"status": door.status,
|
||||
"link_status": door.link_status,
|
||||
"battery_level": door.battery_level,
|
||||
}
|
||||
)
|
||||
for door in sdk_doors
|
||||
]
|
||||
doors = await client.get_doors()
|
||||
|
||||
entry.runtime_data = {
|
||||
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
|
||||
|
@@ -41,4 +41,10 @@ class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
|
||||
async def _async_update_data(self) -> GarageDoor:
|
||||
"""Fetch data from the Aladdin Connect API."""
|
||||
await self.client.update_door(self.data.device_id, self.data.door_number)
|
||||
self.data.status = self.client.get_door_status(
|
||||
self.data.device_id, self.data.door_number
|
||||
)
|
||||
self.data.battery_level = self.client.get_battery_status(
|
||||
self.data.device_id, self.data.door_number
|
||||
)
|
||||
return self.data
|
||||
|
@@ -49,7 +49,9 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Update is closed attribute."""
|
||||
return self.coordinator.data.status == "closed"
|
||||
if (status := self.coordinator.data.status) is None:
|
||||
return None
|
||||
return status == "closed"
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aladdin_connect",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["genie-partner-sdk==1.0.10"]
|
||||
"requirements": ["genie-partner-sdk==1.0.11"]
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import _LOGGER, CONF_LOGIN_DATA, COUNTRY_DOMAINS, DOMAIN
|
||||
from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -42,7 +42,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
if entry.version == 1 and entry.minor_version == 1:
|
||||
|
||||
if entry.version == 1 and entry.minor_version < 3:
|
||||
if CONF_SITE in entry.data:
|
||||
# Site in data (wrong place), just move to login data
|
||||
new_data = entry.data.copy()
|
||||
new_data[CONF_LOGIN_DATA][CONF_SITE] = new_data[CONF_SITE]
|
||||
new_data.pop(CONF_SITE)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data=new_data, version=1, minor_version=3
|
||||
)
|
||||
return True
|
||||
|
||||
if CONF_SITE in entry.data[CONF_LOGIN_DATA]:
|
||||
# Site is there, just update version to avoid future migrations
|
||||
hass.config_entries.async_update_entry(entry, version=1, minor_version=3)
|
||||
return True
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migrating from version %s.%s", entry.version, entry.minor_version
|
||||
)
|
||||
@@ -53,10 +69,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) ->
|
||||
|
||||
# Add site to login data
|
||||
new_data = entry.data.copy()
|
||||
new_data[CONF_LOGIN_DATA]["site"] = f"https://www.amazon.{domain}"
|
||||
new_data[CONF_LOGIN_DATA][CONF_SITE] = f"https://www.amazon.{domain}"
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data=new_data, version=1, minor_version=2
|
||||
entry, data=new_data, version=1, minor_version=3
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
|
@@ -52,7 +52,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Alexa Devices."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -107,7 +107,9 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
await validate_input(self.hass, {**reauth_entry.data, **user_input})
|
||||
data = await validate_input(
|
||||
self.hass, {**reauth_entry.data, **user_input}
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except (CannotAuthenticate, TypeError):
|
||||
@@ -119,8 +121,9 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
reauth_entry,
|
||||
data={
|
||||
CONF_USERNAME: entry_data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry_data[CONF_PASSWORD],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_CODE: user_input[CONF_CODE],
|
||||
CONF_LOGIN_DATA: data,
|
||||
},
|
||||
)
|
||||
|
||||
|
@@ -6,6 +6,7 @@ _LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DOMAIN = "alexa_devices"
|
||||
CONF_LOGIN_DATA = "login_data"
|
||||
CONF_SITE = "site"
|
||||
|
||||
DEFAULT_DOMAIN = "com"
|
||||
COUNTRY_DOMAINS = {
|
||||
|
@@ -120,11 +120,17 @@ class AsusWrtBridge(ABC):
|
||||
|
||||
def __init__(self, host: str) -> None:
|
||||
"""Initialize Bridge."""
|
||||
self._configuration_url = f"http://{host}"
|
||||
self._host = host
|
||||
self._firmware: str | None = None
|
||||
self._label_mac: str | None = None
|
||||
self._model: str | None = None
|
||||
|
||||
@property
|
||||
def configuration_url(self) -> str:
|
||||
"""Return configuration URL."""
|
||||
return self._configuration_url
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
"""Return hostname."""
|
||||
@@ -359,6 +365,7 @@ class AsusWrtHttpBridge(AsusWrtBridge):
|
||||
# get main router properties
|
||||
if mac := _identity.mac:
|
||||
self._label_mac = format_mac(mac)
|
||||
self._configuration_url = self._api.webpanel
|
||||
self._firmware = str(_identity.firmware)
|
||||
self._model = _identity.model
|
||||
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioasuswrt", "asusrouter", "asyncssh"],
|
||||
"requirements": ["aioasuswrt==1.4.0", "asusrouter==1.20.1"]
|
||||
"requirements": ["aioasuswrt==1.4.0", "asusrouter==1.21.0"]
|
||||
}
|
||||
|
@@ -388,11 +388,11 @@ class AsusWrtRouter:
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the device information."""
|
||||
info = DeviceInfo(
|
||||
configuration_url=self._api.configuration_url,
|
||||
identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")},
|
||||
name=self.host,
|
||||
model=self._api.model or "Asus Router",
|
||||
manufacturer="Asus",
|
||||
configuration_url=f"http://{self.host}",
|
||||
)
|
||||
if self._api.firmware:
|
||||
info["sw_version"] = self._api.firmware
|
||||
|
@@ -92,7 +92,11 @@ from homeassistant.components.http.ban import (
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.components.http.view import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.network import is_cloud_connection
|
||||
from homeassistant.helpers.network import (
|
||||
NoURLAvailableError,
|
||||
get_url,
|
||||
is_cloud_connection,
|
||||
)
|
||||
from homeassistant.util.network import is_local
|
||||
|
||||
from . import indieauth
|
||||
@@ -125,11 +129,18 @@ class WellKnownOAuthInfoView(HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Return the well known OAuth2 authorization info."""
|
||||
hass = request.app[KEY_HASS]
|
||||
# Some applications require absolute urls, so we prefer using the
|
||||
# current requests url if possible, with fallback to a relative url.
|
||||
try:
|
||||
url_prefix = get_url(hass, require_current_request=True)
|
||||
except NoURLAvailableError:
|
||||
url_prefix = ""
|
||||
return self.json(
|
||||
{
|
||||
"authorization_endpoint": "/auth/authorize",
|
||||
"token_endpoint": "/auth/token",
|
||||
"revocation_endpoint": "/auth/revoke",
|
||||
"authorization_endpoint": f"{url_prefix}/auth/authorize",
|
||||
"token_endpoint": f"{url_prefix}/auth/token",
|
||||
"revocation_endpoint": f"{url_prefix}/auth/revoke",
|
||||
"response_types_supported": ["code"],
|
||||
"service_documentation": (
|
||||
"https://developers.home-assistant.io/docs/auth_api"
|
||||
|
@@ -18,9 +18,9 @@
|
||||
"bleak==1.0.1",
|
||||
"bleak-retry-connector==4.4.3",
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.2",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.2",
|
||||
"dbus-fast==2.44.3",
|
||||
"habluetooth==5.3.0"
|
||||
"habluetooth==5.6.4"
|
||||
]
|
||||
}
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.1.0"],
|
||||
"requirements": ["hass-nabucasa==1.1.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -35,7 +35,7 @@ from hassil.recognize import (
|
||||
)
|
||||
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
|
||||
from hassil.trie import Trie
|
||||
from hassil.util import merge_dict
|
||||
from hassil.util import merge_dict, remove_punctuation
|
||||
from home_assistant_intents import (
|
||||
ErrorKey,
|
||||
FuzzyConfig,
|
||||
@@ -327,12 +327,10 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
if self._exposed_names_trie is not None:
|
||||
# Filter by input string
|
||||
text_lower = user_input.text.strip().lower()
|
||||
text = remove_punctuation(user_input.text).strip().lower()
|
||||
slot_lists["name"] = TextSlotList(
|
||||
name="name",
|
||||
values=[
|
||||
result[2] for result in self._exposed_names_trie.find(text_lower)
|
||||
],
|
||||
values=[result[2] for result in self._exposed_names_trie.find(text)],
|
||||
)
|
||||
|
||||
start = time.monotonic()
|
||||
@@ -1263,7 +1261,7 @@ class DefaultAgent(ConversationEntity):
|
||||
name_list = TextSlotList.from_tuples(exposed_entity_names, allow_template=False)
|
||||
for name_value in name_list.values:
|
||||
assert isinstance(name_value.text_in, TextChunk)
|
||||
name_text = name_value.text_in.text.strip().lower()
|
||||
name_text = remove_punctuation(name_value.text_in.text).strip().lower()
|
||||
self._exposed_names_trie.insert(name_text, name_value)
|
||||
|
||||
self._slot_lists = {
|
||||
|
@@ -19,8 +19,10 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
@@ -103,6 +105,43 @@ class DoorBirdConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Initialize the DoorBird config flow."""
|
||||
self.discovery_schema: vol.Schema | None = None
|
||||
|
||||
async def _async_verify_existing_device_for_discovery(
|
||||
self,
|
||||
existing_entry: ConfigEntry,
|
||||
host: str,
|
||||
macaddress: str,
|
||||
) -> None:
|
||||
"""Verify discovered device matches existing entry before updating IP.
|
||||
|
||||
This method performs the following verification steps:
|
||||
1. Ensures that the stored credentials work before updating the entry.
|
||||
2. Verifies that the device at the discovered IP address has the expected MAC address.
|
||||
"""
|
||||
info, errors = await self._async_validate_or_error(
|
||||
{
|
||||
**existing_entry.data,
|
||||
CONF_HOST: host,
|
||||
}
|
||||
)
|
||||
|
||||
if errors:
|
||||
_LOGGER.debug(
|
||||
"Cannot validate DoorBird at %s with existing credentials: %s",
|
||||
host,
|
||||
errors,
|
||||
)
|
||||
raise AbortFlow("cannot_connect")
|
||||
|
||||
# Verify the MAC address matches what was advertised
|
||||
if format_mac(info["mac_addr"]) != format_mac(macaddress):
|
||||
_LOGGER.debug(
|
||||
"DoorBird at %s reports MAC %s but zeroconf advertised %s, ignoring",
|
||||
host,
|
||||
info["mac_addr"],
|
||||
macaddress,
|
||||
)
|
||||
raise AbortFlow("wrong_device")
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
@@ -172,7 +211,22 @@ class DoorBirdConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
await self.async_set_unique_id(macaddress)
|
||||
host = discovery_info.host
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
|
||||
# Check if we have an existing entry for this MAC
|
||||
existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id(
|
||||
DOMAIN, macaddress
|
||||
)
|
||||
|
||||
if existing_entry:
|
||||
# Check if the host is actually changing
|
||||
if existing_entry.data.get(CONF_HOST) != host:
|
||||
await self._async_verify_existing_device_for_discovery(
|
||||
existing_entry, host, macaddress
|
||||
)
|
||||
|
||||
# All checks passed or no change needed, abort
|
||||
# if already configured with potential IP update
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
|
||||
self._async_abort_entries_match({CONF_HOST: host})
|
||||
|
||||
|
@@ -49,6 +49,8 @@
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"link_local_address": "Link local addresses are not supported",
|
||||
"not_doorbird_device": "This device is not a DoorBird",
|
||||
"not_ipv4_address": "Only IPv4 addresses are supported",
|
||||
"wrong_device": "Device MAC address does not match",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"flow_title": "{name} ({host})",
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aioecowitt==2025.9.0"]
|
||||
"requirements": ["aioecowitt==2025.9.1"]
|
||||
}
|
||||
|
@@ -218,6 +218,12 @@ ECOWITT_SENSORS_MAPPING: Final = {
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.SOIL_MOISTURE: SensorEntityDescription(
|
||||
key="SOIL_MOISTURE",
|
||||
device_class=SensorDeviceClass.MOISTURE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/emoncms",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pyemoncms==0.1.2"]
|
||||
"requirements": ["pyemoncms==0.1.3"]
|
||||
}
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/emoncms_history",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyemoncms==0.1.2"]
|
||||
"requirements": ["pyemoncms==0.1.3"]
|
||||
}
|
||||
|
@@ -22,5 +22,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eq3btsmart"],
|
||||
"requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.2.0"]
|
||||
"requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.3.0"]
|
||||
}
|
||||
|
@@ -19,7 +19,7 @@
|
||||
"requirements": [
|
||||
"aioesphomeapi==39.0.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.2.0"
|
||||
"bleak-esphome==3.3.0"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
@@ -14,6 +14,9 @@
|
||||
"toggle": "[%key:common::device_automation::action_type::toggle%]",
|
||||
"turn_on": "[%key:common::device_automation::action_type::turn_on%]",
|
||||
"turn_off": "[%key:common::device_automation::action_type::turn_off%]"
|
||||
},
|
||||
"extra_fields": {
|
||||
"for": "[%key:common::device_automation::extra_fields::for%]"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
|
@@ -183,8 +183,8 @@
|
||||
"description": "Sets a new password for the guest Wi-Fi. The password must be between 8 and 63 characters long. If no additional parameter is set, the password will be auto-generated with a length of 12 characters.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Fritz!Box Device",
|
||||
"description": "Select the Fritz!Box to configure."
|
||||
"name": "FRITZ!Box Device",
|
||||
"description": "Select the FRITZ!Box to configure."
|
||||
},
|
||||
"password": {
|
||||
"name": "[%key:common::config_flow::data::password%]",
|
||||
|
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250903.2"]
|
||||
"requirements": ["home-assistant-frontend==20250903.5"]
|
||||
}
|
||||
|
@@ -26,16 +26,11 @@ _LOGGER = logging.getLogger(__name__)
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -> bool:
|
||||
"""Set up Govee light local from a config entry."""
|
||||
|
||||
# Get source IPs for all enabled adapters
|
||||
source_ips = await network.async_get_enabled_source_ips(hass)
|
||||
source_ips = await async_get_source_ips(hass)
|
||||
_LOGGER.debug("Enabled source IPs: %s", source_ips)
|
||||
|
||||
coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator(
|
||||
hass=hass,
|
||||
config_entry=entry,
|
||||
source_ips=[
|
||||
source_ip for source_ip in source_ips if isinstance(source_ip, IPv4Address)
|
||||
],
|
||||
hass=hass, config_entry=entry, source_ips=source_ips
|
||||
)
|
||||
|
||||
async def await_cleanup():
|
||||
@@ -76,3 +71,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_get_source_ips(
|
||||
hass: HomeAssistant,
|
||||
) -> set[str]:
|
||||
"""Get the source ips for Govee local."""
|
||||
source_ips = await network.async_get_enabled_source_ips(hass)
|
||||
return {
|
||||
str(source_ip) for source_ip in source_ips if isinstance(source_ip, IPv4Address)
|
||||
}
|
||||
|
@@ -4,15 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from ipaddress import IPv4Address
|
||||
import logging
|
||||
|
||||
from govee_local_api import GoveeController
|
||||
|
||||
from homeassistant.components import network
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_flow
|
||||
|
||||
from . import async_get_source_ips
|
||||
from .const import (
|
||||
CONF_LISTENING_PORT_DEFAULT,
|
||||
CONF_MULTICAST_ADDRESS_DEFAULT,
|
||||
@@ -24,11 +23,11 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _async_discover(hass: HomeAssistant, adapter_ip: IPv4Address) -> bool:
|
||||
async def _async_discover(hass: HomeAssistant, adapter_ip: str) -> bool:
|
||||
controller: GoveeController = GoveeController(
|
||||
loop=hass.loop,
|
||||
logger=_LOGGER,
|
||||
listening_address=str(adapter_ip),
|
||||
listening_address=adapter_ip,
|
||||
broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT,
|
||||
broadcast_port=CONF_TARGET_PORT_DEFAULT,
|
||||
listening_port=CONF_LISTENING_PORT_DEFAULT,
|
||||
@@ -62,14 +61,8 @@ async def _async_discover(hass: HomeAssistant, adapter_ip: IPv4Address) -> bool:
|
||||
async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
"""Return if there are devices that can be discovered."""
|
||||
|
||||
# Get source IPs for all enabled adapters
|
||||
source_ips = await network.async_get_enabled_source_ips(hass)
|
||||
_LOGGER.debug("Enabled source IPs: %s", source_ips)
|
||||
|
||||
# Run discovery on every IPv4 address and gather results
|
||||
results = await asyncio.gather(
|
||||
*[_async_discover(hass, ip) for ip in source_ips if isinstance(ip, IPv4Address)]
|
||||
)
|
||||
source_ips = await async_get_source_ips(hass)
|
||||
results = await asyncio.gather(*[_async_discover(hass, ip) for ip in source_ips])
|
||||
|
||||
return any(results)
|
||||
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from ipaddress import IPv4Address
|
||||
import logging
|
||||
|
||||
from govee_local_api import GoveeController, GoveeDevice
|
||||
@@ -30,7 +29,7 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GoveeLocalConfigEntry,
|
||||
source_ips: list[IPv4Address],
|
||||
source_ips: set[str],
|
||||
) -> None:
|
||||
"""Initialize my coordinator."""
|
||||
super().__init__(
|
||||
@@ -45,7 +44,7 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
|
||||
GoveeController(
|
||||
loop=hass.loop,
|
||||
logger=_LOGGER,
|
||||
listening_address=str(source_ip),
|
||||
listening_address=source_ip,
|
||||
broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT,
|
||||
broadcast_port=CONF_TARGET_PORT_DEFAULT,
|
||||
listening_port=CONF_LISTENING_PORT_DEFAULT,
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["habiticalib"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["habiticalib==0.4.3"]
|
||||
"requirements": ["habiticalib==0.4.5"]
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/harmony",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioharmony", "slixmpp"],
|
||||
"requirements": ["aioharmony==0.5.2"],
|
||||
"requirements": ["aioharmony==0.5.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Logitech",
|
||||
|
@@ -303,9 +303,9 @@ async def _websocket_forward(
|
||||
elif msg.type is aiohttp.WSMsgType.BINARY:
|
||||
await ws_to.send_bytes(msg.data)
|
||||
elif msg.type is aiohttp.WSMsgType.PING:
|
||||
await ws_to.ping()
|
||||
await ws_to.ping(msg.data)
|
||||
elif msg.type is aiohttp.WSMsgType.PONG:
|
||||
await ws_to.pong()
|
||||
await ws_to.pong(msg.data)
|
||||
elif ws_to.closed:
|
||||
await ws_to.close(code=ws_to.close_code, message=msg.extra) # type: ignore[arg-type]
|
||||
except RuntimeError:
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.79", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.81", "babel==2.15.0"]
|
||||
}
|
||||
|
@@ -20,7 +20,12 @@ from aiohomekit.exceptions import (
|
||||
EncryptionError,
|
||||
)
|
||||
from aiohomekit.model import Accessories, Accessory, Transport
|
||||
from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes
|
||||
from aiohomekit.model.characteristics import (
|
||||
EVENT_CHARACTERISTICS,
|
||||
Characteristic,
|
||||
CharacteristicPermissions,
|
||||
CharacteristicsTypes,
|
||||
)
|
||||
from aiohomekit.model.services import Service, ServicesTypes
|
||||
|
||||
from homeassistant.components.thread import async_get_preferred_dataset
|
||||
@@ -52,7 +57,10 @@ from .utils import IidTuple, unique_id_to_iids
|
||||
|
||||
RETRY_INTERVAL = 60 # seconds
|
||||
MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE = 3
|
||||
|
||||
# HomeKit accessories have varying limits on how many characteristics
|
||||
# they can handle per request. Since we don't know each device's specific limit,
|
||||
# we batch requests to a conservative size to avoid overwhelming any device.
|
||||
MAX_CHARACTERISTICS_PER_REQUEST = 49
|
||||
|
||||
BLE_AVAILABILITY_CHECK_INTERVAL = 1800 # seconds
|
||||
|
||||
@@ -179,6 +187,21 @@ class HKDevice:
|
||||
for aid_iid in characteristics:
|
||||
self.pollable_characteristics.discard(aid_iid)
|
||||
|
||||
def get_all_pollable_characteristics(self) -> set[tuple[int, int]]:
|
||||
"""Get all characteristics that can be polled.
|
||||
|
||||
This is used during startup to poll all readable characteristics
|
||||
before entities have registered what they care about.
|
||||
"""
|
||||
return {
|
||||
(accessory.aid, char.iid)
|
||||
for accessory in self.entity_map.accessories
|
||||
for service in accessory.services
|
||||
for char in service.characteristics
|
||||
if CharacteristicPermissions.paired_read in char.perms
|
||||
and char.type not in EVENT_CHARACTERISTICS
|
||||
}
|
||||
|
||||
def add_watchable_characteristics(
|
||||
self, characteristics: list[tuple[int, int]]
|
||||
) -> None:
|
||||
@@ -306,12 +329,20 @@ class HKDevice:
|
||||
)
|
||||
entry.async_on_unload(self._async_cancel_subscription_timer)
|
||||
|
||||
if transport != Transport.BLE:
|
||||
# Although async_populate_accessories_state fetched the accessory database,
|
||||
# the /accessories endpoint may return cached values from the accessory's
|
||||
# perspective. For example, Ecobee thermostats may report stale temperature
|
||||
# values (like 100°C) in their /accessories response after restarting.
|
||||
# We need to explicitly poll characteristics to get fresh sensor readings
|
||||
# before processing the entity map and creating devices.
|
||||
# Use poll_all=True since entities haven't registered their characteristics yet.
|
||||
await self.async_update(poll_all=True)
|
||||
|
||||
await self.async_process_entity_map()
|
||||
|
||||
if transport != Transport.BLE:
|
||||
# Do a single poll to make sure the chars are
|
||||
# up to date so we don't restore old data.
|
||||
await self.async_update()
|
||||
# Start regular polling after entity map is processed
|
||||
self._async_start_polling()
|
||||
|
||||
# If everything is up to date, we can create the entities
|
||||
@@ -863,9 +894,25 @@ class HKDevice:
|
||||
"""Request an debounced update from the accessory."""
|
||||
await self._debounced_update.async_call()
|
||||
|
||||
async def async_update(self, now: datetime | None = None) -> None:
|
||||
"""Poll state of all entities attached to this bridge/accessory."""
|
||||
to_poll = self.pollable_characteristics
|
||||
async def async_update(
|
||||
self, now: datetime | None = None, *, poll_all: bool = False
|
||||
) -> None:
|
||||
"""Poll state of all entities attached to this bridge/accessory.
|
||||
|
||||
Args:
|
||||
now: The current time (used by time interval callbacks).
|
||||
poll_all: If True, poll all readable characteristics instead
|
||||
of just the registered ones.
|
||||
This is useful during initial setup before entities have
|
||||
registered their characteristics.
|
||||
"""
|
||||
if poll_all:
|
||||
# Poll all readable characteristics during initial startup
|
||||
# excluding device trigger characteristics (buttons, doorbell, etc.)
|
||||
to_poll = self.get_all_pollable_characteristics()
|
||||
else:
|
||||
to_poll = self.pollable_characteristics
|
||||
|
||||
if not to_poll:
|
||||
self.async_update_available_state()
|
||||
_LOGGER.debug(
|
||||
@@ -898,20 +945,26 @@ class HKDevice:
|
||||
async with self._polling_lock:
|
||||
_LOGGER.debug("Starting HomeKit device update: %s", self.unique_id)
|
||||
|
||||
try:
|
||||
new_values_dict = await self.get_characteristics(to_poll)
|
||||
except AccessoryNotFoundError:
|
||||
# Not only did the connection fail, but also the accessory is not
|
||||
# visible on the network.
|
||||
self.async_set_available_state(False)
|
||||
return
|
||||
except (AccessoryDisconnectedError, EncryptionError):
|
||||
# Temporary connection failure. Device may still available but our
|
||||
# connection was dropped or we are reconnecting
|
||||
self._poll_failures += 1
|
||||
if self._poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE:
|
||||
new_values_dict: dict[tuple[int, int], dict[str, Any]] = {}
|
||||
to_poll_list = list(to_poll)
|
||||
|
||||
for i in range(0, len(to_poll_list), MAX_CHARACTERISTICS_PER_REQUEST):
|
||||
batch = to_poll_list[i : i + MAX_CHARACTERISTICS_PER_REQUEST]
|
||||
try:
|
||||
batch_values = await self.get_characteristics(batch)
|
||||
new_values_dict.update(batch_values)
|
||||
except AccessoryNotFoundError:
|
||||
# Not only did the connection fail, but also the accessory is not
|
||||
# visible on the network.
|
||||
self.async_set_available_state(False)
|
||||
return
|
||||
return
|
||||
except (AccessoryDisconnectedError, EncryptionError):
|
||||
# Temporary connection failure. Device may still available but our
|
||||
# connection was dropped or we are reconnecting
|
||||
self._poll_failures += 1
|
||||
if self._poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE:
|
||||
self.async_set_available_state(False)
|
||||
return
|
||||
|
||||
self._poll_failures = 0
|
||||
self.process_new_events(new_values_dict)
|
||||
|
@@ -14,6 +14,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohomekit", "commentjson"],
|
||||
"requirements": ["aiohomekit==3.2.15"],
|
||||
"requirements": ["aiohomekit==3.2.17"],
|
||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
|
||||
}
|
||||
|
@@ -9,7 +9,9 @@ from typing import Any
|
||||
import aiohttp
|
||||
from aiohue import LinkButtonNotPressed, create_app_key
|
||||
from aiohue.discovery import DiscoveredHueBridge, discover_bridge, discover_nupnp
|
||||
from aiohue.errors import AiohueException
|
||||
from aiohue.util import normalize_bridge_id
|
||||
from aiohue.v2 import HueBridgeV2
|
||||
import slugify as unicode_slug
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -40,6 +42,9 @@ HUE_MANUFACTURERURL = ("http://www.philips.com", "http://www.philips-hue.com")
|
||||
HUE_IGNORED_BRIDGE_NAMES = ["Home Assistant Bridge", "Espalexa"]
|
||||
HUE_MANUAL_BRIDGE_ID = "manual"
|
||||
|
||||
BSB002_MODEL_ID = "BSB002"
|
||||
BSB003_MODEL_ID = "BSB003"
|
||||
|
||||
|
||||
class HueFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Hue config flow."""
|
||||
@@ -74,7 +79,14 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Return a DiscoveredHueBridge object."""
|
||||
try:
|
||||
bridge = await discover_bridge(
|
||||
host, websession=aiohttp_client.async_get_clientsession(self.hass)
|
||||
host,
|
||||
websession=aiohttp_client.async_get_clientsession(
|
||||
# NOTE: we disable SSL verification for now due to the fact that the (BSB003)
|
||||
# Hue bridge uses a certificate from a on-bridge root authority.
|
||||
# We need to specifically handle this case in a follow-up update.
|
||||
self.hass,
|
||||
verify_ssl=False,
|
||||
),
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
LOGGER.warning(
|
||||
@@ -110,7 +122,9 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
async with asyncio.timeout(5):
|
||||
bridges = await discover_nupnp(
|
||||
websession=aiohttp_client.async_get_clientsession(self.hass)
|
||||
websession=aiohttp_client.async_get_clientsession(
|
||||
self.hass, verify_ssl=False
|
||||
)
|
||||
)
|
||||
except TimeoutError:
|
||||
bridges = []
|
||||
@@ -178,7 +192,9 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
app_key = await create_app_key(
|
||||
bridge.host,
|
||||
f"home-assistant#{device_name}",
|
||||
websession=aiohttp_client.async_get_clientsession(self.hass),
|
||||
websession=aiohttp_client.async_get_clientsession(
|
||||
self.hass, verify_ssl=False
|
||||
),
|
||||
)
|
||||
except LinkButtonNotPressed:
|
||||
errors["base"] = "register_failed"
|
||||
@@ -228,7 +244,6 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: discovery_info.host}, reload_on_update=True
|
||||
)
|
||||
|
||||
# we need to query the other capabilities too
|
||||
bridge = await self._get_bridge(
|
||||
discovery_info.host, discovery_info.properties["bridgeid"]
|
||||
@@ -236,6 +251,14 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if bridge is None:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
self.bridge = bridge
|
||||
if (
|
||||
bridge.supports_v2
|
||||
and discovery_info.properties.get("modelid") == BSB003_MODEL_ID
|
||||
):
|
||||
# try to handle migration of BSB002 --> BSB003
|
||||
if await self._check_migrated_bridge(bridge):
|
||||
return self.async_abort(reason="migrated_bridge")
|
||||
|
||||
return await self.async_step_link()
|
||||
|
||||
async def async_step_homekit(
|
||||
@@ -272,6 +295,55 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self.bridge = bridge
|
||||
return await self.async_step_link()
|
||||
|
||||
async def _check_migrated_bridge(self, bridge: DiscoveredHueBridge) -> bool:
|
||||
"""Check if the discovered bridge is a migrated bridge."""
|
||||
# Try to handle migration of BSB002 --> BSB003.
|
||||
# Once we detect a BSB003 bridge on the network which has not yet been
|
||||
# configured in HA (otherwise we would have had a unique id match),
|
||||
# we check if we have any existing (BSB002) entries and if we can connect to the
|
||||
# new bridge with our previously stored api key.
|
||||
# If that succeeds, we migrate the entry to the new bridge.
|
||||
for conf_entry in self.hass.config_entries.async_entries(
|
||||
DOMAIN, include_ignore=False, include_disabled=False
|
||||
):
|
||||
if conf_entry.data[CONF_API_VERSION] != 2:
|
||||
continue
|
||||
if conf_entry.data[CONF_HOST] == bridge.host:
|
||||
continue
|
||||
# found an existing (BSB002) bridge entry,
|
||||
# check if we can connect to the new BSB003 bridge using the old credentials
|
||||
api = HueBridgeV2(bridge.host, conf_entry.data[CONF_API_KEY])
|
||||
try:
|
||||
await api.fetch_full_state()
|
||||
except (AiohueException, aiohttp.ClientError):
|
||||
continue
|
||||
old_bridge_id = conf_entry.unique_id
|
||||
assert old_bridge_id is not None
|
||||
# found a matching entry, migrate it
|
||||
self.hass.config_entries.async_update_entry(
|
||||
conf_entry,
|
||||
data={
|
||||
**conf_entry.data,
|
||||
CONF_HOST: bridge.host,
|
||||
},
|
||||
unique_id=bridge.id,
|
||||
)
|
||||
# also update the bridge device
|
||||
dev_reg = dr.async_get(self.hass)
|
||||
if bridge_device := dev_reg.async_get_device(
|
||||
identifiers={(DOMAIN, old_bridge_id)}
|
||||
):
|
||||
dev_reg.async_update_device(
|
||||
bridge_device.id,
|
||||
# overwrite identifiers with new bridge id
|
||||
new_identifiers={(DOMAIN, bridge.id)},
|
||||
# overwrite mac addresses with empty set to drop the old (incorrect) addresses
|
||||
# this will be auto corrected once the integration is loaded
|
||||
new_connections=set(),
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class HueV1OptionsFlowHandler(OptionsFlow):
|
||||
"""Handle Hue options for V1 implementation."""
|
||||
|
@@ -10,6 +10,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohue"],
|
||||
"requirements": ["aiohue==4.7.4"],
|
||||
"requirements": ["aiohue==4.7.5"],
|
||||
"zeroconf": ["_hue._tcp.local."]
|
||||
}
|
||||
|
@@ -36,12 +36,13 @@ async def async_setup_entry(
|
||||
"""Set up Automower message event entities.
|
||||
|
||||
Entities are created dynamically based on messages received from the API,
|
||||
but only for mowers that support message events.
|
||||
but only for mowers that support message events after the WebSocket connection
|
||||
is ready.
|
||||
"""
|
||||
coordinator = config_entry.runtime_data
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
restored_mowers = {
|
||||
restored_mowers: set[str] = {
|
||||
entry.unique_id.removesuffix("_message")
|
||||
for entry in er.async_entries_for_config_entry(
|
||||
entity_registry, config_entry.entry_id
|
||||
@@ -49,14 +50,20 @@ async def async_setup_entry(
|
||||
if entry.domain == EVENT_DOMAIN
|
||||
}
|
||||
|
||||
async_add_entities(
|
||||
AutomowerMessageEventEntity(mower_id, coordinator)
|
||||
for mower_id in restored_mowers
|
||||
if mower_id in coordinator.data
|
||||
)
|
||||
@callback
|
||||
def _on_ws_ready() -> None:
|
||||
async_add_entities(
|
||||
AutomowerMessageEventEntity(mower_id, coordinator, websocket_alive=True)
|
||||
for mower_id in restored_mowers
|
||||
if mower_id in coordinator.data
|
||||
)
|
||||
coordinator.api.unregister_ws_ready_callback(_on_ws_ready)
|
||||
|
||||
coordinator.api.register_ws_ready_callback(_on_ws_ready)
|
||||
|
||||
@callback
|
||||
def _handle_message(msg: SingleMessageData) -> None:
|
||||
"""Add entity dynamically if a new mower sends messages."""
|
||||
if msg.id in restored_mowers:
|
||||
return
|
||||
|
||||
@@ -78,11 +85,17 @@ class AutomowerMessageEventEntity(AutomowerBaseEntity, EventEntity):
|
||||
self,
|
||||
mower_id: str,
|
||||
coordinator: AutomowerDataUpdateCoordinator,
|
||||
*,
|
||||
websocket_alive: bool | None = None,
|
||||
) -> None:
|
||||
"""Initialize Automower message event entity."""
|
||||
super().__init__(mower_id, coordinator)
|
||||
self._attr_unique_id = f"{mower_id}_message"
|
||||
self.websocket_alive: bool = coordinator.websocket_alive
|
||||
self.websocket_alive: bool = (
|
||||
websocket_alive
|
||||
if websocket_alive is not None
|
||||
else coordinator.websocket_alive
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pydrawise"],
|
||||
"requirements": ["pydrawise==2025.7.0"]
|
||||
"requirements": ["pydrawise==2025.9.0"]
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["imeon_inverter_api==0.3.14"],
|
||||
"requirements": ["imeon_inverter_api==0.4.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "IMEON",
|
||||
|
@@ -615,7 +615,7 @@ class IntentHandleView(http.HomeAssistantView):
|
||||
intent_result = await intent.async_handle(
|
||||
hass, DOMAIN, intent_name, slots, "", self.context(request)
|
||||
)
|
||||
except intent.IntentHandleError as err:
|
||||
except (intent.IntentHandleError, intent.MatchFailedError) as err:
|
||||
intent_result = intent.IntentResponse(language=language)
|
||||
intent_result.async_set_speech(str(err))
|
||||
|
||||
|
@@ -29,8 +29,7 @@ from .const import (
|
||||
DEFAULT_LANGUAGE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import JewishCalendarData, JewishCalendarUpdateCoordinator
|
||||
from .entity import JewishCalendarConfigEntry
|
||||
from .entity import JewishCalendarConfigEntry, JewishCalendarData
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -70,7 +69,7 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
data = JewishCalendarData(
|
||||
config_entry.runtime_data = JewishCalendarData(
|
||||
language,
|
||||
diaspora,
|
||||
location,
|
||||
@@ -78,11 +77,8 @@ async def async_setup_entry(
|
||||
havdalah_offset,
|
||||
)
|
||||
|
||||
coordinator = JewishCalendarUpdateCoordinator(hass, config_entry, data)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
config_entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -90,13 +86,7 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: JewishCalendarConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
):
|
||||
coordinator = config_entry.runtime_data
|
||||
if coordinator.event_unsub:
|
||||
coordinator.event_unsub()
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
|
@@ -72,7 +72,8 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if sensor is on."""
|
||||
return self.entity_description.is_on(self.coordinator.zmanim)(dt_util.now())
|
||||
zmanim = self.make_zmanim(dt.date.today())
|
||||
return self.entity_description.is_on(zmanim)(dt_util.now())
|
||||
|
||||
def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]:
|
||||
"""Return a list of times to update the sensor."""
|
||||
|
@@ -1,116 +0,0 @@
|
||||
"""Data update coordinator for Jewish calendar."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
from hdate import HDateInfo, Location, Zmanim
|
||||
from hdate.translator import Language, set_language
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers import event
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarUpdateCoordinator]
|
||||
|
||||
|
||||
@dataclass
|
||||
class JewishCalendarData:
|
||||
"""Jewish Calendar runtime dataclass."""
|
||||
|
||||
language: Language
|
||||
diaspora: bool
|
||||
location: Location
|
||||
candle_lighting_offset: int
|
||||
havdalah_offset: int
|
||||
dateinfo: HDateInfo | None = None
|
||||
zmanim: Zmanim | None = None
|
||||
|
||||
|
||||
class JewishCalendarUpdateCoordinator(DataUpdateCoordinator[JewishCalendarData]):
|
||||
"""Data update coordinator class for Jewish calendar."""
|
||||
|
||||
config_entry: JewishCalendarConfigEntry
|
||||
event_unsub: CALLBACK_TYPE | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: JewishCalendarConfigEntry,
|
||||
data: JewishCalendarData,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, config_entry=config_entry)
|
||||
self.data = data
|
||||
self._unsub_update: CALLBACK_TYPE | None = None
|
||||
set_language(data.language)
|
||||
|
||||
async def _async_update_data(self) -> JewishCalendarData:
|
||||
"""Return HDate and Zmanim for today."""
|
||||
now = dt_util.now()
|
||||
_LOGGER.debug("Now: %s Location: %r", now, self.data.location)
|
||||
|
||||
today = now.date()
|
||||
|
||||
self.data.dateinfo = HDateInfo(today, self.data.diaspora)
|
||||
self.data.zmanim = self.make_zmanim(today)
|
||||
self.async_schedule_future_update()
|
||||
return self.data
|
||||
|
||||
@callback
|
||||
def async_schedule_future_update(self) -> None:
|
||||
"""Schedule the next update of the sensor for the upcoming midnight."""
|
||||
# Cancel any existing update
|
||||
if self._unsub_update:
|
||||
self._unsub_update()
|
||||
self._unsub_update = None
|
||||
|
||||
# Calculate the next midnight
|
||||
next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1)
|
||||
|
||||
_LOGGER.debug("Scheduling next update at %s", next_midnight)
|
||||
|
||||
# Schedule update at next midnight
|
||||
self._unsub_update = event.async_track_point_in_time(
|
||||
self.hass, self._handle_midnight_update, next_midnight
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_midnight_update(self, _now: dt.datetime) -> None:
|
||||
"""Handle midnight update callback."""
|
||||
self._unsub_update = None
|
||||
self.async_set_updated_data(self.data)
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Cancel any scheduled updates when the coordinator is shutting down."""
|
||||
await super().async_shutdown()
|
||||
if self._unsub_update:
|
||||
self._unsub_update()
|
||||
self._unsub_update = None
|
||||
|
||||
def make_zmanim(self, date: dt.date) -> Zmanim:
|
||||
"""Create a Zmanim object."""
|
||||
return Zmanim(
|
||||
date=date,
|
||||
location=self.data.location,
|
||||
candle_lighting_offset=self.data.candle_lighting_offset,
|
||||
havdalah_offset=self.data.havdalah_offset,
|
||||
)
|
||||
|
||||
@property
|
||||
def zmanim(self) -> Zmanim:
|
||||
"""Return the current Zmanim."""
|
||||
assert self.data.zmanim is not None, "Zmanim data not available"
|
||||
return self.data.zmanim
|
||||
|
||||
@property
|
||||
def dateinfo(self) -> HDateInfo:
|
||||
"""Return the current HDateInfo."""
|
||||
assert self.data.dateinfo is not None, "HDateInfo data not available"
|
||||
return self.data.dateinfo
|
@@ -24,5 +24,5 @@ async def async_get_config_entry_diagnostics(
|
||||
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": async_redact_data(asdict(entry.runtime_data.data), TO_REDACT),
|
||||
"data": async_redact_data(asdict(entry.runtime_data), TO_REDACT),
|
||||
}
|
||||
|
@@ -1,22 +1,48 @@
|
||||
"""Entity representing a Jewish Calendar sensor."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
from hdate import Zmanim
|
||||
from hdate import HDateInfo, Location, Zmanim
|
||||
from hdate.translator import Language, set_language
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, callback
|
||||
from homeassistant.helpers import event
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import JewishCalendarConfigEntry, JewishCalendarUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData]
|
||||
|
||||
|
||||
class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]):
|
||||
@dataclass
|
||||
class JewishCalendarDataResults:
|
||||
"""Jewish Calendar results dataclass."""
|
||||
|
||||
dateinfo: HDateInfo
|
||||
zmanim: Zmanim
|
||||
|
||||
|
||||
@dataclass
|
||||
class JewishCalendarData:
|
||||
"""Jewish Calendar runtime dataclass."""
|
||||
|
||||
language: Language
|
||||
diaspora: bool
|
||||
location: Location
|
||||
candle_lighting_offset: int
|
||||
havdalah_offset: int
|
||||
results: JewishCalendarDataResults | None = None
|
||||
|
||||
|
||||
class JewishCalendarEntity(Entity):
|
||||
"""An HA implementation for Jewish Calendar entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -29,13 +55,23 @@ class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]):
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Jewish Calendar entity."""
|
||||
super().__init__(config_entry.runtime_data)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{config_entry.entry_id}-{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, config_entry.entry_id)},
|
||||
)
|
||||
self.data = config_entry.runtime_data
|
||||
set_language(self.data.language)
|
||||
|
||||
def make_zmanim(self, date: dt.date) -> Zmanim:
|
||||
"""Create a Zmanim object."""
|
||||
return Zmanim(
|
||||
date=date,
|
||||
location=self.data.location,
|
||||
candle_lighting_offset=self.data.candle_lighting_offset,
|
||||
havdalah_offset=self.data.havdalah_offset,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added to hass."""
|
||||
@@ -56,9 +92,10 @@ class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]):
|
||||
def _schedule_update(self) -> None:
|
||||
"""Schedule the next update of the sensor."""
|
||||
now = dt_util.now()
|
||||
zmanim = self.make_zmanim(now.date())
|
||||
update = dt_util.start_of_local_day() + dt.timedelta(days=1)
|
||||
|
||||
for update_time in self._update_times(self.coordinator.zmanim):
|
||||
for update_time in self._update_times(zmanim):
|
||||
if update_time is not None and now < update_time < update:
|
||||
update = update_time
|
||||
|
||||
@@ -73,4 +110,17 @@ class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]):
|
||||
"""Update the sensor data."""
|
||||
self._update_unsub = None
|
||||
self._schedule_update()
|
||||
self.create_results(now)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def create_results(self, now: dt.datetime | None = None) -> None:
|
||||
"""Create the results for the sensor."""
|
||||
if now is None:
|
||||
now = dt_util.now()
|
||||
|
||||
_LOGGER.debug("Now: %s Location: %r", now, self.data.location)
|
||||
|
||||
today = now.date()
|
||||
zmanim = self.make_zmanim(today)
|
||||
dateinfo = HDateInfo(today, diaspora=self.data.diaspora)
|
||||
self.data.results = JewishCalendarDataResults(dateinfo, zmanim)
|
||||
|
@@ -19,7 +19,7 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .entity import JewishCalendarConfigEntry, JewishCalendarEntity
|
||||
|
||||
@@ -236,18 +236,25 @@ class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity):
|
||||
return []
|
||||
return [self.entity_description.next_update_fn(zmanim)]
|
||||
|
||||
def get_dateinfo(self) -> HDateInfo:
|
||||
def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo:
|
||||
"""Get the next date info."""
|
||||
now = dt_util.now()
|
||||
if self.data.results is None:
|
||||
self.create_results()
|
||||
assert self.data.results is not None, "Results should be available"
|
||||
|
||||
if now is None:
|
||||
now = dt_util.now()
|
||||
|
||||
today = now.date()
|
||||
zmanim = self.make_zmanim(today)
|
||||
update = None
|
||||
|
||||
if self.entity_description.next_update_fn:
|
||||
update = self.entity_description.next_update_fn(self.coordinator.zmanim)
|
||||
update = self.entity_description.next_update_fn(zmanim)
|
||||
|
||||
_LOGGER.debug("Today: %s, update: %s", now.date(), update)
|
||||
_LOGGER.debug("Today: %s, update: %s", today, update)
|
||||
if update is not None and now >= update:
|
||||
return self.coordinator.dateinfo.next_day
|
||||
return self.coordinator.dateinfo
|
||||
return self.data.results.dateinfo.next_day
|
||||
return self.data.results.dateinfo
|
||||
|
||||
|
||||
class JewishCalendarSensor(JewishCalendarBaseSensor):
|
||||
@@ -264,9 +271,7 @@ class JewishCalendarSensor(JewishCalendarBaseSensor):
|
||||
super().__init__(config_entry, description)
|
||||
# Set the options for enumeration sensors
|
||||
if self.entity_description.options_fn is not None:
|
||||
self._attr_options = self.entity_description.options_fn(
|
||||
self.coordinator.data.diaspora
|
||||
)
|
||||
self._attr_options = self.entity_description.options_fn(self.data.diaspora)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | int | dt.datetime | None:
|
||||
@@ -290,8 +295,9 @@ class JewishCalendarTimeSensor(JewishCalendarBaseSensor):
|
||||
@property
|
||||
def native_value(self) -> dt.datetime | None:
|
||||
"""Return the state of the sensor."""
|
||||
if self.data.results is None:
|
||||
self.create_results()
|
||||
assert self.data.results is not None, "Results should be available"
|
||||
if self.entity_description.value_fn is None:
|
||||
return self.coordinator.zmanim.zmanim[self.entity_description.key].local
|
||||
return self.entity_description.value_fn(
|
||||
self.get_dateinfo(), self.coordinator.make_zmanim
|
||||
)
|
||||
return self.data.results.zmanim.zmanim[self.entity_description.key].local
|
||||
return self.entity_description.value_fn(self.get_dateinfo(), self.make_zmanim)
|
||||
|
@@ -285,13 +285,19 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight
|
||||
group_address_switch_green_state=conf.get_state_and_passive(
|
||||
CONF_COLOR, CONF_GA_GREEN_SWITCH
|
||||
),
|
||||
group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS),
|
||||
group_address_brightness_green=conf.get_write(
|
||||
CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS
|
||||
),
|
||||
group_address_brightness_green_state=conf.get_state_and_passive(
|
||||
CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS
|
||||
),
|
||||
group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH),
|
||||
group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH),
|
||||
group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS),
|
||||
group_address_switch_blue=conf.get_write(CONF_COLOR, CONF_GA_BLUE_SWITCH),
|
||||
group_address_switch_blue_state=conf.get_state_and_passive(
|
||||
CONF_COLOR, CONF_GA_BLUE_SWITCH
|
||||
),
|
||||
group_address_brightness_blue=conf.get_write(
|
||||
CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS
|
||||
),
|
||||
group_address_brightness_blue_state=conf.get_state_and_passive(
|
||||
CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS
|
||||
),
|
||||
|
@@ -13,11 +13,12 @@ from homeassistant.util.ulid import ulid_now
|
||||
|
||||
from ..const import DOMAIN
|
||||
from .const import CONF_DATA
|
||||
from .migration import migrate_1_to_2
|
||||
from .migration import migrate_1_to_2, migrate_2_1_to_2_2
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STORAGE_VERSION: Final = 2
|
||||
STORAGE_VERSION_MINOR: Final = 2
|
||||
STORAGE_KEY: Final = f"{DOMAIN}/config_store.json"
|
||||
|
||||
type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration
|
||||
@@ -54,9 +55,13 @@ class _KNXConfigStoreStorage(Store[KNXConfigStoreModel]):
|
||||
) -> dict[str, Any]:
|
||||
"""Migrate to the new version."""
|
||||
if old_major_version == 1:
|
||||
# version 2 introduced in 2025.8
|
||||
# version 2.1 introduced in 2025.8
|
||||
migrate_1_to_2(old_data)
|
||||
|
||||
if old_major_version <= 2 and old_minor_version < 2:
|
||||
# version 2.2 introduced in 2025.9.2
|
||||
migrate_2_1_to_2_2(old_data)
|
||||
|
||||
return old_data
|
||||
|
||||
|
||||
@@ -71,7 +76,9 @@ class KNXConfigStore:
|
||||
"""Initialize config store."""
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self._store = _KNXConfigStoreStorage(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
self._store = _KNXConfigStoreStorage(
|
||||
hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR
|
||||
)
|
||||
self.data = KNXConfigStoreModel(entities={})
|
||||
self._platform_controllers: dict[Platform, PlatformControllerBase] = {}
|
||||
|
||||
|
@@ -118,27 +118,31 @@ COVER_KNX_SCHEMA = AllSerializeFirst(
|
||||
vol.Schema(
|
||||
{
|
||||
"section_binary_control": KNXSectionFlat(),
|
||||
vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False),
|
||||
vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False, valid_dpt="1"),
|
||||
vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(),
|
||||
"section_stop_control": KNXSectionFlat(),
|
||||
vol.Optional(CONF_GA_STOP): GASelector(state=False),
|
||||
vol.Optional(CONF_GA_STEP): GASelector(state=False),
|
||||
vol.Optional(CONF_GA_STOP): GASelector(state=False, valid_dpt="1"),
|
||||
vol.Optional(CONF_GA_STEP): GASelector(state=False, valid_dpt="1"),
|
||||
"section_position_control": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False),
|
||||
vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False),
|
||||
vol.Optional(CONF_GA_POSITION_SET): GASelector(
|
||||
state=False, valid_dpt="5.001"
|
||||
),
|
||||
vol.Optional(CONF_GA_POSITION_STATE): GASelector(
|
||||
write=False, valid_dpt="5.001"
|
||||
),
|
||||
vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(),
|
||||
"section_tilt_control": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_ANGLE): GASelector(),
|
||||
vol.Optional(CONF_GA_ANGLE): GASelector(valid_dpt="5.001"),
|
||||
vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(),
|
||||
"section_travel_time": KNXSectionFlat(),
|
||||
vol.Optional(
|
||||
vol.Required(
|
||||
CoverConf.TRAVELLING_TIME_UP, default=25
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=1000, step=0.1, unit_of_measurement="s"
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
vol.Required(
|
||||
CoverConf.TRAVELLING_TIME_DOWN, default=25
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
@@ -240,19 +244,19 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst(
|
||||
write_required=True, valid_dpt="5.001"
|
||||
),
|
||||
"section_blue": KNXSectionFlat(),
|
||||
vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector(
|
||||
write_required=True, valid_dpt="5.001"
|
||||
),
|
||||
vol.Optional(CONF_GA_BLUE_SWITCH): GASelector(
|
||||
write_required=False, valid_dpt="1"
|
||||
),
|
||||
"section_white": KNXSectionFlat(),
|
||||
vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector(
|
||||
vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector(
|
||||
write_required=True, valid_dpt="5.001"
|
||||
),
|
||||
"section_white": KNXSectionFlat(),
|
||||
vol.Optional(CONF_GA_WHITE_SWITCH): GASelector(
|
||||
write_required=False, valid_dpt="1"
|
||||
),
|
||||
vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector(
|
||||
write_required=True, valid_dpt="5.001"
|
||||
),
|
||||
},
|
||||
),
|
||||
GroupSelectOption(
|
||||
@@ -310,7 +314,7 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst(
|
||||
SWITCH_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
"section_switch": KNXSectionFlat(),
|
||||
vol.Required(CONF_GA_SWITCH): GASelector(write_required=True),
|
||||
vol.Required(CONF_GA_SWITCH): GASelector(write_required=True, valid_dpt="1"),
|
||||
vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
|
@@ -4,6 +4,7 @@ from typing import Any
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
from ..const import CONF_RESPOND_TO_READ
|
||||
from . import const as store_const
|
||||
|
||||
|
||||
@@ -40,3 +41,12 @@ def _migrate_light_schema_1_to_2(light_knx_data: dict[str, Any]) -> None:
|
||||
|
||||
if color:
|
||||
light_knx_data[store_const.CONF_COLOR] = color
|
||||
|
||||
|
||||
def migrate_2_1_to_2_2(data: dict[str, Any]) -> None:
|
||||
"""Migrate from schema 2.1 to schema 2.2."""
|
||||
if b_sensors := data.get("entities", {}).get(Platform.BINARY_SENSOR):
|
||||
for b_sensor in b_sensors.values():
|
||||
# "respond_to_read" was never used for binary_sensor and is not valid
|
||||
# in the new schema. It was set as default in Store schema v1 and v2.1
|
||||
b_sensor["knx"].pop(CONF_RESPOND_TO_READ, None)
|
||||
|
@@ -2,7 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from packaging import version
|
||||
from pylamarzocco import (
|
||||
LaMarzoccoBluetoothClient,
|
||||
@@ -11,6 +13,7 @@ from pylamarzocco import (
|
||||
)
|
||||
from pylamarzocco.const import FirmwareType
|
||||
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
|
||||
from pylamarzocco.util import InstallationKey, generate_installation_key
|
||||
|
||||
from homeassistant.components.bluetooth import async_discovered_service_info
|
||||
from homeassistant.const import (
|
||||
@@ -19,13 +22,14 @@ from homeassistant.const import (
|
||||
CONF_TOKEN,
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
__version__,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_USE_BLUETOOTH, DOMAIN
|
||||
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
|
||||
from .coordinator import (
|
||||
LaMarzoccoConfigEntry,
|
||||
LaMarzoccoConfigUpdateCoordinator,
|
||||
@@ -60,7 +64,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
cloud_client = LaMarzoccoCloudClient(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
client=async_create_clientsession(hass),
|
||||
installation_key=InstallationKey.from_json(entry.data[CONF_INSTALLATION_KEY]),
|
||||
client=create_client_session(hass),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -166,45 +171,50 @@ async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: LaMarzoccoConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate config entry."""
|
||||
if entry.version > 3:
|
||||
if entry.version > 4:
|
||||
# guard against downgrade from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 1:
|
||||
if entry.version in (1, 2):
|
||||
_LOGGER.error(
|
||||
"Migration from version 1 is no longer supported, please remove and re-add the integration"
|
||||
"Migration from version 1 or 2 is no longer supported, please remove and re-add the integration"
|
||||
)
|
||||
return False
|
||||
|
||||
if entry.version == 2:
|
||||
if entry.version == 3:
|
||||
installation_key = generate_installation_key(str(uuid.uuid4()).lower())
|
||||
cloud_client = LaMarzoccoCloudClient(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
installation_key=installation_key,
|
||||
client=create_client_session(hass),
|
||||
)
|
||||
try:
|
||||
things = await cloud_client.list_things()
|
||||
await cloud_client.async_register_client()
|
||||
except (AuthFail, RequestNotSuccessful) as exc:
|
||||
_LOGGER.error("Migration failed with error %s", exc)
|
||||
return False
|
||||
v3_data = {
|
||||
CONF_USERNAME: entry.data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry.data[CONF_PASSWORD],
|
||||
CONF_TOKEN: next(
|
||||
(
|
||||
thing.ble_auth_token
|
||||
for thing in things
|
||||
if thing.serial_number == entry.unique_id
|
||||
),
|
||||
None,
|
||||
),
|
||||
}
|
||||
if CONF_MAC in entry.data:
|
||||
v3_data[CONF_MAC] = entry.data[CONF_MAC]
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=v3_data,
|
||||
version=3,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_INSTALLATION_KEY: installation_key.to_json(),
|
||||
},
|
||||
version=4,
|
||||
)
|
||||
_LOGGER.debug("Migrated La Marzocco config entry to version 2")
|
||||
_LOGGER.debug("Migrated La Marzocco config entry to version 4")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def create_client_session(hass: HomeAssistant) -> ClientSession:
|
||||
"""Create a ClientSession with La Marzocco specific headers."""
|
||||
|
||||
return async_create_clientsession(
|
||||
hass,
|
||||
headers={
|
||||
"X-Client": "HOME_ASSISTANT",
|
||||
"X-Client-Build": __version__,
|
||||
},
|
||||
)
|
||||
|
@@ -5,11 +5,13 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from pylamarzocco import LaMarzoccoCloudClient
|
||||
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
|
||||
from pylamarzocco.models import Thing
|
||||
from pylamarzocco.util import InstallationKey, generate_installation_key
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
@@ -33,7 +35,6 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
@@ -45,7 +46,8 @@ from homeassistant.helpers.selector import (
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import CONF_USE_BLUETOOTH, DOMAIN
|
||||
from . import create_client_session
|
||||
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
|
||||
from .coordinator import LaMarzoccoConfigEntry
|
||||
|
||||
CONF_MACHINE = "machine"
|
||||
@@ -57,9 +59,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for La Marzocco."""
|
||||
|
||||
VERSION = 3
|
||||
VERSION = 4
|
||||
|
||||
_client: ClientSession
|
||||
_installation_key: InstallationKey
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
@@ -83,13 +86,18 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
**user_input,
|
||||
}
|
||||
|
||||
self._client = async_create_clientsession(self.hass)
|
||||
self._client = create_client_session(self.hass)
|
||||
self._installation_key = generate_installation_key(
|
||||
str(uuid.uuid4()).lower()
|
||||
)
|
||||
cloud_client = LaMarzoccoCloudClient(
|
||||
username=data[CONF_USERNAME],
|
||||
password=data[CONF_PASSWORD],
|
||||
client=self._client,
|
||||
installation_key=self._installation_key,
|
||||
)
|
||||
try:
|
||||
await cloud_client.async_register_client()
|
||||
things = await cloud_client.list_things()
|
||||
except AuthFail:
|
||||
_LOGGER.debug("Server rejected login credentials")
|
||||
@@ -184,6 +192,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title=selected_device.name,
|
||||
data={
|
||||
**self._config,
|
||||
CONF_INSTALLATION_KEY: self._installation_key.to_json(),
|
||||
CONF_TOKEN: self._things[serial_number].ble_auth_token,
|
||||
},
|
||||
)
|
||||
|
@@ -5,3 +5,4 @@ from typing import Final
|
||||
DOMAIN: Final = "lamarzocco"
|
||||
|
||||
CONF_USE_BLUETOOTH: Final = "use_bluetooth"
|
||||
CONF_INSTALLATION_KEY: Final = "installation_key"
|
||||
|
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==2.0.11"]
|
||||
"requirements": ["pylamarzocco==2.1.0"]
|
||||
}
|
||||
|
@@ -54,6 +54,6 @@
|
||||
"requirements": [
|
||||
"aiolifx==1.2.1",
|
||||
"aiolifx-effects==0.3.2",
|
||||
"aiolifx-themes==0.6.4"
|
||||
"aiolifx-themes==1.0.2"
|
||||
]
|
||||
}
|
||||
|
@@ -57,7 +57,8 @@
|
||||
},
|
||||
"extra_fields": {
|
||||
"brightness_pct": "Brightness",
|
||||
"flash": "Flash"
|
||||
"flash": "Flash",
|
||||
"for": "[%key:common::device_automation::extra_fields::for%]"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
|
@@ -634,8 +634,8 @@ DISCOVERY_SCHEMAS = [
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="NitrogenDioxideSensor",
|
||||
translation_key="nitrogen_dioxide",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
device_class=SensorDeviceClass.NITROGEN_DIOXIDE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
|
@@ -435,6 +435,9 @@
|
||||
"evse_soc": {
|
||||
"name": "State of charge"
|
||||
},
|
||||
"nitrogen_dioxide": {
|
||||
"name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]"
|
||||
},
|
||||
"pump_control_mode": {
|
||||
"name": "Control mode",
|
||||
"state": {
|
||||
|
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.08.11"],
|
||||
"requirements": ["yt-dlp[default]==2025.09.05"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -2,12 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio.timeouts
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from pymiele import MieleAction, MieleAPI, MieleDevice
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -66,7 +67,22 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]):
|
||||
self.devices = devices
|
||||
actions = {}
|
||||
for device_id in devices:
|
||||
actions_json = await self.api.get_actions(device_id)
|
||||
try:
|
||||
actions_json = await self.api.get_actions(device_id)
|
||||
except ClientResponseError as err:
|
||||
_LOGGER.debug(
|
||||
"Error fetching actions for device %s: Status: %s, Message: %s",
|
||||
device_id,
|
||||
err.status,
|
||||
err.message,
|
||||
)
|
||||
actions_json = {}
|
||||
except TimeoutError:
|
||||
_LOGGER.debug(
|
||||
"Timeout fetching actions for device %s",
|
||||
device_id,
|
||||
)
|
||||
actions_json = {}
|
||||
actions[device_id] = MieleAction(actions_json)
|
||||
return MieleCoordinatorData(devices=devices, actions=actions)
|
||||
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/mill",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["mill", "mill_local"],
|
||||
"requirements": ["millheater==0.12.5", "mill-local==0.3.0"]
|
||||
"requirements": ["millheater==0.13.1", "mill-local==0.3.0"]
|
||||
}
|
||||
|
@@ -267,8 +267,8 @@ CLIMATE_SCHEMA = vol.All(
|
||||
{
|
||||
vol.Required(CONF_TARGET_TEMP): hvac_fixedsize_reglist_validator,
|
||||
vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean,
|
||||
vol.Optional(CONF_MAX_TEMP, default=35): vol.Coerce(float),
|
||||
vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(float),
|
||||
vol.Optional(CONF_MAX_TEMP, default=35): vol.Coerce(int),
|
||||
vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(int),
|
||||
vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float),
|
||||
vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string,
|
||||
vol.Exclusive(CONF_HVAC_ONOFF_COIL, "hvac_onoff_type"): cv.positive_int,
|
||||
|
@@ -62,7 +62,6 @@ from .const import (
|
||||
CONF_VIRTUAL_COUNT,
|
||||
CONF_WRITE_TYPE,
|
||||
CONF_ZERO_SUPPRESS,
|
||||
SIGNAL_START_ENTITY,
|
||||
SIGNAL_STOP_ENTITY,
|
||||
DataType,
|
||||
)
|
||||
@@ -95,18 +94,10 @@ class BasePlatform(Entity):
|
||||
self._attr_name = entry[CONF_NAME]
|
||||
self._attr_device_class = entry.get(CONF_DEVICE_CLASS)
|
||||
|
||||
def get_optional_numeric_config(config_name: str) -> int | float | None:
|
||||
if (val := entry.get(config_name)) is None:
|
||||
return None
|
||||
assert isinstance(val, (float, int)), (
|
||||
f"Expected float or int but {config_name} was {type(val)}"
|
||||
)
|
||||
return val
|
||||
|
||||
self._min_value = get_optional_numeric_config(CONF_MIN_VALUE)
|
||||
self._max_value = get_optional_numeric_config(CONF_MAX_VALUE)
|
||||
self._min_value = entry.get(CONF_MIN_VALUE)
|
||||
self._max_value = entry.get(CONF_MAX_VALUE)
|
||||
self._nan_value = entry.get(CONF_NAN_VALUE)
|
||||
self._zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS)
|
||||
self._zero_suppress = entry.get(CONF_ZERO_SUPPRESS)
|
||||
|
||||
@abstractmethod
|
||||
async def _async_update(self) -> None:
|
||||
@@ -143,7 +134,6 @@ class BasePlatform(Entity):
|
||||
self._cancel_call()
|
||||
self._cancel_call = None
|
||||
self._attr_available = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_await_connection(self, _now: Any) -> None:
|
||||
"""Wait for first connect."""
|
||||
@@ -162,11 +152,6 @@ class BasePlatform(Entity):
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_disable)
|
||||
)
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_START_ENTITY, self.async_local_update
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class BaseStructPlatform(BasePlatform, RestoreEntity):
|
||||
@@ -352,7 +337,6 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity):
|
||||
return
|
||||
|
||||
if self._verify_delay:
|
||||
assert self._verify_delay == 1
|
||||
if self._cancel_call:
|
||||
self._cancel_call()
|
||||
self._cancel_call = None
|
||||
|
@@ -64,7 +64,8 @@ class ModbusLight(BaseSwitch, LightEntity):
|
||||
self._attr_color_mode = self._detect_color_mode(config)
|
||||
self._attr_supported_color_modes = {self._attr_color_mode}
|
||||
|
||||
# Set min/max kelvin values if the mode is COLOR_TEMP
|
||||
self._attr_min_color_temp_kelvin: int = LIGHT_DEFAULT_MIN_KELVIN
|
||||
self._attr_max_color_temp_kelvin: int = LIGHT_DEFAULT_MAX_KELVIN
|
||||
if self._attr_color_mode == ColorMode.COLOR_TEMP:
|
||||
self._attr_min_color_temp_kelvin = config.get(
|
||||
CONF_MIN_TEMP, LIGHT_DEFAULT_MIN_KELVIN
|
||||
@@ -193,9 +194,6 @@ class ModbusLight(BaseSwitch, LightEntity):
|
||||
|
||||
def _convert_modbus_percent_to_temperature(self, percent: int) -> int:
|
||||
"""Convert Modbus scale (0-100) to the color temperature in Kelvin (2000-7000 К)."""
|
||||
assert isinstance(self._attr_min_color_temp_kelvin, int) and isinstance(
|
||||
self._attr_max_color_temp_kelvin, int
|
||||
)
|
||||
return round(
|
||||
self._attr_min_color_temp_kelvin
|
||||
+ (
|
||||
@@ -216,9 +214,6 @@ class ModbusLight(BaseSwitch, LightEntity):
|
||||
|
||||
def _convert_color_temp_to_modbus(self, kelvin: int) -> int:
|
||||
"""Convert color temperature from Kelvin to the Modbus scale (0-100)."""
|
||||
assert isinstance(self._attr_min_color_temp_kelvin, int) and isinstance(
|
||||
self._attr_max_color_temp_kelvin, int
|
||||
)
|
||||
return round(
|
||||
LIGHT_MODBUS_SCALE_MIN
|
||||
+ (kelvin - self._attr_min_color_temp_kelvin)
|
||||
|
@@ -223,6 +223,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
|
||||
# Brightness is supported and no supported_color_modes are set,
|
||||
# so set brightness as the supported color mode.
|
||||
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
else:
|
||||
self._attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
|
||||
def _update_color(self, values: dict[str, Any]) -> None:
|
||||
color_mode: str = values["color_mode"]
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aionfty"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiontfy==0.5.4"]
|
||||
"requirements": ["aiontfy==0.5.5"]
|
||||
}
|
||||
|
@@ -10,7 +10,7 @@ import logging
|
||||
from ohme import ApiException, OhmeApiClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -83,6 +83,21 @@ class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator):
|
||||
|
||||
coordinator_name = "Advanced Settings"
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: OhmeConfigEntry, client: OhmeApiClient
|
||||
) -> None:
|
||||
"""Initialise coordinator."""
|
||||
super().__init__(hass, config_entry, client)
|
||||
|
||||
@callback
|
||||
def _dummy_listener() -> None:
|
||||
pass
|
||||
|
||||
# This coordinator is used by the API library to determine whether the
|
||||
# charger is online and available. It is therefore required even if no
|
||||
# entities are using it.
|
||||
self.async_add_listener(_dummy_listener)
|
||||
|
||||
async def _internal_update_data(self) -> None:
|
||||
"""Fetch data from API endpoint."""
|
||||
await self.client.async_get_advanced_settings()
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["ohme==1.5.1"]
|
||||
"requirements": ["ohme==1.5.2"]
|
||||
}
|
||||
|
@@ -181,7 +181,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[OWMUpdateCoordinator]
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_BEARING)
|
||||
|
||||
@property
|
||||
def visibility(self) -> float | str | None:
|
||||
def native_visibility(self) -> float | None:
|
||||
"""Return visibility."""
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_VISIBILITY_DISTANCE)
|
||||
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/opower",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"requirements": ["opower==0.15.4"]
|
||||
"requirements": ["opower==0.15.5"]
|
||||
}
|
||||
|
@@ -14,6 +14,9 @@
|
||||
"changed_states": "[%key:common::device_automation::trigger_type::changed_states%]",
|
||||
"turned_on": "[%key:common::device_automation::trigger_type::turned_on%]",
|
||||
"turned_off": "[%key:common::device_automation::trigger_type::turned_off%]"
|
||||
},
|
||||
"extra_fields": {
|
||||
"for": "[%key:common::device_automation::extra_fields::for%]"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/schlage",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["pyschlage==2025.7.3"]
|
||||
"requirements": ["pyschlage==2025.9.0"]
|
||||
}
|
||||
|
@@ -365,7 +365,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
unit converter supports both the native and the suggested units of measurement.
|
||||
"""
|
||||
# Make sure we can convert the units
|
||||
if (
|
||||
if self.native_unit_of_measurement != suggested_unit_of_measurement and (
|
||||
(unit_converter := UNIT_CONVERTERS.get(self.device_class)) is None
|
||||
or self.__native_unit_of_measurement_compat
|
||||
not in unit_converter.VALID_UNITS
|
||||
|
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
|
||||
import aiohttp
|
||||
from sharkiq import (
|
||||
AylaApi,
|
||||
SharkIqAuthError,
|
||||
@@ -15,7 +16,7 @@ from homeassistant import exceptions
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import (
|
||||
API_TIMEOUT,
|
||||
@@ -56,10 +57,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
data={**config_entry.data, CONF_REGION: SHARKIQ_REGION_DEFAULT},
|
||||
)
|
||||
|
||||
new_websession = async_create_clientsession(
|
||||
hass,
|
||||
cookie_jar=aiohttp.CookieJar(unsafe=True, quote_cookie=False),
|
||||
)
|
||||
|
||||
ayla_api = get_ayla_api(
|
||||
username=config_entry.data[CONF_USERNAME],
|
||||
password=config_entry.data[CONF_PASSWORD],
|
||||
websession=async_get_clientsession(hass),
|
||||
websession=new_websession,
|
||||
europe=(config_entry.data[CONF_REGION] == SHARKIQ_REGION_EUROPE),
|
||||
)
|
||||
|
||||
@@ -94,7 +100,7 @@ async def async_disconnect_or_timeout(coordinator: SharkIqUpdateCoordinator):
|
||||
await coordinator.ayla_api.async_sign_out()
|
||||
|
||||
|
||||
async def async_update_options(hass, config_entry):
|
||||
async def async_update_options(hass: HomeAssistant, config_entry):
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
|
@@ -15,7 +15,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
@@ -44,15 +44,19 @@ async def _validate_input(
|
||||
hass: HomeAssistant, data: Mapping[str, Any]
|
||||
) -> dict[str, str]:
|
||||
"""Validate the user input allows us to connect."""
|
||||
new_websession = async_create_clientsession(
|
||||
hass,
|
||||
cookie_jar=aiohttp.CookieJar(unsafe=True, quote_cookie=False),
|
||||
)
|
||||
ayla_api = get_ayla_api(
|
||||
username=data[CONF_USERNAME],
|
||||
password=data[CONF_PASSWORD],
|
||||
websession=async_get_clientsession(hass),
|
||||
websession=new_websession,
|
||||
europe=(data[CONF_REGION] == SHARKIQ_REGION_EUROPE),
|
||||
)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
async with asyncio.timeout(15):
|
||||
LOGGER.debug("Initialize connection to Ayla networks API")
|
||||
await ayla_api.async_sign_in()
|
||||
except (TimeoutError, aiohttp.ClientError, TypeError) as error:
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sharkiq",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["sharkiq"],
|
||||
"requirements": ["sharkiq==1.1.1"]
|
||||
"requirements": ["sharkiq==1.4.0"]
|
||||
}
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pymodbus", "pysmarty2"],
|
||||
"requirements": ["pysmarty2==0.10.2"]
|
||||
"requirements": ["pysmarty2==0.10.3"]
|
||||
}
|
||||
|
@@ -410,7 +410,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
||||
@soco_error()
|
||||
def set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
self.soco.volume = int(volume * 100)
|
||||
self.soco.volume = int(round(volume * 100))
|
||||
|
||||
@soco_error(UPNP_ERRORS_TO_IGNORE)
|
||||
def set_shuffle(self, shuffle: bool) -> None:
|
||||
|
@@ -61,8 +61,15 @@ async def async_setup_entry(
|
||||
if (
|
||||
state := getattr(speaker.soco, select_data.soco_attribute, None)
|
||||
) is not None:
|
||||
setattr(speaker, select_data.speaker_attribute, state)
|
||||
features.append(select_data)
|
||||
try:
|
||||
setattr(speaker, select_data.speaker_attribute, int(state))
|
||||
features.append(select_data)
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
"Invalid value for %s %s",
|
||||
select_data.speaker_attribute,
|
||||
state,
|
||||
)
|
||||
return features
|
||||
|
||||
async def _async_create_entities(speaker: SonosSpeaker) -> None:
|
||||
|
@@ -599,7 +599,12 @@ class SonosSpeaker:
|
||||
|
||||
for enum_var in (ATTR_DIALOG_LEVEL,):
|
||||
if enum_var in variables:
|
||||
setattr(self, f"{enum_var}_enum", variables[enum_var])
|
||||
try:
|
||||
setattr(self, f"{enum_var}_enum", int(variables[enum_var]))
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
"Invalid value for %s %s", enum_var, variables[enum_var]
|
||||
)
|
||||
|
||||
self.async_write_entity_states()
|
||||
|
||||
|
@@ -116,6 +116,7 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[
|
||||
MediaType.APPS: MediaType.APP,
|
||||
MediaType.APP: MediaType.TRACK,
|
||||
"favorite": None,
|
||||
"track": MediaType.TRACK,
|
||||
}
|
||||
|
||||
|
||||
|
@@ -607,7 +607,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
|
||||
_media_content_type_list = (
|
||||
query.media_content_type.lower().replace(", ", ",").split(",")
|
||||
if query.media_content_type
|
||||
else ["albums", "tracks", "artists", "genres"]
|
||||
else ["albums", "tracks", "artists", "genres", "playlists"]
|
||||
)
|
||||
|
||||
if query.media_content_type and set(_media_content_type_list).difference(
|
||||
|
@@ -14,6 +14,9 @@
|
||||
"changed_states": "[%key:common::device_automation::trigger_type::changed_states%]",
|
||||
"turned_on": "[%key:common::device_automation::trigger_type::turned_on%]",
|
||||
"turned_off": "[%key:common::device_automation::trigger_type::turned_off%]"
|
||||
},
|
||||
"extra_fields": {
|
||||
"for": "[%key:common::device_automation::extra_fields::for%]"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
|
@@ -291,6 +291,7 @@ class TractiveClient:
|
||||
for switch, key in SWITCH_KEY_MAP.items():
|
||||
if switch_data := event.get(key):
|
||||
payload[switch] = switch_data["active"]
|
||||
payload[ATTR_POWER_SAVING] = event.get("tracker_state_reason") == "POWER_SAVING"
|
||||
self._dispatch_tracker_event(
|
||||
TRACKER_SWITCH_STATUS_UPDATED, event["tracker_id"], payload
|
||||
)
|
||||
|
@@ -18,6 +18,7 @@ from .const import (
|
||||
ATTR_BUZZER,
|
||||
ATTR_LED,
|
||||
ATTR_LIVE_TRACKING,
|
||||
ATTR_POWER_SAVING,
|
||||
TRACKER_SWITCH_STATUS_UPDATED,
|
||||
)
|
||||
from .entity import TractiveEntity
|
||||
@@ -104,7 +105,7 @@ class TractiveSwitch(TractiveEntity, SwitchEntity):
|
||||
|
||||
# We received an event, so the service is online and the switch entities should
|
||||
# be available.
|
||||
self._attr_available = True
|
||||
self._attr_available = not event[ATTR_POWER_SAVING]
|
||||
self._attr_is_on = event[self.entity_description.key]
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
@@ -42,6 +42,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/tuya",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["tuya_iot"],
|
||||
"loggers": ["tuya_sharing"],
|
||||
"requirements": ["tuya-device-sharing-sdk==0.2.1"]
|
||||
}
|
||||
|
@@ -5,6 +5,9 @@
|
||||
"changed_states": "{entity_name} update availability changed",
|
||||
"turned_on": "{entity_name} got an update available",
|
||||
"turned_off": "{entity_name} became up-to-date"
|
||||
},
|
||||
"extra_fields": {
|
||||
"for": "[%key:common::device_automation::extra_fields::for%]"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
|
@@ -59,5 +59,6 @@ class VeluxRainSensor(VeluxEntity, BinarySensorEntity):
|
||||
LOGGER.error("Error fetching limitation data for cover %s", self.name)
|
||||
return
|
||||
|
||||
# Velux windows with rain sensors report an opening limitation of 93 when rain is detected.
|
||||
self._attr_is_on = limitation.min_value == 93
|
||||
# Velux windows with rain sensors report an opening limitation of 93 or 100 (Velux GPU) when rain is detected.
|
||||
# So far, only 93 and 100 have been observed in practice, documentation on this is non-existent AFAIK.
|
||||
self._attr_is_on = limitation.min_value in {93, 100}
|
||||
|
@@ -4,8 +4,15 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from pyvlx import OpeningDevice, Position
|
||||
from pyvlx.opening_device import Awning, Blind, GarageDoor, Gate, RollerShutter
|
||||
from pyvlx import (
|
||||
Awning,
|
||||
Blind,
|
||||
GarageDoor,
|
||||
Gate,
|
||||
OpeningDevice,
|
||||
Position,
|
||||
RollerShutter,
|
||||
)
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
@@ -97,7 +104,10 @@ class VeluxCover(VeluxEntity, CoverEntity):
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
"""Return if the cover is closed."""
|
||||
return self.node.position.closed
|
||||
# do not use the node's closed state but rely on cover position
|
||||
# until https://github.com/Julius2342/pyvlx/pull/543 is merged.
|
||||
# once merged this can again return self.node.position.closed
|
||||
return self.current_cover_position == 0
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool:
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "velux",
|
||||
"name": "Velux",
|
||||
"codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio"],
|
||||
"codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio", "@wollew"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
|
@@ -8,11 +8,14 @@ from typing import Any, cast
|
||||
from aiohttp import ClientSession
|
||||
from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions
|
||||
|
||||
from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME
|
||||
from homeassistant.components.device_tracker import (
|
||||
DEFAULT_CONSIDER_HOME,
|
||||
DOMAIN as DEVICE_TRACKER_DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -71,16 +74,14 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||
config_entry=config_entry,
|
||||
)
|
||||
device_reg = dr.async_get(self.hass)
|
||||
device_list = dr.async_entries_for_config_entry(
|
||||
device_reg, self.config_entry.entry_id
|
||||
)
|
||||
|
||||
entity_reg = er.async_get(hass)
|
||||
self.previous_devices = {
|
||||
connection[1].upper()
|
||||
for device in device_list
|
||||
for connection in device.connections
|
||||
if connection[0] == dr.CONNECTION_NETWORK_MAC
|
||||
entry.unique_id
|
||||
for entry in er.async_entries_for_config_entry(
|
||||
entity_reg, config_entry.entry_id
|
||||
)
|
||||
if entry.domain == DEVICE_TRACKER_DOMAIN
|
||||
}
|
||||
|
||||
def _calculate_update_time_and_consider_home(
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiovodafone"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiovodafone==0.10.0"]
|
||||
"requirements": ["aiovodafone==1.2.1"]
|
||||
}
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["waterfurnace"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["waterfurnace==1.1.0"]
|
||||
"requirements": ["waterfurnace==1.2.0"]
|
||||
}
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["holidays"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["holidays==0.79"]
|
||||
"requirements": ["holidays==0.81"]
|
||||
}
|
||||
|
@@ -146,6 +146,8 @@ async def async_send_message( # noqa: C901
|
||||
|
||||
self.enable_starttls = use_tls
|
||||
self.enable_direct_tls = use_tls
|
||||
self.enable_plaintext = not use_tls
|
||||
self["feature_mechanisms"].unencrypted_scram = not use_tls
|
||||
self.use_ipv6 = False
|
||||
self.add_event_handler("failed_all_auth", self.disconnect_on_login_fail)
|
||||
self.add_event_handler("session_start", self.start)
|
||||
|
@@ -25,7 +25,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2025
|
||||
MINOR_VERSION: Final = 9
|
||||
PATCH_VERSION: Final = "0"
|
||||
PATCH_VERSION: Final = "4"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
|
||||
|
@@ -1898,11 +1898,25 @@ def _async_setup_entity_restore(hass: HomeAssistant, registry: EntityRegistry) -
|
||||
@callback
|
||||
def cleanup_restored_states_filter(event_data: Mapping[str, Any]) -> bool:
|
||||
"""Clean up restored states filter."""
|
||||
return bool(event_data["action"] == "remove")
|
||||
return (event_data["action"] == "remove") or (
|
||||
event_data["action"] == "update"
|
||||
and "old_entity_id" in event_data
|
||||
and event_data["entity_id"] != event_data["old_entity_id"]
|
||||
)
|
||||
|
||||
@callback
|
||||
def cleanup_restored_states(event: Event[EventEntityRegistryUpdatedData]) -> None:
|
||||
"""Clean up restored states."""
|
||||
if event.data["action"] == "update":
|
||||
old_entity_id = event.data["old_entity_id"]
|
||||
old_state = hass.states.get(old_entity_id)
|
||||
if old_state is None or not old_state.attributes.get(ATTR_RESTORED):
|
||||
return
|
||||
hass.states.async_remove(old_entity_id, context=event.context)
|
||||
if entry := registry.async_get(event.data["entity_id"]):
|
||||
entry.write_unavailable_state(hass)
|
||||
return
|
||||
|
||||
state = hass.states.get(event.data["entity_id"])
|
||||
|
||||
if state is None or not state.attributes.get(ATTR_RESTORED):
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user