Merge pull request #52936 from home-assistant/rc

This commit is contained in:
Paulus Schoutsen 2021-07-12 14:38:48 -07:00 committed by GitHub
commit f4359f98b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 491 additions and 175 deletions

View File

@ -687,6 +687,7 @@ omit =
homeassistant/components/netgear_lte/*
homeassistant/components/netio/switch.py
homeassistant/components/neurio_energy/sensor.py
homeassistant/components/nexia/climate.py
homeassistant/components/nextcloud/*
homeassistant/components/nfandroidtv/notify.py
homeassistant/components/niko_home_control/light.py

View File

@ -3,7 +3,7 @@
"name": "Arcam FMJ Receivers",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/arcam_fmj",
"requirements": ["arcam-fmj==0.5.3"],
"requirements": ["arcam-fmj==0.7.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@ -214,7 +214,6 @@ CC_SENSOR_TYPES = [
ATTR_FIELD: CC_ATTR_CLOUD_COVER,
ATTR_NAME: "Cloud Cover",
CONF_UNIT_OF_MEASUREMENT: PERCENTAGE,
ATTR_SCALE: 1 / 100,
},
{
ATTR_FIELD: CC_ATTR_WIND_GUST,

View File

@ -207,8 +207,6 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity):
distance_convert(self.wind_gust, LENGTH_MILES, LENGTH_KILOMETERS), 4
)
cloud_cover = self.cloud_cover
if cloud_cover is not None:
cloud_cover /= 100
return {
ATTR_CLOUD_COVER: cloud_cover,
ATTR_WIND_GUST: wind_gust,

View File

@ -26,6 +26,7 @@ from homeassistant.components.light import (
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util.color import color_hs_to_xy
from .const import (
COVER_TYPES,
@ -189,8 +190,11 @@ class DeconzBaseLight(DeconzDevice, LightEntity):
data["ct"] = kwargs[ATTR_COLOR_TEMP]
if ATTR_HS_COLOR in kwargs:
data["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535)
data["sat"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255)
if COLOR_MODE_XY in self._attr_supported_color_modes:
data["xy"] = color_hs_to_xy(*kwargs[ATTR_HS_COLOR])
else:
data["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535)
data["sat"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255)
if ATTR_XY_COLOR in kwargs:
data["xy"] = kwargs[ATTR_XY_COLOR]

View File

@ -3,13 +3,17 @@
"name": "deCONZ",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/deconz",
"requirements": ["pydeconz==80"],
"requirements": [
"pydeconz==81"
],
"ssdp": [
{
"manufacturer": "Royal Philips Electronics"
}
],
"codeowners": ["@Kane610"],
"codeowners": [
"@Kane610"
],
"quality_scale": "platinum",
"iot_class": "local_push"
}
}

View File

@ -3,7 +3,7 @@
"name": "ESPHome",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/esphome",
"requirements": ["aioesphomeapi==5.0.0"],
"requirements": ["aioesphomeapi==5.0.1"],
"zeroconf": ["_esphomelib._tcp.local."],
"codeowners": ["@OttoWinter", "@jesserockz"],
"after_dependencies": ["zeroconf", "tag"],

View File

@ -3,7 +3,7 @@
"name": "FireServiceRota",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fireservicerota",
"requirements": ["pyfireservicerota==0.0.40"],
"requirements": ["pyfireservicerota==0.0.42"],
"codeowners": ["@cyberjunky"],
"iot_class": "cloud_polling"
}

View File

@ -3,7 +3,7 @@
"name": "HomeKit Controller",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"requirements": ["aiohomekit==0.4.2"],
"requirements": ["aiohomekit==0.5.1"],
"zeroconf": ["_hap._tcp.local."],
"after_dependencies": ["zeroconf"],
"codeowners": ["@Jc2k", "@bdraco"],

View File

@ -44,7 +44,8 @@ SIMPLE_SENSOR = {
"unit": TEMP_CELSIUS,
# This sensor is only for temperature characteristics that are not part
# of a temperature sensor service.
"probe": lambda char: char.service.type != ServicesTypes.TEMPERATURE_SENSOR,
"probe": lambda char: char.service.type
!= ServicesTypes.get_uuid(ServicesTypes.TEMPERATURE_SENSOR),
},
}

View File

@ -2,8 +2,12 @@
"domain": "insteon",
"name": "Insteon",
"documentation": "https://www.home-assistant.io/integrations/insteon",
"requirements": ["pyinsteon==1.0.9"],
"codeowners": ["@teharris1"],
"requirements": [
"pyinsteon==1.0.11"
],
"codeowners": [
"@teharris1"
],
"config_flow": true,
"iot_class": "local_push"
}
}

View File

@ -50,7 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
region = entry.data[CONF_REGION]
websession = aiohttp_client.async_get_clientsession(hass)
mazda_client = MazdaAPI(email, password, region, websession)
mazda_client = MazdaAPI(
email, password, region, websession=websession, use_cached_vehicle_list=True
)
try:
await mazda_client.validate_credentials()
@ -166,7 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER,
name=DOMAIN,
update_method=async_update_data,
update_interval=timedelta(seconds=60),
update_interval=timedelta(seconds=180),
)
hass.data.setdefault(DOMAIN, {})

View File

@ -3,7 +3,7 @@
"name": "Mazda Connected Services",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/mazda",
"requirements": ["pymazda==0.1.6"],
"requirements": ["pymazda==0.2.0"],
"codeowners": ["@bdr99"],
"quality_scale": "platinum",
"iot_class": "cloud_polling"

View File

@ -77,9 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
neato_session = api.ConfigEntryAuth(hass, entry, session)
neato_session = api.ConfigEntryAuth(hass, entry, implementation)
hass.data[NEATO_DOMAIN][entry.entry_id] = neato_session
hub = NeatoHub(hass, Account(neato_session))

View File

@ -203,7 +203,10 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity):
def set_humidity(self, humidity):
"""Dehumidify target."""
self._thermostat.set_dehumidify_setpoint(humidity / 100.0)
if self._thermostat.has_dehumidify_support():
self._thermostat.set_dehumidify_setpoint(humidity / 100.0)
else:
self._thermostat.set_humidify_setpoint(humidity / 100.0)
self._signal_thermostat_update()
@property

View File

@ -1,7 +1,7 @@
"""Config flow for Nexia integration."""
import logging
from nexia.const import BRAND_ASAIR, BRAND_NEXIA
from nexia.const import BRAND_ASAIR, BRAND_NEXIA, BRAND_TRANE
from nexia.home import NexiaHome
from requests.exceptions import ConnectTimeout, HTTPError
import voluptuous as vol
@ -9,7 +9,13 @@ import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import BRAND_ASAIR_NAME, BRAND_NEXIA_NAME, CONF_BRAND, DOMAIN
from .const import (
BRAND_ASAIR_NAME,
BRAND_NEXIA_NAME,
BRAND_TRANE_NAME,
CONF_BRAND,
DOMAIN,
)
from .util import is_invalid_auth_code
_LOGGER = logging.getLogger(__name__)
@ -19,7 +25,11 @@ DATA_SCHEMA = vol.Schema(
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_BRAND, default=BRAND_NEXIA): vol.In(
{BRAND_NEXIA: BRAND_NEXIA_NAME, BRAND_ASAIR: BRAND_ASAIR_NAME}
{
BRAND_NEXIA: BRAND_NEXIA_NAME,
BRAND_ASAIR: BRAND_ASAIR_NAME,
BRAND_TRANE: BRAND_TRANE_NAME,
}
),
}
)
@ -31,7 +41,9 @@ async def validate_input(hass: core.HomeAssistant, data):
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
state_file = hass.config.path(f"nexia_config_{data[CONF_USERNAME]}.conf")
state_file = hass.config.path(
f"{data[CONF_BRAND]}_config_{data[CONF_USERNAME]}.conf"
)
try:
nexia_home = NexiaHome(
username=data[CONF_USERNAME],

View File

@ -33,4 +33,5 @@ SIGNAL_ZONE_UPDATE = "NEXIA_CLIMATE_ZONE_UPDATE"
SIGNAL_THERMOSTAT_UPDATE = "NEXIA_CLIMATE_THERMOSTAT_UPDATE"
BRAND_NEXIA_NAME = "Nexia"
BRAND_ASAIR_NAME = "American Standard"
BRAND_ASAIR_NAME = "American Standard Home"
BRAND_TRANE_NAME = "Trane Home"

View File

@ -1,7 +1,7 @@
{
"domain": "nexia",
"name": "Nexia/American Standard",
"requirements": ["nexia==0.9.7"],
"name": "Nexia/American Standard/Trane",
"requirements": ["nexia==0.9.9"],
"codeowners": ["@bdraco"],
"documentation": "https://www.home-assistant.io/integrations/nexia",
"config_flow": true,

View File

@ -7,4 +7,9 @@ DOMAIN = "recorder"
CONF_DB_INTEGRITY_CHECK = "db_integrity_check"
# The maximum number of rows (events) we purge in one delete statement
MAX_ROWS_TO_PURGE = 1000
# sqlite3 has a limit of 999 until version 3.32.0
# in https://github.com/sqlite/sqlite/commit/efdba1a8b3c6c967e7fae9c1989c40d420ce64cc
# We can increase this back to 1000 once most
# have upgraded their sqlite version
MAX_ROWS_TO_PURGE = 998

View File

@ -31,6 +31,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_s
from .alarms import SonosAlarms
from .const import (
DATA_SONOS,
DATA_SONOS_DISCOVERY_MANAGER,
DISCOVERY_INTERVAL,
DOMAIN,
PLATFORMS,
@ -46,6 +47,7 @@ _LOGGER = logging.getLogger(__name__)
CONF_ADVERTISE_ADDR = "advertise_addr"
CONF_INTERFACE_ADDR = "interface_addr"
DISCOVERY_IGNORED_MODELS = ["Sonos Boost"]
CONFIG_SCHEMA = vol.Schema(
@ -90,7 +92,7 @@ class SonosData:
self.alarms: dict[str, SonosAlarms] = {}
self.topology_condition = asyncio.Condition()
self.hosts_heartbeat = None
self.ssdp_known: set[str] = set()
self.discovery_known: set[str] = set()
self.boot_counts: dict[str, int] = {}
@ -110,9 +112,7 @@ async def async_setup(hass, config):
return True
async def async_setup_entry( # noqa: C901
hass: HomeAssistant, entry: ConfigEntry
) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Sonos from a config entry."""
pysonos.config.EVENTS_MODULE = events_asyncio
@ -122,7 +122,6 @@ async def async_setup_entry( # noqa: C901
data = hass.data[DATA_SONOS]
config = hass.data[DOMAIN].get("media_player", {})
hosts = config.get(CONF_HOSTS, [])
discovery_lock = asyncio.Lock()
_LOGGER.debug("Reached async_setup_entry, config=%s", config)
advertise_addr = config.get(CONF_ADVERTISE_ADDR)
@ -136,150 +135,181 @@ async def async_setup_entry( # noqa: C901
deprecated_address,
)
async def _async_stop_event_listener(event: Event) -> None:
manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = SonosDiscoveryManager(
hass, entry, data, hosts
)
hass.async_create_task(manager.setup_platforms_and_discovery())
return True
def _create_soco(ip_address: str, source: SoCoCreationSource) -> SoCo | None:
"""Create a soco instance and return if successful."""
try:
soco = pysonos.SoCo(ip_address)
# Ensure that the player is available and UID is cached
_ = soco.uid
_ = soco.volume
return soco
except (OSError, SoCoException) as ex:
_LOGGER.warning(
"Failed to connect to %s player '%s': %s", source.value, ip_address, ex
)
return None
class SonosDiscoveryManager:
"""Manage sonos discovery."""
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, data: SonosData, hosts: list[str]
) -> None:
"""Init discovery manager."""
self.hass = hass
self.entry = entry
self.data = data
self.hosts = hosts
self.discovery_lock = asyncio.Lock()
async def _async_stop_event_listener(self, event: Event) -> None:
await asyncio.gather(
*[speaker.async_unsubscribe() for speaker in data.discovered.values()],
*[speaker.async_unsubscribe() for speaker in self.data.discovered.values()],
return_exceptions=True,
)
if events_asyncio.event_listener:
await events_asyncio.event_listener.async_stop()
def _stop_manual_heartbeat(event: Event) -> None:
if data.hosts_heartbeat:
data.hosts_heartbeat()
data.hosts_heartbeat = None
def _stop_manual_heartbeat(self, event: Event) -> None:
if self.data.hosts_heartbeat:
self.data.hosts_heartbeat()
self.data.hosts_heartbeat = None
def _discovered_player(soco: SoCo) -> None:
def _discovered_player(self, soco: SoCo) -> None:
"""Handle a (re)discovered player."""
try:
speaker_info = soco.get_speaker_info(True)
_LOGGER.debug("Adding new speaker: %s", speaker_info)
speaker = SonosSpeaker(hass, soco, speaker_info)
data.discovered[soco.uid] = speaker
speaker = SonosSpeaker(self.hass, soco, speaker_info)
self.data.discovered[soco.uid] = speaker
for coordinator, coord_dict in [
(SonosAlarms, data.alarms),
(SonosFavorites, data.favorites),
(SonosAlarms, self.data.alarms),
(SonosFavorites, self.data.favorites),
]:
if soco.household_id not in coord_dict:
new_coordinator = coordinator(hass, soco.household_id)
new_coordinator = coordinator(self.hass, soco.household_id)
new_coordinator.setup(soco)
coord_dict[soco.household_id] = new_coordinator
speaker.setup()
except (OSError, SoCoException):
_LOGGER.warning("Failed to add SonosSpeaker using %s", soco, exc_info=True)
def _create_soco(ip_address: str, source: SoCoCreationSource) -> SoCo | None:
"""Create a soco instance and return if successful."""
try:
soco = pysonos.SoCo(ip_address)
# Ensure that the player is available and UID is cached
_ = soco.uid
_ = soco.volume
return soco
except (OSError, SoCoException) as ex:
_LOGGER.warning(
"Failed to connect to %s player '%s': %s", source.value, ip_address, ex
)
return None
def _manual_hosts(now: datetime.datetime | None = None) -> None:
def _manual_hosts(self, now: datetime.datetime | None = None) -> None:
"""Players from network configuration."""
for host in hosts:
for host in self.hosts:
ip_addr = socket.gethostbyname(host)
known_uid = next(
(
uid
for uid, speaker in data.discovered.items()
for uid, speaker in self.data.discovered.items()
if speaker.soco.ip_address == ip_addr
),
None,
)
if known_uid:
dispatcher_send(hass, f"{SONOS_SEEN}-{known_uid}")
dispatcher_send(self.hass, f"{SONOS_SEEN}-{known_uid}")
else:
soco = _create_soco(ip_addr, SoCoCreationSource.CONFIGURED)
if soco and soco.is_visible:
_discovered_player(soco)
self._discovered_player(soco)
data.hosts_heartbeat = hass.helpers.event.call_later(
DISCOVERY_INTERVAL.total_seconds(), _manual_hosts
self.data.hosts_heartbeat = self.hass.helpers.event.call_later(
DISCOVERY_INTERVAL.total_seconds(), self._manual_hosts
)
@callback
def _async_signal_update_groups(event):
async_dispatcher_send(hass, SONOS_GROUP_UPDATE)
def _async_signal_update_groups(self, _event):
async_dispatcher_send(self.hass, SONOS_GROUP_UPDATE)
def _discovered_ip(ip_address):
def _discovered_ip(self, ip_address):
soco = _create_soco(ip_address, SoCoCreationSource.DISCOVERED)
if soco and soco.is_visible:
_discovered_player(soco)
self._discovered_player(soco)
async def _async_create_discovered_player(uid, discovered_ip, boot_seqnum):
async def _async_create_discovered_player(self, uid, discovered_ip, boot_seqnum):
"""Only create one player at a time."""
async with discovery_lock:
if uid not in data.discovered:
await hass.async_add_executor_job(_discovered_ip, discovered_ip)
async with self.discovery_lock:
if uid not in self.data.discovered:
await self.hass.async_add_executor_job(
self._discovered_ip, discovered_ip
)
return
if boot_seqnum and boot_seqnum > data.boot_counts[uid]:
data.boot_counts[uid] = boot_seqnum
if soco := await hass.async_add_executor_job(
if boot_seqnum and boot_seqnum > self.data.boot_counts[uid]:
self.data.boot_counts[uid] = boot_seqnum
if soco := await self.hass.async_add_executor_job(
_create_soco, discovered_ip, SoCoCreationSource.REBOOTED
):
async_dispatcher_send(hass, f"{SONOS_REBOOTED}-{uid}", soco)
async_dispatcher_send(self.hass, f"{SONOS_REBOOTED}-{uid}", soco)
else:
async_dispatcher_send(hass, f"{SONOS_SEEN}-{uid}")
async_dispatcher_send(self.hass, f"{SONOS_SEEN}-{uid}")
@callback
def _async_discovered_player(info):
def _async_ssdp_discovered_player(self, info):
discovered_ip = urlparse(info[ssdp.ATTR_SSDP_LOCATION]).hostname
boot_seqnum = info.get("X-RINCON-BOOTSEQ")
uid = info.get(ssdp.ATTR_UPNP_UDN)
if uid.startswith("uuid:"):
uid = uid[5:]
if boot_seqnum := info.get("X-RINCON-BOOTSEQ"):
boot_seqnum = int(boot_seqnum)
data.boot_counts.setdefault(uid, boot_seqnum)
if uid not in data.ssdp_known:
_LOGGER.debug("New discovery: %s", info)
data.ssdp_known.add(uid)
discovered_ip = urlparse(info[ssdp.ATTR_SSDP_LOCATION]).hostname
asyncio.create_task(
_async_create_discovered_player(uid, discovered_ip, boot_seqnum)
self.async_discovered_player(
info, discovered_ip, uid, boot_seqnum, info.get("modelName")
)
async def setup_platforms_and_discovery():
@callback
def async_discovered_player(self, info, discovered_ip, uid, boot_seqnum, model):
"""Handle discovery via ssdp or zeroconf."""
if model in DISCOVERY_IGNORED_MODELS:
_LOGGER.debug("Ignoring device: %s", info)
return
if boot_seqnum:
boot_seqnum = int(boot_seqnum)
self.data.boot_counts.setdefault(uid, boot_seqnum)
if uid not in self.data.discovery_known:
_LOGGER.debug("New discovery uid=%s: %s", uid, info)
self.data.discovery_known.add(uid)
asyncio.create_task(
self._async_create_discovered_player(uid, discovered_ip, boot_seqnum)
)
async def setup_platforms_and_discovery(self):
"""Set up platforms and discovery."""
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_setup(entry, platform)
self.hass.config_entries.async_forward_entry_setup(self.entry, platform)
for platform in PLATFORMS
]
)
entry.async_on_unload(
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, _async_signal_update_groups
self.entry.async_on_unload(
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, self._async_signal_update_groups
)
)
entry.async_on_unload(
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, _async_stop_event_listener
self.entry.async_on_unload(
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, self._async_stop_event_listener
)
)
_LOGGER.debug("Adding discovery job")
if hosts:
entry.async_on_unload(
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, _stop_manual_heartbeat
if self.hosts:
self.entry.async_on_unload(
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, self._stop_manual_heartbeat
)
)
await hass.async_add_executor_job(_manual_hosts)
await self.hass.async_add_executor_job(self._manual_hosts)
return
entry.async_on_unload(
self.entry.async_on_unload(
ssdp.async_register_callback(
hass, _async_discovered_player, {"st": UPNP_ST}
self.hass, self._async_ssdp_discovered_player, {"st": UPNP_ST}
)
)
hass.async_create_task(setup_platforms_and_discovery())
return True

View File

@ -1,10 +1,19 @@
"""Config flow for SONOS."""
import logging
import pysonos
from homeassistant import config_entries
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_flow
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler
from homeassistant.helpers.typing import DiscoveryInfoType
from .const import DOMAIN
from .const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN
from .helpers import hostname_to_uid
_LOGGER = logging.getLogger(__name__)
async def _async_has_devices(hass: HomeAssistant) -> bool:
@ -13,4 +22,37 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
return bool(result)
config_entry_flow.register_discovery_flow(DOMAIN, "Sonos", _async_has_devices)
class SonosDiscoveryFlowHandler(DiscoveryFlowHandler):
"""Sonos discovery flow that callsback zeroconf updates."""
def __init__(self) -> None:
"""Init discovery flow."""
super().__init__(DOMAIN, "Sonos", _async_has_devices)
async def async_step_zeroconf(
self, discovery_info: DiscoveryInfoType
) -> FlowResult:
"""Handle a flow initialized by zeroconf."""
hostname = discovery_info["hostname"]
if hostname is None or not hostname.startswith("Sonos-"):
return self.async_abort(reason="not_sonos_device")
await self.async_set_unique_id(self._domain, raise_on_progress=False)
host = discovery_info[CONF_HOST]
properties = discovery_info["properties"]
boot_seqnum = properties.get("bootseq")
model = properties.get("model")
uid = hostname_to_uid(hostname)
_LOGGER.debug(
"Calling async_discovered_player for %s with uid=%s and boot_seqnum=%s",
host,
uid,
boot_seqnum,
)
if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER):
discovery_manager.async_discovered_player(
properties, host, uid, boot_seqnum, model
)
return await self.async_step_discovery(discovery_info)
config_entries.HANDLERS.register(DOMAIN)(SonosDiscoveryFlowHandler)

View File

@ -26,6 +26,7 @@ UPNP_ST = "urn:schemas-upnp-org:device:ZonePlayer:1"
DOMAIN = "sonos"
DATA_SONOS = "sonos_media_player"
DATA_SONOS_DISCOVERY_MANAGER = "sonos_discovery_manager"
PLATFORMS = {BINARY_SENSOR_DOMAIN, MP_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN}
SONOS_ARTIST = "artists"
@ -154,3 +155,5 @@ SCAN_INTERVAL = datetime.timedelta(seconds=10)
DISCOVERY_INTERVAL = datetime.timedelta(seconds=60)
SEEN_EXPIRE_TIME = 3.5 * DISCOVERY_INTERVAL
SUBSCRIPTION_TIMEOUT = 1200
MDNS_SERVICE = "_sonos._tcp.local."

View File

@ -9,6 +9,9 @@ from pysonos.exceptions import SoCoException, SoCoUPnPException
from homeassistant.exceptions import HomeAssistantError
UID_PREFIX = "RINCON_"
UID_POSTFIX = "01400"
_LOGGER = logging.getLogger(__name__)
@ -36,3 +39,19 @@ def soco_error(errorcodes: list[str] | None = None) -> Callable:
return wrapper
return decorator
def uid_to_short_hostname(uid: str) -> str:
"""Convert a Sonos uid to a short hostname."""
hostname_uid = uid
if hostname_uid.startswith(UID_PREFIX):
hostname_uid = hostname_uid[len(UID_PREFIX) :]
if hostname_uid.endswith(UID_POSTFIX):
hostname_uid = hostname_uid[: -len(UID_POSTFIX)]
return f"Sonos-{hostname_uid}"
def hostname_to_uid(hostname: str) -> str:
"""Convert a Sonos hostname to a uid."""
baseuid = hostname.split("-")[1].replace(".local.", "")
return f"{UID_PREFIX}{baseuid}{UID_POSTFIX}"

View File

@ -3,9 +3,10 @@
"name": "Sonos",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sonos",
"requirements": ["pysonos==0.0.51"],
"requirements": ["pysonos==0.0.52"],
"dependencies": ["ssdp"],
"after_dependencies": ["plex"],
"after_dependencies": ["plex", "zeroconf"],
"zeroconf": ["_sonos._tcp.local."],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"

View File

@ -19,6 +19,7 @@ from pysonos.music_library import MusicLibrary
from pysonos.plugins.sharelink import ShareLinkPlugin
from pysonos.snapshot import Snapshot
from homeassistant.components import zeroconf
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
@ -37,6 +38,7 @@ from .const import (
BATTERY_SCAN_INTERVAL,
DATA_SONOS,
DOMAIN,
MDNS_SERVICE,
PLATFORMS,
SCAN_INTERVAL,
SEEN_EXPIRE_TIME,
@ -56,7 +58,7 @@ from .const import (
SUBSCRIPTION_TIMEOUT,
)
from .favorites import SonosFavorites
from .helpers import soco_error
from .helpers import soco_error, uid_to_short_hostname
EVENT_CHARGING = {
"CHARGING": True,
@ -498,12 +500,27 @@ class SonosSpeaker:
self, now: datetime.datetime | None = None, will_reconnect: bool = False
) -> None:
"""Make this player unavailable when it was not seen recently."""
self._share_link_plugin = None
if self._seen_timer:
self._seen_timer()
self._seen_timer = None
hostname = uid_to_short_hostname(self.soco.uid)
zcname = f"{hostname}.{MDNS_SERVICE}"
aiozeroconf = await zeroconf.async_get_async_instance(self.hass)
if await aiozeroconf.async_get_service_info(MDNS_SERVICE, zcname):
# We can still see the speaker via zeroconf check again later.
self._seen_timer = self.hass.helpers.event.async_call_later(
SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen
)
return
_LOGGER.debug(
"No activity and could not locate %s on the network. Marking unavailable",
zcname,
)
self._share_link_plugin = None
if self._poll_timer:
self._poll_timer()
self._poll_timer = None
@ -511,7 +528,7 @@ class SonosSpeaker:
await self.async_unsubscribe()
if not will_reconnect:
self.hass.data[DATA_SONOS].ssdp_known.remove(self.soco.uid)
self.hass.data[DATA_SONOS].discovery_known.discard(self.soco.uid)
self.async_write_entity_states()
async def async_rebooted(self, soco: SoCo) -> None:

View File

@ -6,6 +6,7 @@
}
},
"abort": {
"not_sonos_device": "Discovered device is not a Sonos device",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
}

View File

@ -2,6 +2,7 @@
"config": {
"abort": {
"no_devices_found": "No devices found on the network",
"not_sonos_device": "Discovered device is not a Sonos device",
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"step": {

View File

@ -29,6 +29,8 @@ from .flow import FlowDispatcher, SSDPFlow
DOMAIN = "ssdp"
SCAN_INTERVAL = timedelta(seconds=60)
IPV4_BROADCAST = IPv4Address("255.255.255.255")
# Attributes for accessing info from SSDP response
ATTR_SSDP_LOCATION = "ssdp_location"
ATTR_SSDP_ST = "ssdp_st"
@ -236,7 +238,20 @@ class Scanner:
async_callback=self._async_process_entry, source_ip=source_ip
)
)
try:
IPv4Address(source_ip)
except ValueError:
continue
# Some sonos devices only seem to respond if we send to the broadcast
# address. This matches pysonos' behavior
# https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120
self._ssdp_listeners.append(
SSDPListener(
async_callback=self._async_process_entry,
source_ip=source_ip,
target_ip=IPV4_BROADCAST,
)
)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, self.flow_dispatcher.async_start

View File

@ -158,10 +158,10 @@ class SurePetcareAPI:
# https://github.com/PyCQA/pylint/issues/2062
# pylint: disable=no-member
if state == LockState.UNLOCKED.name.lower():
await self.surepy.unlock(flap_id)
await self.surepy.sac.unlock(flap_id)
elif state == LockState.LOCKED_IN.name.lower():
await self.surepy.lock_in(flap_id)
await self.surepy.sac.lock_in(flap_id)
elif state == LockState.LOCKED_OUT.name.lower():
await self.surepy.lock_out(flap_id)
await self.surepy.sac.lock_out(flap_id)
elif state == LockState.LOCKED_ALL.name.lower():
await self.surepy.lock(flap_id)
await self.surepy.sac.lock(flap_id)

View File

@ -3,7 +3,7 @@
"name": "Version",
"documentation": "https://www.home-assistant.io/integrations/version",
"requirements": [
"pyhaversion==21.5.0"
"pyhaversion==21.7.0"
],
"codeowners": [
"@fabaff",

View File

@ -10,7 +10,7 @@
"zha-quirks==0.0.59",
"zigpy-cc==0.5.2",
"zigpy-deconz==0.12.0",
"zigpy==0.35.1",
"zigpy==0.35.2",
"zigpy-xbee==0.13.0",
"zigpy-zigate==0.7.3",
"zigpy-znp==0.5.1"

View File

@ -3,7 +3,7 @@
"name": "Z-Wave JS",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zwave_js",
"requirements": ["zwave-js-server-python==0.27.0"],
"requirements": ["zwave-js-server-python==0.27.1"],
"codeowners": ["@home-assistant/z-wave"],
"dependencies": ["http", "websocket_api"],
"iot_class": "local_push"

View File

@ -5,7 +5,7 @@ from typing import Final
MAJOR_VERSION: Final = 2021
MINOR_VERSION: Final = 7
PATCH_VERSION: Final = "1"
PATCH_VERSION: Final = "2"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0)

View File

@ -186,6 +186,11 @@ ZEROCONF = {
"name": "brother*"
}
],
"_sonos._tcp.local.": [
{
"domain": "sonos"
}
],
"_spotify-connect._tcp.local.": [
{
"domain": "spotify"

View File

@ -160,7 +160,7 @@ aioeafm==0.1.2
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==5.0.0
aioesphomeapi==5.0.1
# homeassistant.components.flo
aioflo==0.4.1
@ -175,7 +175,7 @@ aioguardian==1.0.4
aioharmony==0.2.7
# homeassistant.components.homekit_controller
aiohomekit==0.4.2
aiohomekit==0.5.1
# homeassistant.components.emulated_hue
# homeassistant.components.http
@ -290,7 +290,7 @@ aprslib==0.6.46
aqualogic==2.6
# homeassistant.components.arcam_fmj
arcam-fmj==0.5.3
arcam-fmj==0.7.0
# homeassistant.components.arris_tg2492lg
arris-tg2492lg==1.1.0
@ -1020,7 +1020,7 @@ nettigo-air-monitor==1.0.0
neurio==0.3.1
# homeassistant.components.nexia
nexia==0.9.7
nexia==0.9.9
# homeassistant.components.nextcloud
nextcloudmonitor==1.1.0
@ -1375,7 +1375,7 @@ pydaikin==2.4.4
pydanfossair==0.1.0
# homeassistant.components.deconz
pydeconz==80
pydeconz==81
# homeassistant.components.delijn
pydelijn==0.6.1
@ -1423,7 +1423,7 @@ pyezviz==0.1.8.9
pyfido==2.1.1
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.40
pyfireservicerota==0.0.42
# homeassistant.components.flexit
pyflexit==0.3
@ -1466,7 +1466,7 @@ pygtfs==0.1.6
pygti==0.9.2
# homeassistant.components.version
pyhaversion==21.5.0
pyhaversion==21.7.0
# homeassistant.components.heos
pyheos==0.7.2
@ -1490,7 +1490,7 @@ pyialarm==1.9.0
pyicloud==0.10.2
# homeassistant.components.insteon
pyinsteon==1.0.9
pyinsteon==1.0.11
# homeassistant.components.intesishome
pyintesishome==1.7.6
@ -1571,7 +1571,7 @@ pymailgunner==1.4
pymata-express==1.19
# homeassistant.components.mazda
pymazda==0.1.6
pymazda==0.2.0
# homeassistant.components.mediaroom
pymediaroom==0.6.4.1
@ -1773,7 +1773,7 @@ pysnmp==4.4.12
pysoma==0.0.10
# homeassistant.components.sonos
pysonos==0.0.51
pysonos==0.0.52
# homeassistant.components.spc
pyspcwebgw==0.4.0
@ -2451,10 +2451,10 @@ zigpy-zigate==0.7.3
zigpy-znp==0.5.1
# homeassistant.components.zha
zigpy==0.35.1
zigpy==0.35.2
# homeassistant.components.zoneminder
zm-py==0.5.2
# homeassistant.components.zwave_js
zwave-js-server-python==0.27.0
zwave-js-server-python==0.27.1

View File

@ -100,7 +100,7 @@ aioeafm==0.1.2
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==5.0.0
aioesphomeapi==5.0.1
# homeassistant.components.flo
aioflo==0.4.1
@ -112,7 +112,7 @@ aioguardian==1.0.4
aioharmony==0.2.7
# homeassistant.components.homekit_controller
aiohomekit==0.4.2
aiohomekit==0.5.1
# homeassistant.components.emulated_hue
# homeassistant.components.http
@ -191,7 +191,7 @@ apprise==0.9.3
aprslib==0.6.46
# homeassistant.components.arcam_fmj
arcam-fmj==0.5.3
arcam-fmj==0.7.0
# homeassistant.components.dlna_dmr
# homeassistant.components.ssdp
@ -574,7 +574,7 @@ netdisco==2.9.0
nettigo-air-monitor==1.0.0
# homeassistant.components.nexia
nexia==0.9.7
nexia==0.9.9
# homeassistant.components.notify_events
notify-events==1.0.4
@ -770,7 +770,7 @@ pycoolmasternet-async==0.1.2
pydaikin==2.4.4
# homeassistant.components.deconz
pydeconz==80
pydeconz==81
# homeassistant.components.dexcom
pydexcom==0.2.0
@ -791,7 +791,7 @@ pyezviz==0.1.8.9
pyfido==2.1.1
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.40
pyfireservicerota==0.0.42
# homeassistant.components.flume
pyflume==0.5.5
@ -819,7 +819,7 @@ pygatt[GATTTOOL]==4.0.5
pygti==0.9.2
# homeassistant.components.version
pyhaversion==21.5.0
pyhaversion==21.7.0
# homeassistant.components.heos
pyheos==0.7.2
@ -837,7 +837,7 @@ pyialarm==1.9.0
pyicloud==0.10.2
# homeassistant.components.insteon
pyinsteon==1.0.9
pyinsteon==1.0.11
# homeassistant.components.ipma
pyipma==2.0.5
@ -888,7 +888,7 @@ pymailgunner==1.4
pymata-express==1.19
# homeassistant.components.mazda
pymazda==0.1.6
pymazda==0.2.0
# homeassistant.components.melcloud
pymelcloud==2.5.3
@ -1006,7 +1006,7 @@ pysmartthings==0.7.6
pysoma==0.0.10
# homeassistant.components.sonos
pysonos==0.0.51
pysonos==0.0.52
# homeassistant.components.spc
pyspcwebgw==0.4.0
@ -1345,7 +1345,7 @@ zigpy-zigate==0.7.3
zigpy-znp==0.5.1
# homeassistant.components.zha
zigpy==0.35.1
zigpy==0.35.2
# homeassistant.components.zwave_js
zwave-js-server-python==0.27.0
zwave-js-server-python==0.27.1

View File

@ -182,7 +182,7 @@ async def test_v4_sensor(
check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "997.9688")
check_sensor_state(hass, GHI, "0.0")
check_sensor_state(hass, CLOUD_BASE, "1.1909")
check_sensor_state(hass, CLOUD_COVER, "1.0")
check_sensor_state(hass, CLOUD_COVER, "100")
check_sensor_state(hass, CLOUD_CEILING, "1.1909")
check_sensor_state(hass, WIND_GUST, "5.6506")
check_sensor_state(hass, PRECIPITATION_TYPE, "rain")

View File

@ -228,7 +228,7 @@ async def test_v3_weather(
assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 9.9940
assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 320.31
assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 14.6289
assert weather_state.attributes[ATTR_CLOUD_COVER] == 1
assert weather_state.attributes[ATTR_CLOUD_COVER] == 100
assert weather_state.attributes[ATTR_WIND_GUST] == 24.0758
assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain"
@ -391,6 +391,6 @@ async def test_v4_weather(
assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 13.1162
assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14
assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 15.0152
assert weather_state.attributes[ATTR_CLOUD_COVER] == 1
assert weather_state.attributes[ATTR_CLOUD_COVER] == 100
assert weather_state.attributes[ATTR_WIND_GUST] == 20.3421
assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain"

View File

@ -371,6 +371,34 @@ async def test_light_state_change(hass, aioclient_mock, mock_deconz_websocket):
@pytest.mark.parametrize(
"input,expected",
[
( # Turn on light with hue and sat
{
"light_on": True,
"service": SERVICE_TURN_ON,
"call": {
ATTR_ENTITY_ID: "light.hue_go",
ATTR_HS_COLOR: (20, 30),
},
},
{
"on": True,
"xy": (0.411, 0.351),
},
),
( # Turn on light with XY color
{
"light_on": True,
"service": SERVICE_TURN_ON,
"call": {
ATTR_ENTITY_ID: "light.hue_go",
ATTR_XY_COLOR: (0.411, 0.351),
},
},
{
"on": True,
"xy": (0.411, 0.351),
},
),
( # Turn on light with short color loop
{
"light_on": False,
@ -811,9 +839,8 @@ async def test_groups(hass, aioclient_mock, input, expected):
},
},
{
"hue": 45510,
"on": True,
"sat": 127,
"xy": (0.235, 0.164),
},
),
( # Turn on group with short color loop
@ -827,9 +854,8 @@ async def test_groups(hass, aioclient_mock, input, expected):
},
},
{
"hue": 45510,
"on": True,
"sat": 127,
"xy": (0.235, 0.164),
},
),
],

View File

@ -86,6 +86,16 @@ async def test_temperature_sensor_read_state(hass, utcnow):
assert state.attributes["device_class"] == DEVICE_CLASS_TEMPERATURE
async def test_temperature_sensor_not_added_twice(hass, utcnow):
"""A standalone temperature sensor should not get a characteristic AND a service entity."""
helper = await setup_test_component(
hass, create_temperature_sensor_service, suffix="temperature"
)
for state in hass.states.async_all():
assert state.entity_id == helper.entity_id
async def test_humidity_sensor_read_state(hass, utcnow):
"""Test reading the state of a HomeKit humidity sensor accessory."""
helper = await setup_test_component(

View File

@ -97,7 +97,7 @@ async def test_update_auth_failure(hass: HomeAssistant):
"homeassistant.components.mazda.MazdaAPI.get_vehicles",
side_effect=MazdaAuthenticationException("Login failed"),
):
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=61))
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=181))
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
@ -136,7 +136,7 @@ async def test_update_general_failure(hass: HomeAssistant):
"homeassistant.components.mazda.MazdaAPI.get_vehicles",
side_effect=Exception("Unknown exception"),
):
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=61))
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=181))
await hass.async_block_till_done()
entity = hass.states.get("sensor.my_mazda3_fuel_remaining_percentage")

View File

@ -0,0 +1,92 @@
"""Test the sonos config flow."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from homeassistant import config_entries, core, setup
from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN
@patch("homeassistant.components.sonos.config_flow.pysonos.discover", return_value=True)
async def test_user_form(discover_mock: MagicMock, hass: core.HomeAssistant):
"""Test we get the user initiated form."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] is None
with patch(
"homeassistant.components.sonos.async_setup",
return_value=True,
) as mock_setup, patch(
"homeassistant.components.sonos.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result2["type"] == "create_entry"
assert result2["title"] == "Sonos"
assert result2["data"] == {}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_form(hass: core.HomeAssistant):
"""Test we pass sonos devices to the discovery manager."""
await setup.async_setup_component(hass, "persistent_notification", {})
mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data={
"host": "192.168.4.2",
"hostname": "Sonos-aaa",
"properties": {"bootseq": "1234"},
},
)
assert result["type"] == "form"
assert result["errors"] is None
with patch(
"homeassistant.components.sonos.async_setup",
return_value=True,
) as mock_setup, patch(
"homeassistant.components.sonos.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result2["type"] == "create_entry"
assert result2["title"] == "Sonos"
assert result2["data"] == {}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_manager.mock_calls) == 2
async def test_zeroconf_form_not_sonos(hass: core.HomeAssistant):
"""Test we abort on non-sonos devices."""
mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock()
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data={
"host": "192.168.4.2",
"hostname": "not-aaa",
"properties": {"bootseq": "1234"},
},
)
assert result["type"] == "abort"
assert result["reason"] == "not_sonos_device"
assert len(mock_manager.mock_calls) == 0

View File

@ -0,0 +1,17 @@
"""Test the sonos config flow."""
from __future__ import annotations
from homeassistant.components.sonos.helpers import (
hostname_to_uid,
uid_to_short_hostname,
)
async def test_uid_to_short_hostname():
"""Test we can convert a uid to a short hostname."""
assert uid_to_short_hostname("RINCON_347E5C0CF1E301400") == "Sonos-347E5C0CF1E3"
async def test_uid_to_hostname():
"""Test we can convert a hostname to a uid."""
assert hostname_to_uid("Sonos-347E5C0CF1E3.local.") == "RINCON_347E5C0CF1E301400"

View File

@ -295,15 +295,15 @@ async def test_start_stop_scanner(async_start_mock, async_search_mock, hass):
await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200))
await hass.async_block_till_done()
assert async_start_mock.call_count == 1
assert async_search_mock.call_count == 1
assert async_start_mock.call_count == 2
assert async_search_mock.call_count == 2
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200))
await hass.async_block_till_done()
assert async_start_mock.call_count == 1
assert async_search_mock.call_count == 1
assert async_start_mock.call_count == 2
assert async_search_mock.call_count == 2
async def test_unexpected_exception_while_fetching(hass, aioclient_mock, caplog):
@ -459,11 +459,11 @@ async def test_scan_with_registered_callback(hass, aioclient_mock, caplog):
await hass.async_block_till_done()
assert hass.state == CoreState.running
assert len(integration_callbacks) == 3
assert len(integration_callbacks_from_cache) == 3
assert len(integration_match_all_callbacks) == 3
assert len(integration_callbacks) == 5
assert len(integration_callbacks_from_cache) == 5
assert len(integration_match_all_callbacks) == 5
assert len(integration_match_all_not_present_callbacks) == 0
assert len(match_any_callbacks) == 3
assert len(match_any_callbacks) == 5
assert len(not_matching_integration_callbacks) == 0
assert integration_callbacks[0] == {
ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus",
@ -546,7 +546,7 @@ async def test_unsolicited_ssdp_registered_callback(hass, aioclient_mock, caplog
assert hass.state == CoreState.running
assert (
len(integration_callbacks) == 2
len(integration_callbacks) == 4
) # unsolicited callbacks without st are not cached
assert integration_callbacks[0] == {
"UDN": "uuid:RINCON_1111BB963FD801400",
@ -635,7 +635,7 @@ async def test_scan_second_hit(hass, aioclient_mock, caplog):
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200))
await hass.async_block_till_done()
assert len(integration_callbacks) == 2
assert len(integration_callbacks) == 4
assert integration_callbacks[0] == {
ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus",
ssdp.ATTR_SSDP_EXT: "",
@ -781,7 +781,12 @@ async def test_async_detect_interfaces_setting_empty_route(hass):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert {create_args[0][1]["source_ip"], create_args[1][1]["source_ip"]} == {
IPv4Address("192.168.1.5"),
IPv6Address("2001:db8::"),
argset = set()
for argmap in create_args:
argset.add((argmap[1].get("source_ip"), argmap[1].get("target_ip")))
assert argset == {
(IPv6Address("2001:db8::"), None),
(IPv4Address("192.168.1.5"), IPv4Address("255.255.255.255")),
(IPv4Address("192.168.1.5"), None),
}