provide Squeezebox player sensor for next alarm timestamp (#155788)

This commit is contained in:
wollew
2025-12-22 11:53:38 +01:00
committed by GitHub
parent fd9064376a
commit 01c3e88e0f
5 changed files with 144 additions and 22 deletions

View File

@@ -19,9 +19,10 @@ 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"
PLAYER_SENSOR_ALARM_SNOOZE = "alarm_snooze"
PLAYER_SENSOR_ALARM_UPCOMING = "alarm_upcoming"
PLAYER_SENSOR_NEXT_ALARM = "alarm_next"
STATUS_QUERY_LIBRARYNAME = "libraryname"
STATUS_QUERY_MAC = "mac"
STATUS_QUERY_UUID = "uuid"

View File

@@ -2,6 +2,9 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
import logging
from typing import cast
@@ -13,11 +16,15 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import SqueezeboxConfigEntry
from .const import (
PLAYER_SENSOR_NEXT_ALARM,
SIGNAL_PLAYER_DISCOVERED,
STATUS_SENSOR_INFO_TOTAL_ALBUMS,
STATUS_SENSOR_INFO_TOTAL_ARTISTS,
STATUS_SENSOR_INFO_TOTAL_DURATION,
@@ -27,12 +34,12 @@ from .const import (
STATUS_SENSOR_OTHER_PLAYER_COUNT,
STATUS_SENSOR_PLAYER_COUNT,
)
from .entity import LMSStatusEntity
from .entity import LMSStatusEntity, SqueezeboxEntity, SqueezeBoxPlayerUpdateCoordinator
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
SENSORS: tuple[SensorEntityDescription, ...] = (
SERVER_STATUS_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=STATUS_SENSOR_INFO_TOTAL_ALBUMS,
state_class=SensorStateClass.TOTAL,
@@ -71,6 +78,23 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
),
)
@dataclass(frozen=True, kw_only=True)
class PlayerSensorEntityDescription(SensorEntityDescription):
"""Describes player sensor entity."""
value_fn: Callable[[SqueezeboxSensorEntity], datetime | None]
PLAYER_SENSORS: tuple[PlayerSensorEntityDescription, ...] = (
PlayerSensorEntityDescription(
key=PLAYER_SENSOR_NEXT_ALARM,
translation_key=PLAYER_SENSOR_NEXT_ALARM,
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda sensor: sensor.coordinator.player.alarm_next,
),
)
_LOGGER = logging.getLogger(__name__)
@@ -81,9 +105,30 @@ async def async_setup_entry(
) -> None:
"""Platform setup using common elements."""
# Add player sensor entities when player discovered
async def _player_discovered(
player_coordinator: SqueezeBoxPlayerUpdateCoordinator,
) -> None:
_LOGGER.debug(
"Setting up sensor entities for player %s, model %s",
player_coordinator.player.name,
player_coordinator.player.model,
)
async_add_entities(
SqueezeboxSensorEntity(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(
ServerStatusSensor(entry.runtime_data.coordinator, description)
for description in SENSORS
for description in SERVER_STATUS_SENSORS
)
@@ -94,3 +139,24 @@ class ServerStatusSensor(LMSStatusEntity, SensorEntity):
def native_value(self) -> StateType:
"""LMS Status directly from coordinator data."""
return cast(StateType, self.coordinator.data[self.entity_description.key])
class SqueezeboxSensorEntity(SqueezeboxEntity, SensorEntity):
"""Representation of player based sensors."""
entity_description: PlayerSensorEntityDescription
def __init__(
self,
coordinator: SqueezeBoxPlayerUpdateCoordinator,
description: PlayerSensorEntityDescription,
) -> 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 native_value(self) -> datetime | None:
"""Sensor value directly from player coordinator."""
return self.entity_description.value_fn(self)

View File

@@ -82,6 +82,9 @@
}
},
"sensor": {
"alarm_next": {
"name": "Next alarm"
},
"info_total_albums": {
"name": "Total albums",
"unit_of_measurement": "albums"

View File

@@ -1,7 +1,9 @@
"""Setup the squeezebox tests."""
from collections.abc import Generator
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from zoneinfo import ZoneInfo
import pytest
@@ -47,6 +49,14 @@ TEST_MAC = ["aa:bb:cc:dd:ee:ff", "de:ad:be:ef:de:ad", "ff:ee:dd:cc:bb:aa"]
TEST_PLAYER_NAME = "Test Player"
TEST_SERVER_NAME = "Test Server"
TEST_ALARM_ID = "1"
TEST_ALARM_NEXT_TIME = datetime(
year=1985,
month=10,
day=26,
hour=1,
minute=21,
tzinfo=ZoneInfo("UTC"),
)
FAKE_VALID_ITEM_ID = "1234"
FAKE_INVALID_ITEM_ID = "4321"
@@ -297,9 +307,10 @@ 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
mock_player.alarm_snooze = False
mock_player.alarm_upcoming = True
mock_player.alarm_next = TEST_ALARM_NEXT_TIME
return mock_player

View File

@@ -1,29 +1,37 @@
"""Test squeezebox 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.squeezebox.const import PLAYER_UPDATE_INTERVAL
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from .conftest import FAKE_QUERY_RESPONSE
from .conftest import FAKE_QUERY_RESPONSE, TEST_ALARM_NEXT_TIME
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_sensor(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Test sensor states and attributes."""
@pytest.fixture(autouse=True)
def squeezebox_sensor_platform():
"""Only set up the sensor platform for these tests."""
with patch("homeassistant.components.squeezebox.PLATFORMS", [Platform.SENSOR]):
yield
async def test_server_sensor(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test that server sensor reports correct player count."""
# Setup component
with (
patch(
"homeassistant.components.squeezebox.PLATFORMS",
[Platform.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,3 +40,36 @@ async def test_sensor(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non
assert state is not None
assert state.state == "10"
async def test_player_sensor_next_alarm(
hass: HomeAssistant,
config_entry: MockConfigEntry,
lms: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test player sensor for time of next alarm."""
# Setup component
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)
player = (await lms.async_get_players())[0]
# test alarm time is set from player
state = hass.states.get("sensor.none_next_alarm")
assert state is not None
assert state.state == TEST_ALARM_NEXT_TIME.isoformat()
# simulate no upcoming alarm
player.alarm_next = None
freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.none_next_alarm")
assert state is not None
assert state.state == STATE_UNKNOWN