Compare commits

..

16 Commits

Author SHA1 Message Date
Kevin Stillhammer
cf9444dc64 Add reauth to fressnapf_tracker (#157994) 2025-12-26 20:36:17 +01:00
Daniel Rauber
a447217b03 kostal_plenticore: Add state_class to Battery SoC sensor (#159776) 2025-12-26 20:31:47 +01:00
Pete Sage
e639ebc269 Exceptions during Sonos Unjoin action results in hung script (#159779) 2025-12-26 20:20:12 +01:00
Paul Tarjan
45ba7e0df1 Fix HikCamera.get_event_triggers() call with incorrect argument (#159760) 2025-12-26 11:33:53 +01:00
Manu
dfdcdbc856 Add integration type hub to Google Cast (#159757) 2025-12-26 11:26:31 +01:00
Manu
ea5df92ab9 Add integration type hub to Xiaomi Home (#159758) 2025-12-26 11:25:38 +01:00
Víctor Gurbani
9d1f500d65 Add state_class to Nuki battery sensor (#159756) 2025-12-26 10:37:16 +01:00
Joost Lekkerkerker
a82f500934 Add integration_type hub to moehlenhoff_alpha2 (#159694)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-12-25 21:56:55 +01:00
Joost Lekkerkerker
71d29ba28e Add integration_type hub to motioneye (#159698) 2025-12-25 21:54:32 +01:00
Joost Lekkerkerker
7910f33140 Add integration_type hub to microbees (#159690) 2025-12-25 21:53:24 +01:00
Joost Lekkerkerker
91ebeb84e7 Add integration_type service to monzo (#159697) 2025-12-25 21:52:54 +01:00
Joost Lekkerkerker
1664dd5702 Add integration_type device to mikrotik (#159691) 2025-12-25 21:52:37 +01:00
Joost Lekkerkerker
5e96ec820f Add integration_type service to monarch_money (#159695) 2025-12-25 21:50:17 +01:00
Joost Lekkerkerker
5eedef4920 Add integration_type hub to monoprice (#159696) 2025-12-25 21:50:03 +01:00
Joost Lekkerkerker
71728ba37e Add integration_type device to moat (#159693) 2025-12-25 21:48:54 +01:00
Allen Porter
5657bd11b8 Start reauth when roborock notices the MQTT session is unauthorized (#159719) 2025-12-25 21:47:42 +01:00
34 changed files with 357 additions and 194 deletions

View File

@@ -12,6 +12,7 @@
"codeowners": ["@emontnemery"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/cast",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["casttube", "pychromecast"],
"requirements": ["PyChromecast==14.0.9"],

View File

@@ -1,12 +1,13 @@
"""The Fressnapf Tracker integration."""
from fressnapftracker import AuthClient
from fressnapftracker import AuthClient, FressnapfTrackerAuthenticationError
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_USER_ID
from .const import CONF_USER_ID, DOMAIN
from .coordinator import (
FressnapfTrackerConfigEntry,
FressnapfTrackerDataUpdateCoordinator,
@@ -26,10 +27,16 @@ async def async_setup_entry(
) -> bool:
"""Set up Fressnapf Tracker from a config entry."""
auth_client = AuthClient(client=get_async_client(hass))
devices = await auth_client.get_devices(
user_id=entry.data[CONF_USER_ID],
user_access_token=entry.data[CONF_ACCESS_TOKEN],
)
try:
devices = await auth_client.get_devices(
user_id=entry.data[CONF_USER_ID],
user_access_token=entry.data[CONF_ACCESS_TOKEN],
)
except FressnapfTrackerAuthenticationError as exception:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
) from exception
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
for device in devices:

View File

@@ -1,5 +1,6 @@
"""Config flow for the Fressnapf Tracker integration."""
from collections.abc import Mapping
import logging
from typing import Any
@@ -10,7 +11,12 @@ from fressnapftracker import (
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers.httpx_client import get_async_client
@@ -136,40 +142,43 @@ class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
async def _async_reauth_reconfigure(
self,
user_input: dict[str, Any] | None,
entry: Any,
step_id: str,
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
"""Request a new sms code for reauth or reconfigure flows."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
errors, success = await self._async_request_sms_code(
user_input[CONF_PHONE_NUMBER]
)
if success:
if reconfigure_entry.data[CONF_USER_ID] != self._context[CONF_USER_ID]:
if entry.data[CONF_USER_ID] != self._context[CONF_USER_ID]:
errors["base"] = "account_change_not_allowed"
else:
elif self.source == SOURCE_REAUTH:
return await self.async_step_reauth_sms_code()
elif self.source == SOURCE_RECONFIGURE:
return await self.async_step_reconfigure_sms_code()
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
{
vol.Required(
CONF_PHONE_NUMBER,
default=reconfigure_entry.data.get(CONF_PHONE_NUMBER),
): str,
}
step_id=step_id,
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA,
{CONF_PHONE_NUMBER: entry.data.get(CONF_PHONE_NUMBER)},
),
errors=errors,
)
async def async_step_reconfigure_sms_code(
self, user_input: dict[str, Any] | None = None
async def _async_reauth_reconfigure_sms_code(
self,
user_input: dict[str, Any] | None,
entry: Any,
step_id: str,
) -> ConfigFlowResult:
"""Handle the SMS code step during reconfiguration."""
"""Verify SMS code for reauth or reconfigure flows."""
errors: dict[str, str] = {}
if user_input is not None:
@@ -178,16 +187,61 @@ class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
)
if access_token:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data={
entry,
data_updates={
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
CONF_USER_ID: self._context[CONF_USER_ID],
CONF_ACCESS_TOKEN: access_token,
},
)
return self.async_show_form(
step_id="reconfigure_sms_code",
step_id=step_id,
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle configuration by re-auth."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirmation step."""
return await self._async_reauth_reconfigure(
user_input,
self._get_reauth_entry(),
"reauth_confirm",
)
async def async_step_reauth_sms_code(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the SMS code step during reauth."""
return await self._async_reauth_reconfigure_sms_code(
user_input,
self._get_reauth_entry(),
"reauth_sms_code",
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
return await self._async_reauth_reconfigure(
user_input,
self._get_reconfigure_entry(),
"reconfigure",
)
async def async_step_reconfigure_sms_code(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the SMS code step during reconfiguration."""
return await self._async_reauth_reconfigure_sms_code(
user_input,
self._get_reconfigure_entry(),
"reconfigure_sms_code",
)

View File

@@ -3,10 +3,17 @@
from datetime import timedelta
import logging
from fressnapftracker import ApiClient, Device, FressnapfTrackerError, Tracker
from fressnapftracker import (
ApiClient,
Device,
FressnapfTrackerError,
FressnapfTrackerInvalidDeviceTokenError,
Tracker,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -46,5 +53,10 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
async def _async_update_data(self) -> Tracker:
try:
return await self.client.get_tracker()
except FressnapfTrackerInvalidDeviceTokenError as exception:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
) from exception
except FressnapfTrackerError as exception:
raise UpdateFailed(exception) from exception

View File

@@ -34,7 +34,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold

View File

@@ -2,6 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
@@ -11,7 +12,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reconfigure": {
"reauth_confirm": {
"data": {
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data::phone_number%]"
},
@@ -20,6 +21,23 @@
},
"description": "Re-authenticate with your Fressnapf Tracker account to refresh your credentials."
},
"reauth_sms_code": {
"data": {
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data::sms_code%]"
},
"data_description": {
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data_description::sms_code%]"
}
},
"reconfigure": {
"data": {
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data::phone_number%]"
},
"data_description": {
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data_description::phone_number%]"
},
"description": "Update your Fressnapf Tracker account configuration."
},
"reconfigure_sms_code": {
"data": {
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data::sms_code%]"
@@ -62,6 +80,9 @@
"charging": {
"message": "The flashlight cannot be activated while charging."
},
"invalid_auth": {
"message": "Your authentication with the Fressnapf Tracker API expired. Please re-authenticate to refresh your credentials."
},
"low_battery": {
"message": "The flashlight cannot be activated due to low battery."
},

View File

@@ -75,7 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
def fetch_and_inject_nvr_events() -> None:
"""Fetch and inject NVR events in a single executor job."""
if nvr_events := camera.get_event_triggers(None):
if nvr_events := camera.get_event_triggers():
camera.inject_events(nvr_events)
await hass.async_add_executor_job(fetch_and_inject_nvr_events)

View File

@@ -245,6 +245,7 @@ SENSOR_PROCESS_DATA = [
name="Battery SoC",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
formatter="format_round",
),
PlenticoreSensorEntityDescription(

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/microbees",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["microBeesPy==0.3.5"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@engrbm87"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/mikrotik",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["librouteros"],
"requirements": ["librouteros==3.2.0"]

View File

@@ -11,6 +11,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/moat",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["moat-ble==0.1.1"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@j-a-n"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/moehlenhoff_alpha2",
"integration_type": "hub",
"iot_class": "local_push",
"requirements": ["moehlenhoff-alpha2==1.4.0"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@jeeftor"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/monarchmoney",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["typedmonarchmoney==0.4.4"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@etsinko", "@OnFreund"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/monoprice",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pymonoprice"],
"requirements": ["pymonoprice==0.5"]

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/monzo",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["monzopy==1.5.1"]
}

View File

@@ -6,6 +6,7 @@
"config_flow": true,
"dependencies": ["http", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/motioneye",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["motioneye_client"],
"requirements": ["motioneye-client==0.3.14"]

View File

@@ -4,7 +4,11 @@ from __future__ import annotations
from pynuki.device import NukiDevice
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
@@ -34,6 +38,7 @@ class NukiBatterySensor(NukiEntity[NukiDevice], SensorEntity):
_attr_has_entity_name = True
_attr_native_unit_of_measurement = PERCENTAGE
_attr_device_class = SensorDeviceClass.BATTERY
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_entity_category = EntityCategory.DIAGNOSTIC
@property

View File

@@ -78,6 +78,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
show_background=entry.options.get(CONF_SHOW_BACKGROUND, False),
map_scale=MAP_SCALE,
),
mqtt_session_unauthorized_hook=lambda: entry.async_start_reauth(hass),
)
except RoborockInvalidCredentials as err:
raise ConfigEntryAuthFailed(

View File

@@ -15,6 +15,7 @@ from soco.exceptions import SoCoException, SoCoUPnPException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import SONOS_SPEAKER_ACTIVITY
@@ -135,6 +136,7 @@ class UnjoinData:
speakers: list[SonosSpeaker] = field(default_factory=list)
event: asyncio.Event = field(default_factory=asyncio.Event)
exception: HomeAssistantError | OSError | SoCoException | None = None
@dataclass

View File

@@ -15,6 +15,7 @@ from soco.core import (
PLAY_MODES,
)
from soco.data_structures import DidlFavorite, DidlMusicTrack
from soco.exceptions import SoCoException
from soco.ms_data_structures import MusicServiceItem
from sonos_websocket.exception import SonosWebsocketError
@@ -853,10 +854,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
_LOGGER.debug(
"Processing unjoins for %s", [x.zone_name for x in unjoin_data.speakers]
)
await SonosSpeaker.unjoin_multi(
self.hass, self.config_entry, unjoin_data.speakers
)
unjoin_data.event.set()
try:
await SonosSpeaker.unjoin_multi(
self.hass, self.config_entry, unjoin_data.speakers
)
except (HomeAssistantError, SoCoException, OSError) as err:
unjoin_data.exception = err
finally:
unjoin_data.event.set()
if unjoin_data := sonos_data.unjoin_data.get(household_id):
unjoin_data.speakers.append(self.speaker)
@@ -868,3 +873,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
_LOGGER.debug("Requesting unjoin for %s", self.speaker.zone_name)
await unjoin_data.event.wait()
# Re-raise any exception that occurred during processing
if unjoin_data.exception:
raise unjoin_data.exception

View File

@@ -1060,7 +1060,10 @@ class SonosSpeaker:
async with config_entry.runtime_data.topology_condition:
await hass.async_add_executor_job(_unjoin_all, speakers)
await SonosSpeaker.wait_for_groups(
hass, config_entry, [[s] for s in speakers]
hass,
config_entry,
[[s] for s in speakers],
action="unjoin",
)
@soco_error()
@@ -1204,6 +1207,7 @@ class SonosSpeaker:
hass: HomeAssistant,
config_entry: SonosConfigEntry,
groups: list[list[SonosSpeaker]],
action: str = "join",
) -> None:
"""Wait until all groups are present, or timeout."""
@@ -1228,14 +1232,17 @@ class SonosSpeaker:
while not _test_groups(groups):
await config_entry.runtime_data.topology_condition.wait()
except TimeoutError:
group_description = [
group_description = "; ".join(
f"{group[0].zone_name}: {', '.join(speaker.zone_name for speaker in group)}"
for group in groups
]
)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_join",
translation_placeholders={"group_description": str(group_description)},
translation_placeholders={
"group_description": group_description,
"action": action,
},
) from TimeoutError
any_speaker = next(iter(config_entry.runtime_data.discovered.values()))
any_speaker.soco.zone_group_state.clear_cache()

View File

@@ -125,7 +125,7 @@
"message": "{entity_id} is not a known Sonos speaker."
},
"timeout_join": {
"message": "Timeout while waiting for Sonos player to join the group {group_description}"
"message": "Timeout while waiting for Sonos player to {action} the group {group_description}"
}
},
"issues": {

View File

@@ -19,7 +19,6 @@ PLATFORMS = [
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
TASMOTA_EVENT = "tasmota_event"

View File

@@ -1,38 +0,0 @@
"""Data update coordinators for Tasmota."""
from datetime import timedelta
import logging
from aiogithubapi import GitHubAPI, GitHubRatelimitException, GitHubReleaseModel
from aiogithubapi.client import GitHubConnectionException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
class TasmotaLatestReleaseUpdateCoordinator(DataUpdateCoordinator[GitHubReleaseModel]):
"""Data update coordinator for Tasmota latest release info."""
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the coordinator."""
self.client = GitHubAPI(session=async_get_clientsession(hass))
super().__init__(
hass,
logger=logging.getLogger(__name__),
config_entry=config_entry,
name="Tasmota latest release",
update_interval=timedelta(days=1),
)
async def _async_update_data(self) -> GitHubReleaseModel:
"""Get new data."""
try:
response = await self.client.repos.releases.latest("arendst/Tasmota")
if response.data is None:
raise UpdateFailed("No data received")
except (GitHubConnectionException, GitHubRatelimitException) as ex:
raise UpdateFailed(ex) from ex
else:
return response.data

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["hatasmota"],
"mqtt": ["tasmota/discovery/#"],
"requirements": ["HATasmota==0.10.1", "aiogithubapi==24.6.0"]
"requirements": ["HATasmota==0.10.1"]
}

View File

@@ -1,79 +0,0 @@
"""Update entity for Tasmota."""
import re
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TasmotaLatestReleaseUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tasmota update entities."""
coordinator = TasmotaLatestReleaseUpdateCoordinator(hass, config_entry)
await coordinator.async_config_entry_first_refresh()
device_registry = dr.async_get(hass)
devices = device_registry.devices.get_devices_for_config_entry_id(
config_entry.entry_id
)
async_add_entities(TasmotaUpdateEntity(coordinator, device) for device in devices)
class TasmotaUpdateEntity(UpdateEntity):
"""Representation of a Tasmota update entity."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_name = "Firmware"
_attr_title = "Tasmota firmware"
_attr_supported_features = UpdateEntityFeature.RELEASE_NOTES
def __init__(
self,
coordinator: TasmotaLatestReleaseUpdateCoordinator,
device_entry: DeviceEntry,
) -> None:
"""Initialize the Tasmota update entity."""
self.coordinator = coordinator
self.device_entry = device_entry
self._attr_unique_id = f"{device_entry.id}_update"
@property
def installed_version(self) -> str | None:
"""Return the installed version."""
return self.device_entry.sw_version # type:ignore[union-attr]
@property
def latest_version(self) -> str:
"""Return the latest version."""
return self.coordinator.data.tag_name.removeprefix("v")
@property
def release_url(self) -> str:
"""Return the release URL."""
return self.coordinator.data.html_url
@property
def release_summary(self) -> str:
"""Return the release summary."""
return self.coordinator.data.name
def release_notes(self) -> str | None:
"""Return the release notes."""
if not self.coordinator.data.body:
return None
return re.sub(
r"^<picture>.*?</picture>", "", self.coordinator.data.body, flags=re.DOTALL
)

View File

@@ -4,6 +4,7 @@
"codeowners": ["@rytilahti", "@syssi", "@starkillerOG"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/xiaomi_miio",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["micloud", "miio"],
"requirements": ["construct==2.10.68", "micloud==0.5", "python-miio==0.5.12"],

View File

@@ -4075,7 +4075,7 @@
},
"mikrotik": {
"name": "Mikrotik",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -4110,7 +4110,7 @@
},
"moat": {
"name": "Moat",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
@@ -4150,7 +4150,7 @@
},
"monarch_money": {
"name": "Monarch Money",
"integration_type": "hub",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
@@ -4167,7 +4167,7 @@
},
"monzo": {
"name": "Monzo",
"integration_type": "hub",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},

1
requirements_all.txt generated
View File

@@ -265,7 +265,6 @@ aioflo==2021.11.0
aioftp==0.21.3
# homeassistant.components.github
# homeassistant.components.tasmota
aiogithubapi==24.6.0
# homeassistant.components.guardian

View File

@@ -253,7 +253,6 @@ aiofiles==24.1.0
aioflo==2021.11.0
# homeassistant.components.github
# homeassistant.components.tasmota
aiogithubapi==24.6.0
# homeassistant.components.guardian

View File

@@ -1,5 +1,6 @@
"""Test the Fressnapf Tracker config flow."""
from collections.abc import Callable
from unittest.mock import AsyncMock, MagicMock
from fressnapftracker import (
@@ -198,21 +199,41 @@ async def test_user_flow_duplicate_phone_number(
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_api_client")
@pytest.mark.usefixtures("mock_auth_client")
async def test_reconfigure_flow(
@pytest.mark.parametrize(
("flow_starter", "expected_step_id", "expected_sms_step_id", "expected_reason"),
[
(
lambda entry, hass: entry.start_reauth_flow(hass),
"reauth_confirm",
"reauth_sms_code",
"reauth_successful",
),
(
lambda entry, hass: entry.start_reconfigure_flow(hass),
"reconfigure",
"reconfigure_sms_code",
"reconfigure_successful",
),
],
)
@pytest.mark.usefixtures("mock_api_client", "mock_auth_client")
async def test_reauth_reconfigure_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
flow_starter: Callable,
expected_step_id: str,
expected_sms_step_id: str,
expected_reason: str,
) -> None:
"""Test the reconfigure flow."""
"""Test the reauth and reconfigure flows."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await mock_config_entry.start_reconfigure_flow(hass)
result = await flow_starter(mock_config_entry, hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["step_id"] == expected_step_id
# Submit phone number
result = await hass.config_entries.flow.async_configure(
@@ -220,7 +241,7 @@ async def test_reconfigure_flow(
{CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_sms_code"
assert result["step_id"] == expected_sms_step_id
# Submit SMS code
result = await hass.config_entries.flow.async_configure(
@@ -229,21 +250,42 @@ async def test_reconfigure_flow(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert result["reason"] == expected_reason
@pytest.mark.parametrize(
("flow_starter", "expected_step_id", "expected_sms_step_id", "expected_reason"),
[
(
lambda entry, hass: entry.start_reauth_flow(hass),
"reauth_confirm",
"reauth_sms_code",
"reauth_successful",
),
(
lambda entry, hass: entry.start_reconfigure_flow(hass),
"reconfigure",
"reconfigure_sms_code",
"reconfigure_successful",
),
],
)
@pytest.mark.usefixtures("mock_api_client")
async def test_reconfigure_flow_invalid_phone_number(
async def test_reauth_reconfigure_flow_invalid_phone_number(
hass: HomeAssistant,
mock_auth_client: MagicMock,
mock_config_entry: MockConfigEntry,
flow_starter: Callable,
expected_step_id: str,
expected_sms_step_id: str,
expected_reason: str,
) -> None:
"""Test reconfigure flow with invalid phone number."""
"""Test reauth and reconfigure flows with invalid phone number."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await mock_config_entry.start_reconfigure_flow(hass)
result = await flow_starter(mock_config_entry, hass)
mock_auth_client.request_sms_code.side_effect = (
FressnapfTrackerInvalidPhoneNumberError
@@ -255,7 +297,7 @@ async def test_reconfigure_flow_invalid_phone_number(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["step_id"] == expected_step_id
assert result["errors"] == {"base": "invalid_phone_number"}
# Recover from error
@@ -265,7 +307,7 @@ async def test_reconfigure_flow_invalid_phone_number(
{CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_sms_code"
assert result["step_id"] == expected_sms_step_id
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -273,21 +315,39 @@ async def test_reconfigure_flow_invalid_phone_number(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert result["reason"] == expected_reason
@pytest.mark.parametrize(
("flow_starter", "expected_sms_step_id", "expected_reason"),
[
(
lambda entry, hass: entry.start_reauth_flow(hass),
"reauth_sms_code",
"reauth_successful",
),
(
lambda entry, hass: entry.start_reconfigure_flow(hass),
"reconfigure_sms_code",
"reconfigure_successful",
),
],
)
@pytest.mark.usefixtures("mock_api_client")
async def test_reconfigure_flow_invalid_sms_code(
async def test_reauth_reconfigure_flow_invalid_sms_code(
hass: HomeAssistant,
mock_auth_client: MagicMock,
mock_config_entry: MockConfigEntry,
flow_starter: Callable,
expected_sms_step_id: str,
expected_reason: str,
) -> None:
"""Test reconfigure flow with invalid SMS code."""
"""Test reauth and reconfigure flows with invalid SMS code."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await mock_config_entry.start_reconfigure_flow(hass)
result = await flow_starter(mock_config_entry, hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -302,7 +362,7 @@ async def test_reconfigure_flow_invalid_sms_code(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_sms_code"
assert result["step_id"] == expected_sms_step_id
assert result["errors"] == {"base": "invalid_sms_code"}
# Recover from error
@@ -313,21 +373,42 @@ async def test_reconfigure_flow_invalid_sms_code(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert result["reason"] == expected_reason
@pytest.mark.parametrize(
("flow_starter", "expected_step_id", "expected_sms_step_id", "expected_reason"),
[
(
lambda entry, hass: entry.start_reauth_flow(hass),
"reauth_confirm",
"reauth_sms_code",
"reauth_successful",
),
(
lambda entry, hass: entry.start_reconfigure_flow(hass),
"reconfigure",
"reconfigure_sms_code",
"reconfigure_successful",
),
],
)
@pytest.mark.usefixtures("mock_api_client")
async def test_reconfigure_flow_invalid_user_id(
async def test_reauth_reconfigure_flow_invalid_user_id(
hass: HomeAssistant,
mock_auth_client: MagicMock,
mock_config_entry: MockConfigEntry,
flow_starter: Callable,
expected_step_id: str,
expected_sms_step_id: str,
expected_reason: str,
) -> None:
"""Test reconfigure flow does not allow to reconfigure to another account."""
"""Test reauth and reconfigure flows do not allow changing to another account."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await mock_config_entry.start_reconfigure_flow(hass)
result = await flow_starter(mock_config_entry, hass)
mock_auth_client.request_sms_code = AsyncMock(
return_value=SmsCodeResponse(id=MOCK_USER_ID + 1)
@@ -339,7 +420,7 @@ async def test_reconfigure_flow_invalid_user_id(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["step_id"] == expected_step_id
assert result["errors"] == {"base": "account_change_not_allowed"}
# Recover from error
@@ -351,7 +432,7 @@ async def test_reconfigure_flow_invalid_user_id(
{CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_sms_code"
assert result["step_id"] == expected_sms_step_id
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -359,4 +440,4 @@ async def test_reconfigure_flow_invalid_user_id(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert result["reason"] == expected_reason

View File

@@ -4,7 +4,9 @@
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -39,6 +41,7 @@
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Home Battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,

View File

@@ -92,6 +92,44 @@ async def test_reauth_started(
assert flows[0]["step_id"] == "reauth_confirm"
async def test_mqtt_session_unauthorized_hook_called(
hass: HomeAssistant,
mock_roborock_entry: MockConfigEntry,
device_manager: AsyncMock,
) -> None:
"""Test that the mqtt session unauthorized hook is called on unauthorized event."""
device_manager_kwargs = {}
def create_device_manager(*args: Any, **kwargs: Any) -> AsyncMock:
nonlocal device_manager_kwargs
device_manager_kwargs = kwargs
return device_manager
with patch(
"homeassistant.components.roborock.create_device_manager",
side_effect=create_device_manager,
):
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
await hass.async_block_till_done()
assert mock_roborock_entry.state is ConfigEntryState.LOADED
flows = hass.config_entries.flow.async_progress()
assert not flows
# Simulate an unauthorized event by calling the captured hook
assert device_manager_kwargs
mqtt_session_unauthorized_hook = device_manager_kwargs.get(
"mqtt_session_unauthorized_hook"
)
assert mqtt_session_unauthorized_hook
mqtt_session_unauthorized_hook()
# Verify that reauth flow is started
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["step_id"] == "reauth_confirm"
@pytest.mark.parametrize("platforms", [[Platform.IMAGE]])
@pytest.mark.parametrize(
("exists", "is_dir", "rmtree_called"),

View File

@@ -13,6 +13,7 @@ from homeassistant.components.media_player import (
SERVICE_JOIN,
SERVICE_UNJOIN,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
@@ -131,7 +132,7 @@ async def test_media_player_join_timeout(
expected = (
"Timeout while waiting for Sonos player to join the "
"group ['Living Room: Living Room, Bedroom']"
"group Living Room: Living Room, Bedroom"
)
with (
patch(
@@ -153,6 +154,37 @@ async def test_media_player_join_timeout(
assert soco_living_room.join.call_count == 0
async def test_media_player_unjoin_timeout(
hass: HomeAssistant,
sonos_setup_two_speakers: list[MockSoCo],
) -> None:
"""Test unjoining of speaker with timeout error."""
soco_living_room = sonos_setup_two_speakers[0]
soco_bedroom = sonos_setup_two_speakers[1]
# First group the speakers together
group_speakers(soco_living_room, soco_bedroom)
await hass.async_block_till_done(wait_background_tasks=True)
expected = (
"Timeout while waiting for Sonos player to unjoin the group Bedroom: Bedroom"
)
with (
patch(
"homeassistant.components.sonos.speaker.asyncio.timeout", instant_timeout
),
pytest.raises(HomeAssistantError, match=re.escape(expected)),
):
await hass.services.async_call(
MP_DOMAIN,
SERVICE_UNJOIN,
{ATTR_ENTITY_ID: "media_player.bedroom"},
blocking=True,
)
assert soco_bedroom.unjoin.call_count == 1
async def test_media_player_unjoin(
hass: HomeAssistant,
sonos_setup_two_speakers: list[MockSoCo],