mirror of
https://github.com/home-assistant/core.git
synced 2025-12-27 02:08:31 +00:00
Compare commits
16 Commits
scop-tasmo
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf9444dc64 | ||
|
|
a447217b03 | ||
|
|
e639ebc269 | ||
|
|
45ba7e0df1 | ||
|
|
dfdcdbc856 | ||
|
|
ea5df92ab9 | ||
|
|
9d1f500d65 | ||
|
|
a82f500934 | ||
|
|
71d29ba28e | ||
|
|
7910f33140 | ||
|
|
91ebeb84e7 | ||
|
|
1664dd5702 | ||
|
|
5e96ec820f | ||
|
|
5eedef4920 | ||
|
|
71728ba37e | ||
|
|
5657bd11b8 |
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -19,7 +19,6 @@ PLATFORMS = [
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
TASMOTA_EVENT = "tasmota_event"
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
1
requirements_all.txt
generated
@@ -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
|
||||
|
||||
1
requirements_test_all.txt
generated
1
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user