diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 969c6c7b837..b81fec90a59 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -36,6 +36,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.REMOTE, Platform.SWITCH, + Platform.BINARY_SENSOR, ] LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py new file mode 100644 index 00000000000..78aa9f17b05 --- /dev/null +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -0,0 +1,105 @@ +"""Philips TV binary sensors.""" +from __future__ import annotations + +from dataclasses import dataclass + +from haphilipsjs import PhilipsTV + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PhilipsTVDataUpdateCoordinator +from .const import DOMAIN +from .entity import PhilipsJsEntity + + +@dataclass +class PhilipsTVBinarySensorEntityDescription(BinarySensorEntityDescription): + """A entity description for Philips TV binary sensor.""" + + def __init__(self, recording_value, *args, **kwargs) -> None: + """Set up a binary sensor entity description and add additional attributes.""" + super().__init__(*args, **kwargs) + self.recording_value: str = recording_value + + +DESCRIPTIONS = ( + PhilipsTVBinarySensorEntityDescription( + key="recording_ongoing", + translation_key="recording_ongoing", + icon="mdi:record-rec", + recording_value="RECORDING_ONGOING", + ), + PhilipsTVBinarySensorEntityDescription( + key="recording_new", + translation_key="recording_new", + icon="mdi:new-box", + recording_value="RECORDING_NEW", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the configuration entry.""" + coordinator: PhilipsTVDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + if ( + coordinator.api.json_feature_supported("recordings", "List") + and coordinator.api.api_version == 6 + ): + async_add_entities( + PhilipsTVBinarySensorEntityRecordingType(coordinator, description) + for description in DESCRIPTIONS + ) + + +def _check_for_recording_entry(api: PhilipsTV, entry: str, value: str) -> bool: + """Return True if at least one specified value is available within entry of list.""" + for rec in api.recordings_list["recordings"]: + if rec.get(entry) == value: + return True + return False + + +class PhilipsTVBinarySensorEntityRecordingType(PhilipsJsEntity, BinarySensorEntity): + """A Philips TV binary sensor class, which allows multiple entities given by a BinarySensorEntityDescription.""" + + entity_description: PhilipsTVBinarySensorEntityDescription + + def __init__( + self, + coordinator: PhilipsTVDataUpdateCoordinator, + description: PhilipsTVBinarySensorEntityDescription, + ) -> None: + """Initialize entity class.""" + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" + self._attr_device_info = coordinator.device_info + self._attr_is_on = _check_for_recording_entry( + coordinator.api, + "RecordingType", + description.recording_value, + ) + + super().__init__(coordinator) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator and set is_on true if one specified value is available within given entry of list.""" + self._attr_is_on = _check_for_recording_entry( + self.coordinator.api, + "RecordingType", + self.entity_description.recording_value, + ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index 6c738a36df3..3ea632ce436 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -44,6 +44,14 @@ } }, "entity": { + "binary_sensor": { + "recording_new": { + "name": "New recording available" + }, + "recording_ongoing": { + "name": "Recording ongoing" + } + }, "light": { "ambilight": { "name": "Ambilight" diff --git a/tests/components/philips_js/__init__.py b/tests/components/philips_js/__init__.py index f524a586fc8..60e8b238917 100644 --- a/tests/components/philips_js/__init__.py +++ b/tests/components/philips_js/__init__.py @@ -73,3 +73,129 @@ MOCK_CONFIG_PAIRED = { } MOCK_ENTITY_ID = "media_player.philips_tv" + +MOCK_RECORDINGS_LIST = { + "version": "253.91", + "recordings": [ + { + "RecordingId": 36, + "RecordingType": "RECORDING_ONGOING", + "IsIpEpgRec": False, + "ccid": 2091, + "StartTime": 1676833531, + "Duration": 569, + "MarginStart": 0, + "MarginEnd": 0, + "EventId": 47369, + "EITVersion": 0, + "RetentionInfo": 0, + "EventInfo": "This is a event info which is not rejected by codespell.", + "EventExtendedInfo": "", + "EventGenre": "8", + "RecName": "Terra X", + "SeriesID": "None", + "SeasonNo": 0, + "EpisodeNo": 0, + "EpisodeCount": 72300, + "ProgramNumber": 11110, + "EventRating": 0, + "hasDot": True, + "isFTARecording": False, + "LastPinChangedTime": 0, + "Version": 344, + "HasCicamPin": False, + "HasLicenseFile": False, + "Size": 0, + "ResumeInfo": 0, + "IsPartial": False, + "AutoMarginStart": 0, + "AutoMarginEnd": 0, + "ServerRecordingId": -1, + "ActualStartTime": 1676833531, + "ProgramDuration": 0, + "IsRadio": False, + "EITSource": "EIT_SOURCE_PF", + "RecError": "REC_ERROR_NONE", + }, + { + "RecordingId": 35, + "RecordingType": "RECORDING_NEW", + "IsIpEpgRec": False, + "ccid": 2091, + "StartTime": 1676832212, + "Duration": 22, + "MarginStart": 0, + "MarginEnd": 0, + "EventId": 47369, + "EITVersion": 0, + "RetentionInfo": -1, + "EventInfo": "This is another event info which is not rejected by codespell.", + "EventExtendedInfo": "", + "EventGenre": "8", + "RecName": "Terra X", + "SeriesID": "None", + "SeasonNo": 0, + "EpisodeNo": 0, + "EpisodeCount": 70980, + "ProgramNumber": 11110, + "EventRating": 0, + "hasDot": True, + "isFTARecording": False, + "LastPinChangedTime": 0, + "Version": 339, + "HasCicamPin": False, + "HasLicenseFile": False, + "Size": 0, + "ResumeInfo": 0, + "IsPartial": False, + "AutoMarginStart": 0, + "AutoMarginEnd": 0, + "ServerRecordingId": -1, + "ActualStartTime": 1676832212, + "ProgramDuration": 0, + "IsRadio": False, + "EITSource": "EIT_SOURCE_PF", + "RecError": "REC_ERROR_NONE", + }, + { + "RecordingId": 34, + "RecordingType": "RECORDING_PARTIALLY_VIEWED", + "IsIpEpgRec": False, + "ccid": 2091, + "StartTime": 1676677580, + "Duration": 484, + "MarginStart": 0, + "MarginEnd": 0, + "EventId": -1, + "EITVersion": 0, + "RetentionInfo": -1, + "EventInfo": "\n\nAlpine Ski-WM: Parallel-Event, Übertragung aus Méribel/Frankreich\n\n14:10: Biathlon-WM (AD): 20 km Einzel Männer, Übertragung aus Oberhof\nHD-Produktion", + "EventExtendedInfo": "", + "EventGenre": "4", + "RecName": "ZDF HD 2023-02-18 00:46", + "SeriesID": "None", + "SeasonNo": 0, + "EpisodeNo": 0, + "EpisodeCount": 2760, + "ProgramNumber": 11110, + "EventRating": 0, + "hasDot": True, + "isFTARecording": False, + "LastPinChangedTime": 0, + "Version": 328, + "HasCicamPin": False, + "HasLicenseFile": False, + "Size": 0, + "ResumeInfo": 56, + "IsPartial": False, + "AutoMarginStart": 0, + "AutoMarginEnd": 0, + "ServerRecordingId": -1, + "ActualStartTime": 1676677581, + "ProgramDuration": 0, + "IsRadio": False, + "EITSource": "EIT_SOURCE_PF", + "RecError": "REC_ERROR_NONE", + }, + ], +} diff --git a/tests/components/philips_js/test_binary_sensor.py b/tests/components/philips_js/test_binary_sensor.py new file mode 100644 index 00000000000..d11f3fe22f1 --- /dev/null +++ b/tests/components/philips_js/test_binary_sensor.py @@ -0,0 +1,60 @@ +"""The tests for philips_js binary_sensor.""" +import pytest + +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant + +from . import MOCK_NAME, MOCK_RECORDINGS_LIST + +ID_RECORDING_AVAILABLE = ( + "binary_sensor." + MOCK_NAME.replace(" ", "_").lower() + "_new_recording_available" +) +ID_RECORDING_ONGOING = ( + "binary_sensor." + MOCK_NAME.replace(" ", "_").lower() + "_recording_ongoing" +) + + +@pytest.fixture +async def mock_tv_api_invalid(mock_tv): + """Set up a invalid mock_tv with should not create sensors.""" + mock_tv.secured_transport = True + mock_tv.api_version = 1 + mock_tv.recordings_list = None + return mock_tv + + +@pytest.fixture +async def mock_tv_api_valid(mock_tv): + """Set up a valid mock_tv with should create sensors.""" + mock_tv.secured_transport = True + mock_tv.api_version = 6 + mock_tv.recordings_list = MOCK_RECORDINGS_LIST + return mock_tv + + +async def test_recordings_list_invalid( + mock_tv_api_invalid, mock_config_entry, hass: HomeAssistant +) -> None: + """Test if sensors are not created if mock_tv is invalid.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(ID_RECORDING_AVAILABLE) + assert state is None + + state = hass.states.get(ID_RECORDING_ONGOING) + assert state is None + + +async def test_recordings_list_valid( + mock_tv_api_valid, mock_config_entry, hass: HomeAssistant +) -> None: + """Test if sensors are created correctly.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(ID_RECORDING_AVAILABLE) + assert state.state is STATE_ON + + state = hass.states.get(ID_RECORDING_ONGOING) + assert state.state is STATE_ON