Add sensor platform to Sleep as Android (#150440)

This commit is contained in:
Manu
2025-08-11 23:25:51 +02:00
committed by GitHub
parent 6e3ccbefc2
commit 93c30f1b59
9 changed files with 360 additions and 11 deletions

View File

@@ -15,7 +15,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import ATTR_EVENT, ATTR_VALUE1, ATTR_VALUE2, ATTR_VALUE3, DOMAIN
PLATFORMS: list[Platform] = [Platform.EVENT]
PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR]
type SleepAsAndroidConfigEntry = ConfigEntry

View File

@@ -28,3 +28,5 @@ MAP_EVENTS = {
"lullaby_stop": "stop",
"lullaby_volume_down": "volume_down",
}
ALARM_LABEL_DEFAULT = "alarm"

View File

@@ -2,8 +2,11 @@
from __future__ import annotations
from abc import abstractmethod
from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity, EntityDescription
from . import SleepAsAndroidConfigEntry
@@ -31,3 +34,14 @@ class SleepAsAndroidEntity(Entity):
model="Sleep as Android",
name=config_entry.title,
)
@abstractmethod
def _async_handle_event(self, webhook_id: str, data: dict[str, str]) -> None:
"""Handle the Sleep as Android event."""
async def async_added_to_hass(self) -> None:
"""Register event callback."""
self.async_on_remove(
async_dispatcher_connect(self.hass, DOMAIN, self._async_handle_event)
)

View File

@@ -11,11 +11,10 @@ from homeassistant.components.event import (
EventEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SleepAsAndroidConfigEntry
from .const import ATTR_EVENT, DOMAIN, MAP_EVENTS
from .const import ATTR_EVENT, MAP_EVENTS
from .entity import SleepAsAndroidEntity
PARALLEL_UPDATES = 0
@@ -152,10 +151,3 @@ class SleepAsAndroidEventEntity(SleepAsAndroidEntity, EventEntity):
):
self._trigger_event(event)
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register event callback."""
self.async_on_remove(
async_dispatcher_connect(self.hass, DOMAIN, self._async_handle_event)
)

View File

@@ -0,0 +1,96 @@
"""Sensor platform for Sleep as Android integration."""
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from homeassistant.components.sensor import (
RestoreSensor,
SensorDeviceClass,
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import SleepAsAndroidConfigEntry
from .const import ALARM_LABEL_DEFAULT, ATTR_EVENT, ATTR_VALUE1, ATTR_VALUE2
from .entity import SleepAsAndroidEntity
PARALLEL_UPDATES = 0
class SleepAsAndroidSensor(StrEnum):
"""Sleep as Android sensors."""
NEXT_ALARM = "next_alarm"
ALARM_LABEL = "alarm_label"
SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=SleepAsAndroidSensor.NEXT_ALARM,
translation_key=SleepAsAndroidSensor.NEXT_ALARM,
device_class=SensorDeviceClass.TIMESTAMP,
),
SensorEntityDescription(
key=SleepAsAndroidSensor.ALARM_LABEL,
translation_key=SleepAsAndroidSensor.ALARM_LABEL,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SleepAsAndroidConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
async_add_entities(
SleepAsAndroidSensorEntity(config_entry, description)
for description in SENSOR_DESCRIPTIONS
)
class SleepAsAndroidSensorEntity(SleepAsAndroidEntity, RestoreSensor):
"""A sensor entity."""
entity_description: SensorEntityDescription
@callback
def _async_handle_event(self, webhook_id: str, data: dict[str, str]) -> None:
"""Handle the Sleep as Android event."""
if webhook_id == self.webhook_id and data[ATTR_EVENT] in (
"alarm_snooze_clicked",
"alarm_snooze_canceled",
"alarm_alert_start",
"alarm_alert_dismiss",
"alarm_skip_next",
"show_skip_next_alarm",
"alarm_rescheduled",
):
if (
self.entity_description.key is SleepAsAndroidSensor.NEXT_ALARM
and (alarm_time := data.get(ATTR_VALUE1))
and alarm_time.isnumeric()
):
self._attr_native_value = datetime.fromtimestamp(
int(alarm_time) / 1000, tz=dt_util.get_default_time_zone()
)
if self.entity_description.key is SleepAsAndroidSensor.ALARM_LABEL and (
label := data.get(ATTR_VALUE2, ALARM_LABEL_DEFAULT)
):
self._attr_native_value = label
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Restore entity state."""
state = await self.async_get_last_sensor_data()
if state:
self._attr_native_value = state.native_value
await super().async_added_to_hass()

