Skip to content

Commit

Permalink
Merge pull request #8 from N0ciple/dev
Browse files Browse the repository at this point in the history
merge dev branch for version 0.6.0
  • Loading branch information
N0ciple authored Feb 20, 2024
2 parents 2770af6 + 961713a commit 0d9f8d4
Show file tree
Hide file tree
Showing 4 changed files with 100 additions and 31 deletions.
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# 🏠️↔️🔉 hass-kef-connector
A Home Assistant integration for the Kef LS50W2 🔊
# 🏠️↔️🔉 Kef Connector
A Home Assistant integration for KEF speakers 🔊
Kef Connector is compatible with LSX2LT, LSX2, LS50W2 and LS60.

- [hass-kef-connector](#hass-kef-connector)
- [🏠️↔️🔉 Kef Connector](#️️-kef-connector)
- [Installation and configuration](#installation-and-configuration)
- [⬇️ Installation](#️-installation)
- [🔧 Configuration](#-configuration)
- [📜 Platform configuration options](#-platform-configuration-options)
- [🧑‍🔬 Full configuration example](#-full-configuration-example)


## Installation and configuration

### ⬇️ Installation
Expand Down Expand Up @@ -57,7 +59,7 @@ Here is the list of the variables you can set if your `configuration.yaml` file.
| `name` | _Optional_ | _see comment_ | If you do not specify a `name`, the integration will fetch the name you set up on the KefConnect app for your speakers, if any. If you specify a `name` property, this name will be used instead.|
| `maximum_volume` | _Optional_ | `1.0` | This should be a float between 0 and 1. 0 is muted and 1 is maximum volume. Bear in mind that this option **does not** override the maximum volume set in the KefConnect app. It will prevent hass from setting a volume higher than `maximum_volume`|
| `volume_step` | _Optional_ | `0.03` | This should be float bewteen 0 and 1 (however it is **not recommended** to set it higher than 0.1). This value is by how much volume will be changed when calling `media_player.volume_up` or `media_player.volume_down` services, by clicking on ![volume_down_up](assets/images/volume_down_up.png) for example. |

| `speaker_model` | _Optional_ | _see comment_ | Write the model of your KEF speakers (either `LSX2`, `LSX2LT`, `LS50W2` or `LS60`). This allows Kef Connector to know which sources are available on your speakers. If you do not put `speaker_model` in your `configuration.yaml`, by default, all sources will be available on the entity, even though they are not physically present on your speakers (for example, there is no analog input on the LSX2LT). |
#### 🧑‍🔬 Full configuration example
This is just and example ! You can copy it but **at least** change the `host` value to the IP address of you speakers. More info on how to find the IP address [here](https://github.com/N0ciple/pykefcontrol#-get-the-ip-address).
```yaml
Expand All @@ -67,4 +69,5 @@ media_player:
name: "My Kef Speakers"
maximum_volume: 0.7
volume_step: 0.02
speaker_model: LS50W2
```
2 changes: 1 addition & 1 deletion custom_components/kef_connector/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
"""The Kef for LS50 W2 integration."""
"""The integration for KEF speakers."""
13 changes: 7 additions & 6 deletions custom_components/kef_connector/manifest.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
{
"domain": "kef_connector",
"name": "Kef Connector",
"codeowners": [
"@n0ciple"
],
"config_flow": false,
"version":"0.5.5",
"documentation": "https://github.com/N0ciple/hass-kef-connector",
"integration_type": "device",
"iot_class": "local_polling",
"issue_tracker":"https://github.com/N0ciple/hass-kef-connector/issues",
"requirements": [
"pykefcontrol==0.5.1"
],
"codeowners": [
"@n0ciple"
"pykefcontrol==0.7.1"
],
"iot_class": "local_polling"
"version":"0.6.0"
}
105 changes: 85 additions & 20 deletions custom_components/kef_connector/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,46 +23,83 @@
STATE_PLAYING,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
import homeassistant.helpers.aiohttp_client as hass_aiohttp
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as hass_dt
from homeassistant.helpers.device_registry import format_mac

# from homeassistant.helpers.entity_component import EntityComponent
# from homeassistant.helpers.entity_platform import AddEntitiesCallback
# from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util

_LOGGER = logging.getLogger(__name__)

CONF_MAX_VOLUME = "maximum_volume"
CONF_VOLUME_STEP = "volume_step"
CONF_SPEAKER_MODEL = "speaker_model"

DEFAULT_NAME = "DEFAULT_KEFLS50W2"
DEFAULT_NAME = "DEFAULT_KEFSPEAKER"
DEFAULT_MAX_VOLUME = 1
DEFAULT_VOLUME_STEP = 0.03
DEFAULT_SPEAKER_MODEL = "default"

SCAN_INTERVAL = timedelta(seconds=10)


DOMAIN = "kef_connector"

SOURCES = ["wifi", "bluetooth", "tv", "optical", "coaxial", "analog"]
SOURCES = {
"LSX2": ["wifi", "bluetooth", "tv", "optical", "analog", "usb"],
"LSX2LT": ["wifi", "bluetooth", "tv", "optical", "usb"],
"LS50W2": ["wifi", "bluetooth", "tv", "optical", "coaxial", "analog"],
"LS60": ["wifi", "bluetooth", "tv", "optical", "coaxial", "analog"],
"default": ["wifi", "bluetooth", "tv", "optical", "coaxial", "analog", "usb"],
}

UNIQUE_ID_PREFIX = "KEF_SPEAKER"

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
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_SPEAKER_MODEL, default=DEFAULT_SPEAKER_MODEL): cv.string,
}
)


def migrate_old_unique_ids(hass: HomeAssistant):
"""Migrate old unique ids to new format."""
registry = er.async_get(hass)
for entity in registry.entities.values():
if entity.platform == DOMAIN:
entity_mac_address = entity.unique_id.split("_")[-1]
if entity.unique_id == "KEFLS50W2_" + entity_mac_address:
_LOGGER.warning(
"Kef Connector found an entity with an old unique_id: %s. It will automatically migrate it to the new unique_id scheme. The entity is : %s",
entity.unique_id,
entity,
)
registry.async_update_entity(
entity.entity_id,
new_unique_id=f"{UNIQUE_ID_PREFIX}_{format_mac(entity_mac_address)}",
)


# Create new class from KefAsyncConnector to override the
# resurect_session method, so that i uses the function
# async_get_clientsession
class KefHassAsyncConnector(KefAsyncConnector):
"""KefAsyncConnector with resurect_session method."""

def __init__(self, host, session=None, hass=None) -> None:
def __init__(
self,
host,
session=None,
hass: HomeAssistant | None = None,
) -> None:
"""Initialize the KefAsyncConnector."""

super().__init__(host, session=session)
Expand Down Expand Up @@ -95,21 +132,36 @@ async def wrapper(self, *args, **kwargs):
return inner_function


async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
async def async_setup_platform(
hass: HomeAssistant | None,
config,
async_add_entities,
discovery_info=None,
):
"""Set up platform kef_connector."""

# Get variables from configuration
host = config[CONF_HOST]
name = config[CONF_NAME]
max_volume = config[CONF_MAX_VOLUME]
volume_step = config[CONF_VOLUME_STEP]
speaker_model = config[CONF_SPEAKER_MODEL]

# set available sources
sources = SOURCES
# make sure the speaker model is in uppercase
speaker_model = speaker_model.upper()

# get session
session = hass_aiohttp.async_create_clientsession(hass)

if speaker_model not in SOURCES:
sources = SOURCES["default"]
_LOGGER.warning(
"Kef Speaker model %s is unknown. Using default sources. Please make sure the model is either LSX2, LSX2LT, LS50W2 or LS60",
speaker_model,
)
else:
sources = SOURCES[speaker_model]

_LOGGER.debug(
"Setting up %s with host: %s, name: %s, sources: %s",
DOMAIN,
Expand All @@ -118,7 +170,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
sources,
)

media_player = KefLS50W2(
# Migrate old unique ids starting with "KEFLS50W2_" to the new format "KEF_SPEAKER_" + mac_address
migrate_old_unique_ids(hass)

media_player = KefSpeaker(
host, name, max_volume, volume_step, sources, session, hass
)

Expand All @@ -127,16 +182,23 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
return True


class KefLS50W2(MediaPlayerEntity):
"""Media player implementation for KEF LS50W2."""
class KefSpeaker(MediaPlayerEntity):
"""Media player implementation for KEF Speakers."""

def __init__(
self, host, name, max_volume, volume_step, sources, session, hass
self,
host,
name,
max_volume,
volume_step,
sources,
session,
hass: HomeAssistant | None,
) -> None:
"""Initialize the media player."""
super().__init__()
self._speaker = KefHassAsyncConnector(host, session=session, hass=hass)
if name != "DEFAULT_KEFLS50W2":
if name != DEFAULT_NAME:
self._name = name
else:
self._name = None
Expand Down Expand Up @@ -265,24 +327,27 @@ async def async_update(self):
if self.name is None:
self._name = await self._speaker.speaker_name
if self.unique_id is None:
self._attr_unique_id = "KEFLS50W2_" + await self._speaker.mac_address
self._attr_unique_id = (
f"{UNIQUE_ID_PREFIX}_{format_mac(await self._speaker.mac_address)}"
)

# Get speaker volume (from [0,100] to [0,1])
self._volume = await self._speaker.volume / 100

# Get speaker state.
# Playing/Idle is available only for bluetooth or wifi
spkr_source = await self._speaker.source
if await self._speaker.status == "standby":
self._state = STATE_OFF
elif await self._speaker.source in ["tv", "optical", "coaxial", "analog"]:
self._state = STATE_ON
else:
elif spkr_source in ["wifi", "bluetooth"]:
if await self._speaker.is_playing:
self._state = STATE_PLAYING
elif self._attr_media_title is not None:
self._state = STATE_PAUSED
else:
self._state = STATE_IDLE
else:
self._state = STATE_ON

# Check if speaker is muted
self._muted = True if self._volume == 0 else False
Expand All @@ -308,7 +373,7 @@ async def async_update(self):
# Update media position
self._attr_media_position = int(await self._speaker.song_status / 1000)
# Update last media position update
self._attr_media_position_updated_at = hass_dt.utcnow()
self._attr_media_position_updated_at = dt_util.utcnow()
else:
# Set values to None if no media is playing
self._attr_media_duration = None
Expand Down

0 comments on commit 0d9f8d4

Please sign in to comment.