From e5b04cedf3060e3555eae07c5d814e08568f694c Mon Sep 17 00:00:00 2001 From: Anton Malko Date: Fri, 10 Dec 2021 21:52:51 +0300 Subject: [PATCH] Add media_player platform to Lookin (#61337) Co-authored-by: J. Nick Koston --- .coveragerc | 1 + homeassistant/components/lookin/__init__.py | 16 +- homeassistant/components/lookin/climate.py | 31 ++- homeassistant/components/lookin/const.py | 4 +- homeassistant/components/lookin/entity.py | 52 +---- homeassistant/components/lookin/manifest.json | 2 +- .../components/lookin/media_player.py | 213 ++++++++++++++++++ homeassistant/components/lookin/sensor.py | 10 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 10 files changed, 264 insertions(+), 69 deletions(-) create mode 100644 homeassistant/components/lookin/media_player.py diff --git a/.coveragerc b/.coveragerc index 7ea4fa34a13..32a9d586c52 100644 --- a/.coveragerc +++ b/.coveragerc @@ -604,6 +604,7 @@ omit = homeassistant/components/lookin/models.py homeassistant/components/lookin/sensor.py homeassistant/components/lookin/climate.py + homeassistant/components/lookin/media_player.py homeassistant/components/luci/device_tracker.py homeassistant/components/luftdaten/__init__.py homeassistant/components/luftdaten/sensor.py diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 5e603027a50..2dd58c0982b 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -10,9 +10,9 @@ from aiolookin import ( LookInHttpProtocol, LookinUDPSubscriptions, MeteoSensor, - SensorID, start_lookin_udp, ) +from aiolookin.models import UDPCommandType, UDPEvent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST @@ -53,22 +53,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await meteo_coordinator.async_config_entry_first_refresh() @callback - def _async_meteo_push_update(msg: dict[str, str]) -> None: + def _async_meteo_push_update(event: UDPEvent) -> None: """Process an update pushed via UDP.""" - if int(msg["event_id"]): - return - LOGGER.debug("Processing push message for meteo sensor: %s", msg) + LOGGER.debug("Processing push message for meteo sensor: %s", event) meteo: MeteoSensor = meteo_coordinator.data - meteo.update_from_value(msg["value"]) + meteo.update_from_value(event.value) meteo_coordinator.async_set_updated_data(meteo) lookin_udp_subs = LookinUDPSubscriptions() entry.async_on_unload( - lookin_udp_subs.subscribe_sensor( - lookin_device.id, SensorID.Meteo, None, _async_meteo_push_update + lookin_udp_subs.subscribe_event( + lookin_device.id, UDPCommandType.meteo, None, _async_meteo_push_update ) ) - entry.async_on_unload(await start_lookin_udp(lookin_udp_subs)) + entry.async_on_unload(await start_lookin_udp(lookin_udp_subs, lookin_device.id)) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = LookinData( lookin_udp_subs=lookin_udp_subs, diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index 356b57453bc..74e888745b6 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -6,7 +6,8 @@ from datetime import timedelta import logging from typing import Any, Final, cast -from aiolookin import Climate, MeteoSensor, SensorID +from aiolookin import Climate, MeteoSensor +from aiolookin.models import UDPCommandType, UDPEvent from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( @@ -116,6 +117,7 @@ async def async_setup_entry( class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): """An aircon or heat pump.""" + _attr_current_humidity: float | None = None # type: ignore _attr_temperature_unit = TEMP_CELSIUS _attr_supported_features: int = SUPPORT_FLAGS _attr_fan_modes: list[str] = LOOKIN_FAN_MODE_IDX_TO_HASS @@ -198,6 +200,7 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): def _async_update_from_data(self) -> None: """Update attrs from data.""" meteo_data: MeteoSensor = self._meteo_coordinator.data + self._attr_current_temperature = meteo_data.temperature self._attr_current_humidity = int(meteo_data.humidity) self._attr_target_temperature = self._climate.temp_celsius @@ -205,6 +208,12 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): self._attr_swing_mode = LOOKIN_SWING_MODE_IDX_TO_HASS[self._climate.swing_mode] self._attr_hvac_mode = LOOKIN_HVAC_MODE_IDX_TO_HASS[self._climate.hvac_mode] + @callback + def _async_update_meteo_from_value(self, event: UDPEvent) -> None: + """Update temperature and humidity from UDP event.""" + self._attr_current_temperature = float(int(event.value[:4], 16)) / 10 + self._attr_current_humidity = float(int(event.value[-4:], 16)) / 10 + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -212,20 +221,28 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): super()._handle_coordinator_update() @callback - def _async_push_update(self, msg: dict[str, str]) -> None: + def _async_push_update(self, event: UDPEvent) -> None: """Process an update pushed via UDP.""" - LOGGER.debug("Processing push message for %s: %s", self.entity_id, msg) - self._climate.update_from_status(msg["value"]) + LOGGER.debug("Processing push message for %s: %s", self.entity_id, event) + self._climate.update_from_status(event.value) self.coordinator.async_set_updated_data(self._climate) async def async_added_to_hass(self) -> None: """Call when the entity is added to hass.""" self.async_on_remove( - self._lookin_udp_subs.subscribe_sensor( - self._lookin_device.id, SensorID.IR, self._uuid, self._async_push_update + self._lookin_udp_subs.subscribe_event( + self._lookin_device.id, + UDPCommandType.ir, + self._uuid, + self._async_push_update, ) ) self.async_on_remove( - self._meteo_coordinator.async_add_listener(self._handle_coordinator_update) + self._lookin_udp_subs.subscribe_event( + self._lookin_device.id, + UDPCommandType.meteo, + None, + self._async_update_meteo_from_value, + ) ) return await super().async_added_to_hass() diff --git a/homeassistant/components/lookin/const.py b/homeassistant/components/lookin/const.py index d9b1141aa97..c919dc30a79 100644 --- a/homeassistant/components/lookin/const.py +++ b/homeassistant/components/lookin/const.py @@ -5,5 +5,7 @@ from typing import Final from homeassistant.const import Platform +MODEL_NAMES: Final = ["LOOKin Remote", "LOOKin Remote", "LOOKin Remote2"] + DOMAIN: Final = "lookin" -PLATFORMS: Final = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS: Final = [Platform.CLIMATE, Platform.MEDIA_PLAYER, Platform.SENSOR] diff --git a/homeassistant/components/lookin/entity.py b/homeassistant/components/lookin/entity.py index ad532889771..c444407d5ae 100644 --- a/homeassistant/components/lookin/entity.py +++ b/homeassistant/components/lookin/entity.py @@ -4,13 +4,13 @@ from __future__ import annotations from aiolookin import POWER_CMD, POWER_OFF_CMD, POWER_ON_CMD, Climate, Remote from aiolookin.models import Device -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import DOMAIN +from .const import DOMAIN, MODEL_NAMES from .models import LookinData @@ -20,7 +20,7 @@ def _lookin_device_to_device_info(lookin_device: Device) -> DeviceInfo: identifiers={(DOMAIN, lookin_device.id)}, name=lookin_device.name, manufacturer="LOOKin", - model="LOOKin Remote2", + model=MODEL_NAMES[lookin_device.model], sw_version=lookin_device.firmware, ) @@ -46,19 +46,6 @@ class LookinDeviceMixIn: self._lookin_udp_subs = lookin_data.lookin_udp_subs -class LookinDeviceEntity(LookinDeviceMixIn, Entity): - """A lookin device entity on the device itself.""" - - _attr_should_poll = False - - def __init__(self, lookin_data: LookinData) -> None: - """Init the lookin device entity.""" - self._set_lookin_device_attrs(lookin_data) - self._attr_device_info = _lookin_device_to_device_info( - lookin_data.lookin_device - ) - - class LookinDeviceCoordinatorEntity(LookinDeviceMixIn, CoordinatorEntity): """A lookin device entity on the device itself that uses the coordinator.""" @@ -89,34 +76,6 @@ class LookinEntityMixIn: self._function_names = {function.name for function in self._device.functions} -class LookinEntity(LookinDeviceMixIn, LookinEntityMixIn, Entity): - """A base class for lookin entities.""" - - _attr_should_poll = False - _attr_assumed_state = True - - def __init__( - self, - uuid: str, - device: Remote | Climate, - lookin_data: LookinData, - ) -> None: - """Init the base entity.""" - self._set_lookin_device_attrs(lookin_data) - self._set_lookin_entity_attrs(uuid, device, lookin_data) - self._attr_device_info = _lookin_controlled_device_to_device_info( - self._lookin_device, uuid, device - ) - self._attr_unique_id = uuid - self._attr_name = device.name - - async def _async_send_command(self, command: str) -> None: - """Send command from saved IR device.""" - await self._lookin_protocol.send_command( - uuid=self._uuid, command=command, signal="FF" - ) - - class LookinCoordinatorEntity(LookinDeviceMixIn, LookinEntityMixIn, CoordinatorEntity): """A lookin device entity for an external device that uses the coordinator.""" @@ -147,17 +106,18 @@ class LookinCoordinatorEntity(LookinDeviceMixIn, LookinEntityMixIn, CoordinatorE ) -class LookinPowerEntity(LookinEntity): +class LookinPowerEntity(LookinCoordinatorEntity): """A Lookin entity that has a power on and power off command.""" def __init__( self, + coordinator: DataUpdateCoordinator, uuid: str, device: Remote | Climate, lookin_data: LookinData, ) -> None: """Init the power entity.""" - super().__init__(uuid, device, lookin_data) + super().__init__(coordinator, uuid, device, lookin_data) self._power_on_command: str = POWER_CMD self._power_off_command: str = POWER_CMD if POWER_ON_CMD in self._function_names: diff --git a/homeassistant/components/lookin/manifest.json b/homeassistant/components/lookin/manifest.json index 7260985654a..903f6b84d50 100644 --- a/homeassistant/components/lookin/manifest.json +++ b/homeassistant/components/lookin/manifest.json @@ -3,7 +3,7 @@ "name": "LOOKin", "documentation": "https://www.home-assistant.io/integrations/lookin/", "codeowners": ["@ANMalko"], - "requirements": ["aiolookin==0.0.4"], + "requirements": ["aiolookin==0.1.0"], "zeroconf": ["_lookin._tcp.local."], "config_flow": true, "iot_class": "local_push" diff --git a/homeassistant/components/lookin/media_player.py b/homeassistant/components/lookin/media_player.py new file mode 100644 index 00000000000..9aa105e8f87 --- /dev/null +++ b/homeassistant/components/lookin/media_player.py @@ -0,0 +1,213 @@ +"""The lookin integration light platform.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from datetime import timedelta +import logging +from typing import Any, cast + +from aiolookin import Remote +from aiolookin.models import UDPCommandType, UDPEvent + +from homeassistant.components.media_player import ( + DEVICE_CLASS_RECEIVER, + DEVICE_CLASS_TV, + MediaPlayerEntity, +) +from homeassistant.components.media_player.const import ( + SUPPORT_NEXT_TRACK, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_STEP, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, STATE_STANDBY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN +from .entity import LookinPowerEntity +from .models import LookinData + +LOGGER = logging.getLogger(__name__) + +_TYPE_TO_DEVICE_CLASS = {"01": DEVICE_CLASS_TV, "02": DEVICE_CLASS_RECEIVER} + +_FUNCTION_NAME_TO_FEATURE = { + "power": SUPPORT_TURN_OFF, + "poweron": SUPPORT_TURN_ON, + "poweroff": SUPPORT_TURN_OFF, + "mute": SUPPORT_VOLUME_MUTE, + "volup": SUPPORT_VOLUME_STEP, + "chup": SUPPORT_NEXT_TRACK, + "chdown": SUPPORT_PREVIOUS_TRACK, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the media_player platform for lookin from a config entry.""" + lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + + for remote in lookin_data.devices: + if remote["Type"] not in _TYPE_TO_DEVICE_CLASS: + continue + uuid = remote["UUID"] + + def _wrap_async_update( + uuid: str, + ) -> Callable[[], Coroutine[None, Any, Remote]]: + """Create a function to capture the uuid cell variable.""" + + async def _async_update() -> Remote: + return await lookin_data.lookin_protocol.get_remote(uuid) + + return _async_update + + coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name=f"{config_entry.title} {uuid}", + update_method=_wrap_async_update(uuid), + update_interval=timedelta( + seconds=60 + ), # Updates are pushed (fallback is polling) + ) + await coordinator.async_refresh() + device: Remote = coordinator.data + + entities.append( + LookinMedia( + uuid=uuid, + device=device, + lookin_data=lookin_data, + device_class=_TYPE_TO_DEVICE_CLASS[remote["Type"]], + coordinator=coordinator, + ) + ) + + async_add_entities(entities) + + +class LookinMedia(LookinPowerEntity, MediaPlayerEntity): + """A lookin media player.""" + + _attr_should_poll = False + + def __init__( + self, + uuid: str, + device: Remote, + lookin_data: LookinData, + device_class: str, + coordinator: DataUpdateCoordinator, + ) -> None: + """Init the lookin media player.""" + self._attr_device_class = device_class + self._attr_supported_features: int = 0 + self._attr_state = None + self._attr_is_volume_muted: bool = False + super().__init__(coordinator, uuid, device, lookin_data) + for function_name, feature in _FUNCTION_NAME_TO_FEATURE.items(): + if function_name in self._function_names: + self._attr_supported_features |= feature + self._attr_name = self._remote.name + self._async_update_from_data() + + @property + def _remote(self) -> Remote: + return cast(Remote, self.coordinator.data) + + async def async_volume_up(self) -> None: + """Turn volume up for media player.""" + await self._async_send_command("volup") + + async def async_volume_down(self) -> None: + """Turn volume down for media player.""" + await self._async_send_command("voldown") + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + await self._async_send_command("chdown") + + async def async_media_next_track(self) -> None: + """Send next track command.""" + await self._async_send_command("chup") + + async def async_mute_volume(self, mute: bool) -> None: + """Mute the volume.""" + await self._async_send_command("mute") + self._attr_is_volume_muted = not self.is_volume_muted + self.async_write_ha_state() + + async def async_turn_off(self) -> None: + """Turn the media player off.""" + await self._async_send_command(self._power_off_command) + self._attr_state = STATE_STANDBY + self.async_write_ha_state() + + async def async_turn_on(self) -> None: + """Turn the media player on.""" + await self._async_send_command(self._power_on_command) + self._attr_state = STATE_ON + self.async_write_ha_state() + + def _update_from_status(self, status: str) -> None: + """Update media property from status. + + 00F0 + 0 - 0/1 on/off + 0 - sourse + F - volume, 0 - muted, 1 - volume up, F - volume down + 0 - not used + """ + if len(status) != 4: + return + state = status[0] + mute = status[2] + + self._attr_state = STATE_STANDBY if state == "1" else STATE_ON + self._attr_is_volume_muted = mute == "0" + + def _async_push_update(self, event: UDPEvent) -> None: + """Process an update pushed via UDP.""" + LOGGER.debug("Processing push message for %s: %s", self.entity_id, event) + self._update_from_status(event.value) + self.coordinator.async_set_updated_data(self._remote) + self.async_write_ha_state() + + async def _async_push_update_device(self, event: UDPEvent) -> None: + """Process an update pushed via UDP.""" + LOGGER.debug("Processing push message for %s: %s", self.entity_id, event) + await self.coordinator.async_refresh() + self._attr_name = self._remote.name + + async def async_added_to_hass(self) -> None: + """Call when the entity is added to hass.""" + self.async_on_remove( + self._lookin_udp_subs.subscribe_event( + self._lookin_device.id, + UDPCommandType.ir, + self._uuid, + self._async_push_update, + ) + ) + self.async_on_remove( + self._lookin_udp_subs.subscribe_event( + self._lookin_device.id, + UDPCommandType.data, + self._uuid, + self._async_push_update_device, + ) + ) + + def _async_update_from_data(self) -> None: + """Update attrs from data.""" + self._update_from_status(self._remote.status) diff --git a/homeassistant/components/lookin/sensor.py b/homeassistant/components/lookin/sensor.py index b320f5d537a..7b3972b7ce3 100644 --- a/homeassistant/components/lookin/sensor.py +++ b/homeassistant/components/lookin/sensor.py @@ -48,9 +48,13 @@ async def async_setup_entry( """Set up lookin sensors from the config entry.""" lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - [LookinSensorEntity(description, lookin_data) for description in SENSOR_TYPES] - ) + if lookin_data.lookin_device.model >= 2: + async_add_entities( + [ + LookinSensorEntity(description, lookin_data) + for description in SENSOR_TYPES + ] + ) class LookinSensorEntity(LookinDeviceCoordinatorEntity, SensorEntity): diff --git a/requirements_all.txt b/requirements_all.txt index a2c093cb4e5..117cedd7e69 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -213,7 +213,7 @@ aiolifx_effects==0.2.2 aiolip==1.1.6 # homeassistant.components.lookin -aiolookin==0.0.4 +aiolookin==0.1.0 # homeassistant.components.lyric aiolyric==1.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9fe243edb5b..2f292840940 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ aiokafka==0.6.0 aiolip==1.1.6 # homeassistant.components.lookin -aiolookin==0.0.4 +aiolookin==0.1.0 # homeassistant.components.lyric aiolyric==1.0.8