View File

@@ -118,6 +118,17 @@
}
}
}
},
"sensor": {
"next_alarm": {
"name": "Next alarm"
},
"alarm_label": {
"name": "Alarm label",
"state": {
"alarm": "Alarm"
}
}
}
}
}

View File

@@ -0,0 +1,98 @@
# serializer version: 1
# name: test_setup[sensor.sleep_as_android_alarm_label-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.sleep_as_android_alarm_label',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Alarm label',
'platform': 'sleep_as_android',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <SleepAsAndroidSensor.ALARM_LABEL: 'alarm_label'>,
'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_alarm_label',
'unit_of_measurement': None,
})
# ---
# name: test_setup[sensor.sleep_as_android_alarm_label-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Sleep as Android Alarm label',
}),
'context': <ANY>,
'entity_id': 'sensor.sleep_as_android_alarm_label',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'label',
})
# ---
# name: test_setup[sensor.sleep_as_android_next_alarm-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.sleep_as_android_next_alarm',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Next alarm',
'platform': 'sleep_as_android',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <SleepAsAndroidSensor.NEXT_ALARM: 'next_alarm'>,
'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_next_alarm',
'unit_of_measurement': None,
})
# ---
# name: test_setup[sensor.sleep_as_android_next_alarm-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Sleep as Android Next alarm',
}),
'context': <ANY>,
'entity_id': 'sensor.sleep_as_android_next_alarm',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2020-02-26T12:21:00+00:00',
})
# ---

View File

@@ -1,13 +1,15 @@
"""Test the Sleep as Android event platform."""
from collections.abc import Generator
from http import HTTPStatus
from unittest.mock import patch
from freezegun.api import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNKNOWN
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -15,6 +17,16 @@ from tests.common import MockConfigEntry, snapshot_platform
from tests.typing import ClientSessionGenerator
@pytest.fixture(autouse=True)
def event_only() -> Generator[None]:
"""Enable only the event platform."""
with patch(
"homeassistant.components.sleep_as_android.PLATFORMS",
[Platform.EVENT],
):
yield
@freeze_time("2025-01-01T03:30:00.000Z")
async def test_setup(
hass: HomeAssistant,

View File

@@ -0,0 +1,124 @@
"""Test the Sleep as Android sensor platform."""
from collections.abc import Generator
from datetime import datetime
from http import HTTPStatus
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from tests.common import (
MockConfigEntry,
mock_restore_cache_with_extra_data,
snapshot_platform,
)
from tests.typing import ClientSessionGenerator
@pytest.fixture(autouse=True)
def sensor_only() -> Generator[None]:
"""Enable only the sensor platform."""
with patch(
"homeassistant.components.sleep_as_android.PLATFORMS",
[Platform.SENSOR],
):
yield
async def test_setup(
hass: HomeAssistant,
config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Snapshot test states of sensor platform."""
mock_restore_cache_with_extra_data(
hass,
(
(
State(
"sensor.sleep_as_android_next_alarm",
"",
),
{
"native_value": datetime.fromisoformat("2020-02-26T12:21:00+00:00"),
"native_unit_of_measurement": None,
},
),
(
State(
"sensor.sleep_as_android_alarm_label",
"",
),
{
"native_value": "label",
"native_unit_of_measurement": None,
},
),
),
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.parametrize(
"event",
[
"alarm_snooze_clicked",
"alarm_snooze_canceled",
"alarm_alert_start",
"alarm_alert_dismiss",
"alarm_skip_next",
"show_skip_next_alarm",
"alarm_rescheduled",
],
)
async def test_webhook_sensor(
hass: HomeAssistant,
config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
event: str,
) -> None:
"""Test webhook updates sensor."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert (state := hass.states.get("sensor.sleep_as_android_next_alarm"))
assert state.state == STATE_UNKNOWN
assert (state := hass.states.get("sensor.sleep_as_android_alarm_label"))
assert state.state == STATE_UNKNOWN
client = await hass_client_no_auth()
response = await client.post(
"/api/webhook/webhook_id",
json={
"event": event,
"value1": "1582719660934",
"value2": "label",
},
)
assert response.status == HTTPStatus.NO_CONTENT
assert (state := hass.states.get("sensor.sleep_as_android_next_alarm"))
assert state.state == "2020-02-26T12:21:00+00:00"
assert (state := hass.states.get("sensor.sleep_as_android_alarm_label"))
assert state.state == "label"