From 4cd460351d99c5a17b3d34329d684c8ea1476504 Mon Sep 17 00:00:00 2001 From: wollew Date: Fri, 5 Dec 2025 11:43:19 +0100 Subject: [PATCH] Add Squeezebox binary sensors for player alarm status (#154491) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Martin Hjelmare --- .../components/squeezebox/binary_sensor.py | 79 ++++++++++- homeassistant/components/squeezebox/const.py | 3 + .../components/squeezebox/strings.json | 9 ++ tests/components/squeezebox/conftest.py | 3 + .../squeezebox/test_binary_sensor.py | 125 ++++++++++++++++-- tests/components/squeezebox/test_switch.py | 2 +- 6 files changed, 200 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/squeezebox/binary_sensor.py b/homeassistant/components/squeezebox/binary_sensor.py index ea305d71f99..f23d807cd19 100644 --- a/homeassistant/components/squeezebox/binary_sensor.py +++ b/homeassistant/components/squeezebox/binary_sensor.py @@ -10,17 +10,26 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SqueezeboxConfigEntry -from .const import STATUS_SENSOR_NEEDSRESTART, STATUS_SENSOR_RESCAN -from .entity import LMSStatusEntity +from . import SqueezeboxConfigEntry, SqueezeBoxPlayerUpdateCoordinator +from .const import ( + PLAYER_SENSOR_ALARM_ACTIVE, + PLAYER_SENSOR_ALARM_SNOOZE, + PLAYER_SENSOR_ALARM_UPCOMING, + SIGNAL_PLAYER_DISCOVERED, + STATUS_SENSOR_NEEDSRESTART, + STATUS_SENSOR_RESCAN, +) +from .entity import LMSStatusEntity, SqueezeboxEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 -SENSORS: tuple[BinarySensorEntityDescription, ...] = ( +SERVER_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key=STATUS_SENSOR_RESCAN, device_class=BinarySensorDeviceClass.RUNNING, @@ -32,6 +41,23 @@ SENSORS: tuple[BinarySensorEntityDescription, ...] = ( ), ) +PLAYER_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key=PLAYER_SENSOR_ALARM_UPCOMING, + translation_key=PLAYER_SENSOR_ALARM_UPCOMING, + ), + BinarySensorEntityDescription( + key=PLAYER_SENSOR_ALARM_ACTIVE, + translation_key=PLAYER_SENSOR_ALARM_ACTIVE, + device_class=BinarySensorDeviceClass.RUNNING, + ), + BinarySensorEntityDescription( + key=PLAYER_SENSOR_ALARM_SNOOZE, + translation_key=PLAYER_SENSOR_ALARM_SNOOZE, + device_class=BinarySensorDeviceClass.RUNNING, + ), +) + _LOGGER = logging.getLogger(__name__) @@ -42,9 +68,29 @@ async def async_setup_entry( ) -> None: """Platform setup using common elements.""" + @callback + def _player_discovered( + player_coordinator: SqueezeBoxPlayerUpdateCoordinator, + ) -> None: + _LOGGER.debug( + "Setting up binary sensor entities for player %s, model %s", + player_coordinator.player.name, + player_coordinator.player.model, + ) + + async_add_entities( + SqueezeboxBinarySensorEntity(player_coordinator, description) + for description in PLAYER_SENSORS + ) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_PLAYER_DISCOVERED}{entry.entry_id}", _player_discovered + ) + ) async_add_entities( ServerStatusBinarySensor(entry.runtime_data.coordinator, description) - for description in SENSORS + for description in SERVER_SENSORS ) @@ -55,3 +101,24 @@ class ServerStatusBinarySensor(LMSStatusEntity, BinarySensorEntity): def is_on(self) -> bool: """LMS Status directly from coordinator data.""" return bool(self.coordinator.data[self.entity_description.key]) + + +class SqueezeboxBinarySensorEntity(SqueezeboxEntity, BinarySensorEntity): + """Representation of player based binary sensors.""" + + description: BinarySensorEntityDescription + + def __init__( + self, + coordinator: SqueezeBoxPlayerUpdateCoordinator, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize the SqueezeBox sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{format_mac(self._player.player_id)}_{description.key}" + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return getattr(self.coordinator.player, self.entity_description.key, None) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index b61d28943cf..3d38ffadb19 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -19,6 +19,9 @@ STATUS_SENSOR_INFO_TOTAL_GENRES = "info total genres" STATUS_SENSOR_INFO_TOTAL_SONGS = "info total songs" STATUS_SENSOR_PLAYER_COUNT = "player count" STATUS_SENSOR_OTHER_PLAYER_COUNT = "other player count" +PLAYER_SENSOR_ALARM_UPCOMING = "alarm_upcoming" +PLAYER_SENSOR_ALARM_SNOOZE = "alarm_snooze" +PLAYER_SENSOR_ALARM_ACTIVE = "alarm_active" STATUS_QUERY_LIBRARYNAME = "libraryname" STATUS_QUERY_MAC = "mac" STATUS_QUERY_UUID = "uuid" diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index af712ec8341..3333c143c81 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -41,6 +41,15 @@ }, "entity": { "binary_sensor": { + "alarm_active": { + "name": "Alarm active" + }, + "alarm_snooze": { + "name": "Alarm snoozed" + }, + "alarm_upcoming": { + "name": "Alarm upcoming" + }, "needsrestart": { "name": "Needs restart" }, diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 516a574658d..d1b4c5dfabc 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -297,6 +297,9 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.model_type = None mock_player.firmware = None mock_player.alarms_enabled = True + mock_player.alarm_upcoming = True + mock_player.alarm_snooze = False + mock_player.alarm_active = False return mock_player diff --git a/tests/components/squeezebox/test_binary_sensor.py b/tests/components/squeezebox/test_binary_sensor.py index 71cb5ceb105..5c298f06332 100644 --- a/tests/components/squeezebox/test_binary_sensor.py +++ b/tests/components/squeezebox/test_binary_sensor.py @@ -1,30 +1,39 @@ """Test squeezebox binary sensors.""" from copy import deepcopy -from unittest.mock import patch +from datetime import timedelta +from unittest.mock import MagicMock, patch -from homeassistant.const import Platform +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.squeezebox.const import PLAYER_UPDATE_INTERVAL +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from .conftest import FAKE_QUERY_RESPONSE -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed -async def test_binary_sensor( +@pytest.fixture(autouse=True) +def squeezebox_binary_sensor_platform(): + """Only set up the binary_sensor platform for squeezebox tests.""" + with patch( + "homeassistant.components.squeezebox.PLATFORMS", [Platform.BINARY_SENSOR] + ): + yield + + +async def test_binary_server_sensor( hass: HomeAssistant, config_entry: MockConfigEntry, ) -> None: """Test binary sensor states and attributes.""" - with ( - patch( - "homeassistant.components.squeezebox.PLATFORMS", - [Platform.BINARY_SENSOR], - ), - patch( - "homeassistant.components.squeezebox.Server.async_query", - return_value=deepcopy(FAKE_QUERY_RESPONSE), - ), + with patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=deepcopy(FAKE_QUERY_RESPONSE), ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) @@ -32,4 +41,92 @@ async def test_binary_sensor( state = hass.states.get("binary_sensor.fakelib_needs_restart") assert state is not None - assert state.state == "off" + assert state.state == STATE_OFF + + +@pytest.fixture +async def mock_player( + hass: HomeAssistant, + config_entry: MockConfigEntry, + lms: MagicMock, +) -> MagicMock: + """Set up the squeezebox integration and return the mocked player.""" + + # Mock server status data for coordinator update + # called on update, return something != None to not raise + lms.async_prepared_status.return_value = { + "dummy": False, + } + with patch("homeassistant.components.squeezebox.Server", return_value=lms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + # Return the player mock + return (await lms.async_get_players())[0] + + +async def test_player_alarm_sensors_device_class( + hass: HomeAssistant, + mock_player: MagicMock, +) -> None: + """Test player alarm binary sensors have correct device class.""" + + # Test alarm upcoming sensor device class + upcoming_state = hass.states.get("binary_sensor.none_alarm_upcoming") + assert upcoming_state is not None + assert upcoming_state.attributes.get("device_class") is None + + # Test alarm active sensor device class + active_state = hass.states.get("binary_sensor.none_alarm_active") + assert active_state is not None + assert ( + active_state.attributes.get("device_class") == BinarySensorDeviceClass.RUNNING + ) + + # Test alarm snooze sensor device class + snooze_state = hass.states.get("binary_sensor.none_alarm_snoozed") + assert snooze_state is not None + assert ( + snooze_state.attributes.get("device_class") == BinarySensorDeviceClass.RUNNING + ) + + +async def test_player_alarm_sensors_state( + hass: HomeAssistant, + mock_player: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test player alarm binary sensors with default states.""" + + player = mock_player + + # Test alarm upcoming sensor + upcoming_state = hass.states.get("binary_sensor.none_alarm_upcoming") + assert upcoming_state is not None + assert upcoming_state.state == STATE_ON + + # Test alarm active sensor + active_state = hass.states.get("binary_sensor.none_alarm_active") + assert active_state is not None + assert active_state.state == STATE_OFF + + # Test alarm snooze sensor + snooze_state = hass.states.get("binary_sensor.none_alarm_snoozed") + assert snooze_state is not None + assert snooze_state.state == STATE_OFF + + # Toggle alarm states and verify sensors update + player.alarm_upcoming = False + player.alarm_active = True + player.alarm_snooze = True + freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + upcoming_state = hass.states.get("binary_sensor.none_alarm_upcoming") + assert upcoming_state is not None + assert upcoming_state.state == STATE_OFF + + active_state = hass.states.get("binary_sensor.none_alarm_active") + assert active_state is not None + assert active_state.state == STATE_ON diff --git a/tests/components/squeezebox/test_switch.py b/tests/components/squeezebox/test_switch.py index 368fa1bf84a..093a3da054b 100644 --- a/tests/components/squeezebox/test_switch.py +++ b/tests/components/squeezebox/test_switch.py @@ -25,7 +25,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat @pytest.fixture(autouse=True) def squeezebox_alarm_platform(): - """Only set up the media_player platform for squeezebox tests.""" + """Only set up the switch platform for squeezebox tests.""" with patch("homeassistant.components.squeezebox.PLATFORMS", [Platform.SWITCH]): yield