diff --git a/.coveragerc b/.coveragerc index e96895429a6cb1..a7ce4d67c52472 100644 --- a/.coveragerc +++ b/.coveragerc @@ -352,6 +352,7 @@ omit = homeassistant/components/kankun/switch.py homeassistant/components/keba/* homeassistant/components/keenetic_ndms2/device_tracker.py + homeassistant/components/kef/* homeassistant/components/keyboard/* homeassistant/components/keyboard_remote/* homeassistant/components/kira/* diff --git a/CODEOWNERS b/CODEOWNERS index 23005cb5273b70..2546433c4a272d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -176,6 +176,7 @@ homeassistant/components/juicenet/* @jesserockz homeassistant/components/kaiterra/* @Michsior14 homeassistant/components/keba/* @dannerph homeassistant/components/keenetic_ndms2/* @foxel +homeassistant/components/kef/* @basnijholt homeassistant/components/keyboard_remote/* @bendavid homeassistant/components/knx/* @Julius2342 homeassistant/components/kodi/* @armills diff --git a/homeassistant/components/kef/__init__.py b/homeassistant/components/kef/__init__.py new file mode 100644 index 00000000000000..a55c8ca321095b --- /dev/null +++ b/homeassistant/components/kef/__init__.py @@ -0,0 +1 @@ +"""The KEF Wireless Speakers component.""" diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json new file mode 100644 index 00000000000000..30335c409eeadf --- /dev/null +++ b/homeassistant/components/kef/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "kef", + "name": "KEF", + "documentation": "https://www.home-assistant.io/integrations/kef", + "dependencies": [], + "codeowners": ["@basnijholt"], + "requirements": ["aiokef==0.2.2", "getmac==0.8.1"] +} diff --git a/homeassistant/components/kef/media_player.py b/homeassistant/components/kef/media_player.py new file mode 100644 index 00000000000000..f0c2de2a86a099 --- /dev/null +++ b/homeassistant/components/kef/media_player.py @@ -0,0 +1,273 @@ +"""Platform for the KEF Wireless Speakers.""" + +from datetime import timedelta +from functools import partial +import ipaddress +import logging + +from aiokef.aiokef import AsyncKefSpeaker +from getmac import get_mac_address +import voluptuous as vol + +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, + SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, + MediaPlayerDevice, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_TYPE, + STATE_OFF, + STATE_ON, +) +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "KEF" +DEFAULT_PORT = 50001 +DEFAULT_MAX_VOLUME = 0.5 +DEFAULT_VOLUME_STEP = 0.05 +DEFAULT_INVERSE_SPEAKER_MODE = False + +DOMAIN = "kef" + +SCAN_INTERVAL = timedelta(seconds=30) +PARALLEL_UPDATES = 0 + +SOURCES = {"LSX": ["Wifi", "Bluetooth", "Aux", "Opt"]} +SOURCES["LS50"] = SOURCES["LSX"] + ["Usb"] + +SUPPORT_KEF = ( + SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_STEP + | SUPPORT_VOLUME_MUTE + | SUPPORT_SELECT_SOURCE + | SUPPORT_TURN_OFF + | SUPPORT_TURN_ON +) + +CONF_MAX_VOLUME = "maximum_volume" +CONF_VOLUME_STEP = "volume_step" +CONF_INVERSE_SPEAKER_MODE = "inverse_speaker_mode" +CONF_STANDBY_TIME = "standby_time" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TYPE): vol.In(["LS50", "LSX"]), + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MAX_VOLUME, default=DEFAULT_MAX_VOLUME): cv.small_float, + vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): cv.small_float, + vol.Optional( + CONF_INVERSE_SPEAKER_MODE, default=DEFAULT_INVERSE_SPEAKER_MODE + ): cv.boolean, + vol.Optional(CONF_STANDBY_TIME): vol.In([20, 60]), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the KEF platform.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + host = config[CONF_HOST] + speaker_type = config[CONF_TYPE] + port = config[CONF_PORT] + name = config[CONF_NAME] + maximum_volume = config[CONF_MAX_VOLUME] + volume_step = config[CONF_VOLUME_STEP] + inverse_speaker_mode = config[CONF_INVERSE_SPEAKER_MODE] + standby_time = config.get(CONF_STANDBY_TIME) + + sources = SOURCES[speaker_type] + + _LOGGER.debug( + "Setting up %s with host: %s, port: %s, name: %s, sources: %s", + DOMAIN, + host, + port, + name, + sources, + ) + + try: + if ipaddress.ip_address(host).version == 6: + mode = "ip6" + else: + mode = "ip" + except ValueError: + mode = "hostname" + mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host})) + unique_id = f"kef-{mac}" if mac is not None else None + + media_player = KefMediaPlayer( + name, + host, + port, + maximum_volume, + volume_step, + standby_time, + inverse_speaker_mode, + sources, + ioloop=hass.loop, + unique_id=unique_id, + ) + + if host in hass.data[DOMAIN]: + _LOGGER.debug("%s is already configured", host) + else: + hass.data[DOMAIN][host] = media_player + async_add_entities([media_player], update_before_add=True) + + +class KefMediaPlayer(MediaPlayerDevice): + """Kef Player Object.""" + + def __init__( + self, + name, + host, + port, + maximum_volume, + volume_step, + standby_time, + inverse_speaker_mode, + sources, + ioloop, + unique_id, + ): + """Initialize the media player.""" + self._name = name + self._sources = sources + self._speaker = AsyncKefSpeaker( + host, + port, + volume_step, + maximum_volume, + standby_time, + inverse_speaker_mode, + ioloop=ioloop, + ) + self._unique_id = unique_id + + self._state = None + self._muted = None + self._source = None + self._volume = None + self._is_online = None + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + async def async_update(self): + """Update latest state.""" + _LOGGER.debug("Running async_update") + try: + self._is_online = await self._speaker.is_online() + if self._is_online: + ( + self._volume, + self._muted, + ) = await self._speaker.get_volume_and_is_muted() + state = await self._speaker.get_state() + self._source = state.source + self._state = STATE_ON if state.is_on else STATE_OFF + else: + self._muted = None + self._source = None + self._volume = None + self._state = STATE_OFF + except (ConnectionRefusedError, ConnectionError, TimeoutError) as err: + _LOGGER.debug("Error in `update`: %s", err) + self._state = None + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_KEF + + @property + def source(self): + """Name of the current input source.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._sources + + @property + def available(self): + """Return if the speaker is reachable online.""" + return self._is_online + + @property + def unique_id(self): + """Return the device unique id.""" + return self._unique_id + + @property + def icon(self): + """Return the device's icon.""" + return "mdi:speaker-wireless" + + async def async_turn_off(self): + """Turn the media player off.""" + await self._speaker.turn_off() + + async def async_turn_on(self): + """Turn the media player on.""" + await self._speaker.turn_on() + + async def async_volume_up(self): + """Volume up the media player.""" + await self._speaker.increase_volume() + + async def async_volume_down(self): + """Volume down the media player.""" + await self._speaker.decrease_volume() + + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + await self._speaker.set_volume(volume) + + async def async_mute_volume(self, mute): + """Mute (True) or unmute (False) media player.""" + if mute: + await self._speaker.mute() + else: + await self._speaker.unmute() + + async def async_select_source(self, source: str): + """Select input source.""" + if source in self.source_list: + await self._speaker.set_source(source) + else: + raise ValueError(f"Unknown input source: {source}.") diff --git a/requirements_all.txt b/requirements_all.txt index d2ed4e0e1e26b9..36f92c52a71186 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -171,6 +171,9 @@ aioimaplib==0.7.15 # homeassistant.components.apache_kafka aiokafka==0.5.1 +# homeassistant.components.kef +aiokef==0.2.2 + # homeassistant.components.lifx aiolifx==0.6.7 @@ -578,6 +581,7 @@ georss_qld_bushfire_alert_client==0.3 # homeassistant.components.braviatv # homeassistant.components.huawei_lte +# homeassistant.components.kef # homeassistant.components.nmap_tracker getmac==0.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff1ded4434cb5f..84efd81608ae7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -197,6 +197,7 @@ georss_qld_bushfire_alert_client==0.3 # homeassistant.components.braviatv # homeassistant.components.huawei_lte +# homeassistant.components.kef # homeassistant.components.nmap_tracker getmac==0.8.1