This commit is contained in:
Franck Nijhof 2024-10-04 19:33:37 +02:00 committed by GitHub
commit 2182bc3af2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 162 additions and 34 deletions

View File

@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/cast", "documentation": "https://www.home-assistant.io/integrations/cast",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["casttube", "pychromecast"], "loggers": ["casttube", "pychromecast"],
"requirements": ["PyChromecast==14.0.2"], "requirements": ["PyChromecast==14.0.1"],
"zeroconf": ["_googlecast._tcp.local."] "zeroconf": ["_googlecast._tcp.local."]
} }

View File

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/matrix", "documentation": "https://www.home-assistant.io/integrations/matrix",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["matrix_client"], "loggers": ["matrix_client"],
"requirements": ["matrix-nio==0.25.1", "Pillow==10.4.0"] "requirements": ["matrix-nio==0.25.2", "Pillow==10.4.0"]
} }

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/mealie", "documentation": "https://www.home-assistant.io/integrations/mealie",
"integration_type": "service", "integration_type": "service",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["aiomealie==0.9.2"] "requirements": ["aiomealie==0.9.3"]
} }

View File

@ -7,7 +7,7 @@ from nyt_games import NYTGamesClient
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN, Platform from homeassistant.const import CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .coordinator import NYTGamesCoordinator from .coordinator import NYTGamesCoordinator
@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NYTGamesConfigEntry) ->
"""Set up NYTGames from a config entry.""" """Set up NYTGames from a config entry."""
client = NYTGamesClient( client = NYTGamesClient(
entry.data[CONF_TOKEN], session=async_get_clientsession(hass) entry.data[CONF_TOKEN], session=async_create_clientsession(hass)
) )
coordinator = NYTGamesCoordinator(hass, client) coordinator = NYTGamesCoordinator(hass, client)

View File

@ -7,7 +7,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_TOKEN from homeassistant.const import CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
@ -21,8 +21,9 @@ class NYTGamesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a flow initialized by the user.""" """Handle a flow initialized by the user."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input: if user_input:
session = async_get_clientsession(self.hass) session = async_create_clientsession(self.hass)
client = NYTGamesClient(user_input[CONF_TOKEN], session=session) token = user_input[CONF_TOKEN].strip()
client = NYTGamesClient(token, session=session)
try: try:
user_id = await client.get_user_id() user_id = await client.get_user_id()
except NYTGamesAuthenticationError: except NYTGamesAuthenticationError:
@ -35,7 +36,9 @@ class NYTGamesConfigFlow(ConfigFlow, domain=DOMAIN):
else: else:
await self.async_set_unique_id(str(user_id)) await self.async_set_unique_id(str(user_id))
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
return self.async_create_entry(title="NYT Games", data=user_input) return self.async_create_entry(
title="NYT Games", data={CONF_TOKEN: token}
)
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}), data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}),

View File

