Enable strict typing checking for bluesound integration (#123821)

* Enable strict typing

* Fix types

* Update to pyblu 0.5.2 for typing support

* Update pyblu to 1.0.0

* Update pyblu to 1.0.1

* Update error handling

* Fix tests

* Remove return None from methods only returning None
This commit is contained in:
Louis Christ 2024-08-30 20:21:27 +02:00 committed by GitHub
parent 910fb0930e
commit 7868ffac35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 56 additions and 54 deletions

View File

@ -110,6 +110,7 @@ homeassistant.components.bitcoin.*
homeassistant.components.blockchain.* homeassistant.components.blockchain.*
homeassistant.components.blue_current.* homeassistant.components.blue_current.*
homeassistant.components.blueprint.* homeassistant.components.blueprint.*
homeassistant.components.bluesound.*
homeassistant.components.bluetooth.* homeassistant.components.bluetooth.*
homeassistant.components.bluetooth_adapters.* homeassistant.components.bluetooth_adapters.*
homeassistant.components.bluetooth_tracker.* homeassistant.components.bluetooth_tracker.*

View File

@ -2,8 +2,8 @@
from dataclasses import dataclass from dataclasses import dataclass
import aiohttp
from pyblu import Player, SyncStatus from pyblu import Player, SyncStatus
from pyblu.errors import PlayerUnreachableError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.const import CONF_HOST, CONF_PORT, Platform
@ -22,14 +22,14 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
@dataclass @dataclass
class BluesoundData: class BluesoundRuntimeData:
"""Bluesound data class.""" """Bluesound data class."""
player: Player player: Player
sync_status: SyncStatus sync_status: SyncStatus
type BluesoundConfigEntry = ConfigEntry[BluesoundData] type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@ -51,14 +51,10 @@ async def async_setup_entry(
async with Player(host, port, session=session, default_timeout=10) as player: async with Player(host, port, session=session, default_timeout=10) as player:
try: try:
sync_status = await player.sync_status(timeout=1) sync_status = await player.sync_status(timeout=1)
except TimeoutError as ex: except PlayerUnreachableError as ex:
raise ConfigEntryNotReady(
f"Timeout while connecting to {host}:{port}"
) from ex
except aiohttp.ClientError as ex:
raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
config_entry.runtime_data = BluesoundData(player, sync_status) config_entry.runtime_data = BluesoundRuntimeData(player, sync_status)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

View File

@ -3,8 +3,8 @@
import logging import logging
from typing import Any from typing import Any
import aiohttp
from pyblu import Player, SyncStatus from pyblu import Player, SyncStatus
from pyblu.errors import PlayerUnreachableError
import voluptuous as vol import voluptuous as vol
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
@ -43,7 +43,7 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
) as player: ) as player:
try: try:
sync_status = await player.sync_status(timeout=1) sync_status = await player.sync_status(timeout=1)
except (TimeoutError, aiohttp.ClientError): except PlayerUnreachableError:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
else: else:
await self.async_set_unique_id( await self.async_set_unique_id(
@ -79,7 +79,7 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
) as player: ) as player:
try: try:
sync_status = await player.sync_status(timeout=1) sync_status = await player.sync_status(timeout=1)
except (TimeoutError, aiohttp.ClientError): except PlayerUnreachableError:
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id( await self.async_set_unique_id(
@ -105,7 +105,7 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
discovery_info.host, self._port, session=session discovery_info.host, self._port, session=session
) as player: ) as player:
sync_status = await player.sync_status(timeout=1) sync_status = await player.sync_status(timeout=1)
except (TimeoutError, aiohttp.ClientError): except PlayerUnreachableError:
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(format_unique_id(sync_status.mac, self._port)) await self.async_set_unique_id(format_unique_id(sync_status.mac, self._port))
@ -127,7 +127,9 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
) )
return await self.async_step_confirm() return await self.async_step_confirm()
async def async_step_confirm(self, user_input=None) -> ConfigFlowResult: async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the zeroconf setup.""" """Confirm the zeroconf setup."""
assert self._sync_status is not None assert self._sync_status is not None
assert self._host is not None assert self._host is not None

View File

@ -6,7 +6,7 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bluesound", "documentation": "https://www.home-assistant.io/integrations/bluesound",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["pyblu==0.4.0"], "requirements": ["pyblu==1.0.1"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_musc._tcp.local." "type": "_musc._tcp.local."

View File

@ -9,8 +9,8 @@ from datetime import datetime, timedelta
import logging import logging
from typing import TYPE_CHECKING, Any, NamedTuple from typing import TYPE_CHECKING, Any, NamedTuple
from aiohttp.client_exceptions import ClientError
from pyblu import Input, Player, Preset, Status, SyncStatus from pyblu import Input, Player, Preset, Status, SyncStatus
from pyblu.errors import PlayerUnreachableError
import voluptuous as vol import voluptuous as vol
from homeassistant.components import media_source from homeassistant.components import media_source
@ -239,7 +239,7 @@ class BluesoundPlayer(MediaPlayerEntity):
self.port = port self.port = port
self._polling_task: Task[None] | None = None # The actual polling task. self._polling_task: Task[None] | None = None # The actual polling task.
self._id = sync_status.id self._id = sync_status.id
self._last_status_update = None self._last_status_update: datetime | None = None
self._sync_status = sync_status self._sync_status = sync_status
self._status: Status | None = None self._status: Status | None = None
self._inputs: list[Input] = [] self._inputs: list[Input] = []
@ -247,7 +247,7 @@ class BluesoundPlayer(MediaPlayerEntity):
self._muted = False self._muted = False
self._master: BluesoundPlayer | None = None self._master: BluesoundPlayer | None = None
self._is_master = False self._is_master = False
self._group_name = None self._group_name: str | None = None
self._group_list: list[str] = [] self._group_list: list[str] = []
self._bluesound_device_name = sync_status.name self._bluesound_device_name = sync_status.name
self._player = player self._player = player
@ -273,14 +273,6 @@ class BluesoundPlayer(MediaPlayerEntity):
via_device=(DOMAIN, format_mac(sync_status.mac)), via_device=(DOMAIN, format_mac(sync_status.mac)),
) )
@staticmethod
def _try_get_index(string, search_string):
"""Get the index."""
try:
return string.index(search_string)
except ValueError:
return -1
async def force_update_sync_status(self) -> bool: async def force_update_sync_status(self) -> bool:
"""Update the internal status.""" """Update the internal status."""
sync_status = await self._player.sync_status() sync_status = await self._player.sync_status()
@ -309,12 +301,12 @@ class BluesoundPlayer(MediaPlayerEntity):
return True return True
async def _poll_loop(self): async def _poll_loop(self) -> None:
"""Loop which polls the status of the player.""" """Loop which polls the status of the player."""
while True: while True:
try: try:
await self.async_update_status() await self.async_update_status()
except (TimeoutError, ClientError): except PlayerUnreachableError:
_LOGGER.error( _LOGGER.error(
"Node %s:%s is offline, retrying later", self.host, self.port "Node %s:%s is offline, retrying later", self.host, self.port
) )
@ -324,9 +316,9 @@ class BluesoundPlayer(MediaPlayerEntity):
"Stopping the polling of node %s:%s", self.host, self.port "Stopping the polling of node %s:%s", self.host, self.port
) )
return return
except Exception: except: # noqa: E722 - this loop should never stop
_LOGGER.exception( _LOGGER.exception(
"Unexpected error in %s:%s, retrying later", self.host, self.port "Unexpected error for %s:%s, retrying later", self.host, self.port
) )
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
@ -356,12 +348,12 @@ class BluesoundPlayer(MediaPlayerEntity):
if not self.available: if not self.available:
return return
with suppress(TimeoutError): with suppress(PlayerUnreachableError):
await self.async_update_sync_status() await self.async_update_sync_status()
await self.async_update_presets() await self.async_update_presets()
await self.async_update_captures() await self.async_update_captures()
async def async_update_status(self): async def async_update_status(self) -> None:
"""Use the poll session to always get the status of the player.""" """Use the poll session to always get the status of the player."""
etag = None etag = None
if self._status is not None: if self._status is not None:
@ -394,11 +386,11 @@ class BluesoundPlayer(MediaPlayerEntity):
# the device is playing. This would solve a lot of # the device is playing. This would solve a lot of
# problems. This change will be done when the # problems. This change will be done when the
# communication is moved to a separate library # communication is moved to a separate library
with suppress(TimeoutError): with suppress(PlayerUnreachableError):
await self.force_update_sync_status() await self.force_update_sync_status()
self.async_write_ha_state() self.async_write_ha_state()
except (TimeoutError, ClientError): except PlayerUnreachableError:
self._attr_available = False self._attr_available = False
self._last_status_update = None self._last_status_update = None
self._status = None self._status = None
@ -409,7 +401,7 @@ class BluesoundPlayer(MediaPlayerEntity):
) )
raise raise
async def async_trigger_sync_on_all(self): async def async_trigger_sync_on_all(self) -> None:
"""Trigger sync status update on all devices.""" """Trigger sync status update on all devices."""
_LOGGER.debug("Trigger sync status on all devices") _LOGGER.debug("Trigger sync status on all devices")
@ -417,7 +409,7 @@ class BluesoundPlayer(MediaPlayerEntity):
await player.force_update_sync_status() await player.force_update_sync_status()
@Throttle(SYNC_STATUS_INTERVAL) @Throttle(SYNC_STATUS_INTERVAL)
async def async_update_sync_status(self): async def async_update_sync_status(self) -> None:
"""Update sync status.""" """Update sync status."""
await self.force_update_sync_status() await self.force_update_sync_status()
@ -506,8 +498,6 @@ class BluesoundPlayer(MediaPlayerEntity):
return None return None
position = self._status.seconds position = self._status.seconds
if position is None:
return None
if mediastate == MediaPlayerState.PLAYING: if mediastate == MediaPlayerState.PLAYING:
position += (dt_util.utcnow() - self._last_status_update).total_seconds() position += (dt_util.utcnow() - self._last_status_update).total_seconds()
@ -524,7 +514,7 @@ class BluesoundPlayer(MediaPlayerEntity):
if duration is None: if duration is None:
return None return None
return duration return int(duration)
@property @property
def media_position_updated_at(self) -> datetime | None: def media_position_updated_at(self) -> datetime | None:
@ -660,7 +650,7 @@ class BluesoundPlayer(MediaPlayerEntity):
return shuffle return shuffle
async def async_join(self, master): async def async_join(self, master: str) -> None:
"""Join the player to a group.""" """Join the player to a group."""
master_device = [ master_device = [
device device
@ -711,7 +701,7 @@ class BluesoundPlayer(MediaPlayerEntity):
if entity.bluesound_device_name in device_group if entity.bluesound_device_name in device_group
] ]
async def async_unjoin(self): async def async_unjoin(self) -> None:
"""Unjoin the player from a group.""" """Unjoin the player from a group."""
if self._master is None: if self._master is None:
return return
@ -719,11 +709,11 @@ class BluesoundPlayer(MediaPlayerEntity):
_LOGGER.debug("Trying to unjoin player: %s", self.id) _LOGGER.debug("Trying to unjoin player: %s", self.id)
await self._master.async_remove_slave(self) await self._master.async_remove_slave(self)
async def async_add_slave(self, slave_device: BluesoundPlayer): async def async_add_slave(self, slave_device: BluesoundPlayer) -> None:
"""Add slave to master.""" """Add slave to master."""
await self._player.add_slave(slave_device.host, slave_device.port) await self._player.add_slave(slave_device.host, slave_device.port)
async def async_remove_slave(self, slave_device: BluesoundPlayer): async def async_remove_slave(self, slave_device: BluesoundPlayer) -> None:
"""Remove slave to master.""" """Remove slave to master."""
await self._player.remove_slave(slave_device.host, slave_device.port) await self._player.remove_slave(slave_device.host, slave_device.port)
@ -731,7 +721,7 @@ class BluesoundPlayer(MediaPlayerEntity):
"""Increase sleep time on player.""" """Increase sleep time on player."""
return await self._player.sleep_timer() return await self._player.sleep_timer()
async def async_clear_timer(self): async def async_clear_timer(self) -> None:
"""Clear sleep timer on player.""" """Clear sleep timer on player."""
sleep = 1 sleep = 1
while sleep > 0: while sleep > 0:
@ -755,6 +745,9 @@ class BluesoundPlayer(MediaPlayerEntity):
if preset.name == source: if preset.name == source:
url = preset.url url = preset.url
if url is None:
raise ServiceValidationError(f"Source {source} not found")
await self._player.play_url(url) await self._player.play_url(url)
async def async_clear_playlist(self) -> None: async def async_clear_playlist(self) -> None:
@ -826,20 +819,20 @@ class BluesoundPlayer(MediaPlayerEntity):
async def async_volume_up(self) -> None: async def async_volume_up(self) -> None:
"""Volume up the media player.""" """Volume up the media player."""
if self.volume_level is None: if self.volume_level is None:
return None return
new_volume = self.volume_level + 0.01 new_volume = self.volume_level + 0.01
new_volume = min(1, new_volume) new_volume = min(1, new_volume)
return await self.async_set_volume_level(new_volume) await self.async_set_volume_level(new_volume)
async def async_volume_down(self) -> None: async def async_volume_down(self) -> None:
"""Volume down the media player.""" """Volume down the media player."""
if self.volume_level is None: if self.volume_level is None:
return None return
new_volume = self.volume_level - 0.01 new_volume = self.volume_level - 0.01
new_volume = max(0, new_volume) new_volume = max(0, new_volume)
return await self.async_set_volume_level(new_volume) await self.async_set_volume_level(new_volume)
async def async_set_volume_level(self, volume: float) -> None: async def async_set_volume_level(self, volume: float) -> None:
"""Send volume_up command to media player.""" """Send volume_up command to media player."""

View File

@ -855,6 +855,16 @@ disallow_untyped_defs = true
warn_return_any = true warn_return_any = true
warn_unreachable = true warn_unreachable = true
[mypy-homeassistant.components.bluesound.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.bluetooth.*] [mypy-homeassistant.components.bluetooth.*]
check_untyped_defs = true check_untyped_defs = true
disallow_incomplete_defs = true disallow_incomplete_defs = true

View File

@ -1763,7 +1763,7 @@ pybbox==0.0.5-alpha
pyblackbird==0.6 pyblackbird==0.6
# homeassistant.components.bluesound # homeassistant.components.bluesound
pyblu==0.4.0 pyblu==1.0.1
# homeassistant.components.neato # homeassistant.components.neato
pybotvac==0.0.25 pybotvac==0.0.25

View File

@ -1428,7 +1428,7 @@ pybalboa==1.0.2
pyblackbird==0.6 pyblackbird==0.6
# homeassistant.components.bluesound # homeassistant.components.bluesound
pyblu==0.4.0 pyblu==1.0.1
# homeassistant.components.neato # homeassistant.components.neato
pybotvac==0.0.25 pybotvac==0.0.25

View File

@ -2,7 +2,7 @@
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from aiohttp import ClientConnectionError from pyblu.errors import PlayerUnreachableError
from homeassistant.components.bluesound.const import DOMAIN from homeassistant.components.bluesound.const import DOMAIN
from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.components.zeroconf import ZeroconfServiceInfo
@ -49,7 +49,7 @@ async def test_user_flow_cannot_connect(
context={"source": SOURCE_USER}, context={"source": SOURCE_USER},
) )
mock_player.sync_status.side_effect = ClientConnectionError mock_player.sync_status.side_effect = PlayerUnreachableError("Player not reachable")
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
@ -129,7 +129,7 @@ async def test_import_flow_cannot_connect(
hass: HomeAssistant, mock_player: AsyncMock hass: HomeAssistant, mock_player: AsyncMock
) -> None: ) -> None:
"""Test we handle cannot connect error.""" """Test we handle cannot connect error."""
mock_player.sync_status.side_effect = ClientConnectionError mock_player.sync_status.side_effect = PlayerUnreachableError("Player not reachable")
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": SOURCE_IMPORT}, context={"source": SOURCE_IMPORT},
@ -200,7 +200,7 @@ async def test_zeroconf_flow_cannot_connect(
hass: HomeAssistant, mock_player: AsyncMock hass: HomeAssistant, mock_player: AsyncMock
) -> None: ) -> None:
"""Test we handle cannot connect error.""" """Test we handle cannot connect error."""
mock_player.sync_status.side_effect = ClientConnectionError mock_player.sync_status.side_effect = PlayerUnreachableError("Player not reachable")
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": SOURCE_ZEROCONF}, context={"source": SOURCE_ZEROCONF},