@ -12,7 +12,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import ACCOUNT_HASH, DOMAIN from .const import ACCOUNT_HASH, DOMAIN, UPDATE_INTERVAL
from .coordinator import RitualsDataUpdateCoordinator from .coordinator import RitualsDataUpdateCoordinator
PLATFORMS = [ PLATFORMS = [
@ -37,9 +37,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Migrate old unique_ids to the new format # Migrate old unique_ids to the new format
async_migrate_entities_unique_ids(hass, entry, account_devices) async_migrate_entities_unique_ids(hass, entry, account_devices)
# The API provided by Rituals is currently rate limited to 30 requests
# per hour per IP address. To avoid hitting this limit, we will adjust
# the polling interval based on the number of diffusers one has.
update_interval = UPDATE_INTERVAL * len(account_devices)
# Create a coordinator for each diffuser # Create a coordinator for each diffuser
coordinators = { coordinators = {
diffuser.hublot: RitualsDataUpdateCoordinator(hass, diffuser) diffuser.hublot: RitualsDataUpdateCoordinator(hass, diffuser, update_interval)
for diffuser in account_devices for diffuser in account_devices
} }

View File

@ -45,6 +45,7 @@ class RitualsPerfumeGenieConfigFlow(ConfigFlow, domain=DOMAIN):
try: try:
await account.authenticate() await account.authenticate()
except ClientResponseError: except ClientResponseError:
_LOGGER.exception("Unexpected response")
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except AuthenticationException: except AuthenticationException:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"

View File

@ -6,4 +6,8 @@ DOMAIN = "rituals_perfume_genie"
ACCOUNT_HASH = "account_hash" ACCOUNT_HASH = "account_hash"
UPDATE_INTERVAL = timedelta(minutes=2) # The API provided by Rituals is currently rate limited to 30 requests
# per hour per IP address. To avoid hitting this limit, the polling
# interval is set to 3 minutes. This also gives a little room for
# Home Assistant restarts.
UPDATE_INTERVAL = timedelta(minutes=3)

View File

@ -1,5 +1,6 @@
"""The Rituals Perfume Genie data update coordinator.""" """The Rituals Perfume Genie data update coordinator."""
from datetime import timedelta
import logging import logging
from pyrituals import Diffuser from pyrituals import Diffuser
@ -7,7 +8,7 @@ from pyrituals import Diffuser
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, UPDATE_INTERVAL from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -15,14 +16,19 @@ _LOGGER = logging.getLogger(__name__)
class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""Class to manage fetching Rituals Perfume Genie device data from single endpoint.""" """Class to manage fetching Rituals Perfume Genie device data from single endpoint."""
def __init__(self, hass: HomeAssistant, diffuser: Diffuser) -> None: def __init__(
self,
hass: HomeAssistant,
diffuser: Diffuser,
update_interval: timedelta,
) -> None:
"""Initialize global Rituals Perfume Genie data updater.""" """Initialize global Rituals Perfume Genie data updater."""
self.diffuser = diffuser self.diffuser = diffuser
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name=f"{DOMAIN}-{diffuser.hublot}", name=f"{DOMAIN}-{diffuser.hublot}",
update_interval=UPDATE_INTERVAL, update_interval=update_interval,
) )
async def _async_update_data(self) -> None: async def _async_update_data(self) -> None:

View File

@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/smlight", "documentation": "https://www.home-assistant.io/integrations/smlight",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["pysmlight==0.1.1"], "requirements": ["pysmlight==0.1.2"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_slzb-06._tcp.local." "type": "_slzb-06._tcp.local."

View File

@ -194,4 +194,4 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity):
@property @property
def unique_id(self) -> str: def unique_id(self) -> str:
"""Return a unique ID.""" """Return a unique ID."""
return "-".join(self._id) return "-".join(map(str, self._id))

View File

@ -38,6 +38,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError from homeassistant.exceptions import TemplateError
from homeassistant.helpers import selector from homeassistant.helpers import selector
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
@ -233,7 +234,10 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore
self._trigger_script = Script(hass, trigger_action, name, DOMAIN) self._trigger_script = Script(hass, trigger_action, name, DOMAIN)
self._state: str | None = None self._state: str | None = None
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
supported_features = AlarmControlPanelEntityFeature(0) supported_features = AlarmControlPanelEntityFeature(0)
if self._arm_night_script is not None: if self._arm_night_script is not None:
supported_features = ( supported_features = (

View File

@ -47,6 +47,7 @@ PLATFORMS: Final = [
Platform.DEVICE_TRACKER, Platform.DEVICE_TRACKER,
Platform.LOCK, Platform.LOCK,
Platform.MEDIA_PLAYER, Platform.MEDIA_PLAYER,
Platform.NUMBER,
Platform.SELECT, Platform.SELECT,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,

View File

@ -1558,7 +1558,7 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
super().__init__() super().__init__()
self._hass = hass self._hass = hass
self._domain_index: dict[str, list[ConfigEntry]] = {} self._domain_index: dict[str, list[ConfigEntry]] = {}
self._domain_unique_id_index: dict[str, dict[str, ConfigEntry]] = {} self._domain_unique_id_index: dict[str, dict[str, list[ConfigEntry]]] = {}
def values(self) -> ValuesView[ConfigEntry]: def values(self) -> ValuesView[ConfigEntry]:
"""Return the underlying values to avoid __iter__ overhead.""" """Return the underlying values to avoid __iter__ overhead."""
@ -1601,9 +1601,9 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
report_issue, report_issue,
) )
self._domain_unique_id_index.setdefault(entry.domain, {})[ self._domain_unique_id_index.setdefault(entry.domain, {}).setdefault(
unique_id_hash unique_id_hash, []
] = entry ).append(entry)
def _unindex_entry(self, entry_id: str) -> None: def _unindex_entry(self, entry_id: str) -> None:
"""Unindex an entry.""" """Unindex an entry."""
@ -1616,6 +1616,8 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
# Check type first to avoid expensive isinstance call # Check type first to avoid expensive isinstance call
if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721 if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721
unique_id = str(entry.unique_id) # type: ignore[unreachable] unique_id = str(entry.unique_id) # type: ignore[unreachable]
self._domain_unique_id_index[domain][unique_id].remove(entry)
if not self._domain_unique_id_index[domain][unique_id]:
del self._domain_unique_id_index[domain][unique_id] del self._domain_unique_id_index[domain][unique_id]
if not self._domain_unique_id_index[domain]: if not self._domain_unique_id_index[domain]:
del self._domain_unique_id_index[domain] del self._domain_unique_id_index[domain]
@ -1647,7 +1649,10 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
# Check type first to avoid expensive isinstance call # Check type first to avoid expensive isinstance call
if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721 if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721
unique_id = str(unique_id) # type: ignore[unreachable] unique_id = str(unique_id) # type: ignore[unreachable]
return self._domain_unique_id_index.get(domain, {}).get(unique_id) entries = self._domain_unique_id_index.get(domain, {}).get(unique_id)
if not entries:
return None
return entries[0]
class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]): class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]):

View File

@ -24,7 +24,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024 MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 10 MINOR_VERSION: Final = 10
PATCH_VERSION: Final = "0" PATCH_VERSION: Final = "1"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2024.10.0" version = "2024.10.1"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3." description = "Open-source home automation platform running on Python 3."
readme = "README.rst" readme = "README.rst"

View File

@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3
# PyBluez==0.22 # PyBluez==0.22
# homeassistant.components.cast # homeassistant.components.cast
PyChromecast==14.0.2 PyChromecast==14.0.1
# homeassistant.components.flick_electric # homeassistant.components.flick_electric
PyFlick==0.0.2 PyFlick==0.0.2
@ -294,7 +294,7 @@ aiolookin==1.0.0
aiolyric==2.0.1 aiolyric==2.0.1
# homeassistant.components.mealie # homeassistant.components.mealie
aiomealie==0.9.2 aiomealie==0.9.3
# homeassistant.components.modern_forms # homeassistant.components.modern_forms
aiomodernforms==0.1.8 aiomodernforms==0.1.8
@ -1324,7 +1324,7 @@ lw12==0.9.2
lxml==5.3.0 lxml==5.3.0
# homeassistant.components.matrix # homeassistant.components.matrix
matrix-nio==0.25.1 matrix-nio==0.25.2
# homeassistant.components.maxcube # homeassistant.components.maxcube
maxcube-api==0.4.3 maxcube-api==0.4.3
@ -2244,7 +2244,7 @@ pysmarty2==0.10.1
pysml==0.0.12 pysml==0.0.12
# homeassistant.components.smlight # homeassistant.components.smlight
pysmlight==0.1.1 pysmlight==0.1.2
# homeassistant.components.snmp # homeassistant.components.snmp
pysnmp==6.2.6 pysnmp==6.2.6

View File

@ -42,7 +42,7 @@ PlexAPI==4.15.16
ProgettiHWSW==0.1.3 ProgettiHWSW==0.1.3
# homeassistant.components.cast # homeassistant.components.cast
PyChromecast==14.0.2 PyChromecast==14.0.1
# homeassistant.components.flick_electric # homeassistant.components.flick_electric
PyFlick==0.0.2 PyFlick==0.0.2
@ -276,7 +276,7 @@ aiolookin==1.0.0
aiolyric==2.0.1 aiolyric==2.0.1
# homeassistant.components.mealie # homeassistant.components.mealie
aiomealie==0.9.2 aiomealie==0.9.3
# homeassistant.components.modern_forms # homeassistant.components.modern_forms
aiomodernforms==0.1.8 aiomodernforms==0.1.8
@ -1099,7 +1099,7 @@ lupupy==0.3.2
lxml==5.3.0 lxml==5.3.0
# homeassistant.components.matrix # homeassistant.components.matrix
matrix-nio==0.25.1 matrix-nio==0.25.2
# homeassistant.components.maxcube # homeassistant.components.maxcube
maxcube-api==0.4.3 maxcube-api==0.4.3
@ -1798,7 +1798,7 @@ pysmartthings==0.7.8
pysml==0.0.12 pysml==0.0.12
# homeassistant.components.smlight # homeassistant.components.smlight
pysmlight==0.1.1 pysmlight==0.1.2
# homeassistant.components.snmp # homeassistant.components.snmp
pysnmp==6.2.6 pysnmp==6.2.6

View File

@ -37,6 +37,27 @@ async def test_full_flow(
assert result["result"].unique_id == "218886794" assert result["result"].unique_id == "218886794"
async def test_stripping_token(
hass: HomeAssistant,
mock_nyt_games_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test stripping token."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_TOKEN: " token "},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_TOKEN: "token"}
@pytest.mark.parametrize( @pytest.mark.parametrize(
("exception", "error"), ("exception", "error"),
[ [

View File

@ -23,6 +23,7 @@ from homeassistant.const import (
STATE_UNKNOWN, STATE_UNKNOWN,
) )
from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache
@ -503,3 +504,45 @@ async def test_restore_state(
state = hass.states.get("alarm_control_panel.test_template_panel") state = hass.states.get("alarm_control_panel.test_template_panel")
assert state.state == initial_state assert state.state == initial_state
async def test_device_id(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test for device for button template."""
device_config_entry = MockConfigEntry()
device_config_entry.add_to_hass(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=device_config_entry.entry_id,
identifiers={("test", "identifier_test")},
connections={("mac", "30:31:32:33:34:35")},
)
await hass.async_block_till_done()
assert device_entry is not None
assert device_entry.id is not None
template_config_entry = MockConfigEntry(
data={},
domain=template.DOMAIN,
options={
"name": "My template",
"value_template": "disarmed",
"template_type": "alarm_control_panel",
"code_arm_required": True,
"code_format": "number",
"device_id": device_entry.id,
},
title="My template",
)
template_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(template_config_entry.entry_id)
await hass.async_block_till_done()
template_entity = entity_registry.async_get("alarm_control_panel.my_template")
assert template_entity is not None
assert template_entity.device_id == device_entry.id

View File

@ -512,6 +512,41 @@ async def test_remove_entry(
assert not entity_entry_list assert not entity_entry_list
async def test_remove_entry_non_unique_unique_id(
hass: HomeAssistant,
manager: config_entries.ConfigEntries,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that we can remove entry with colliding unique_id."""
entry_1 = MockConfigEntry(
domain="test_other", entry_id="test1", unique_id="not_unique"
)
entry_1.add_to_manager(manager)
entry_2 = MockConfigEntry(
domain="test_other", entry_id="test2", unique_id="not_unique"
)
entry_2.add_to_manager(manager)
entry_3 = MockConfigEntry(
domain="test_other", entry_id="test3", unique_id="not_unique"
)
entry_3.add_to_manager(manager)
# Check all config entries exist
assert manager.async_entry_ids() == [
"test1",
"test2",
"test3",
]
# Remove entries
assert await manager.async_remove("test1") == {"require_restart": False}
await hass.async_block_till_done()
assert await manager.async_remove("test2") == {"require_restart": False}
await hass.async_block_till_done()
assert await manager.async_remove("test3") == {"require_restart": False}
await hass.async_block_till_done()
async def test_remove_entry_cancels_reauth( async def test_remove_entry_cancels_reauth(
hass: HomeAssistant, hass: HomeAssistant,
manager: config_entries.ConfigEntries, manager: config_entries.ConfigEntries,