diff --git a/.strict-typing b/.strict-typing index 98973c89a5a..b3e41747239 100644 --- a/.strict-typing +++ b/.strict-typing @@ -466,6 +466,7 @@ homeassistant.components.simplisafe.* homeassistant.components.siren.* homeassistant.components.skybell.* homeassistant.components.slack.* +homeassistant.components.sleep_as_android.* homeassistant.components.sleepiq.* homeassistant.components.smhi.* homeassistant.components.smlight.* diff --git a/CODEOWNERS b/CODEOWNERS index 9a7b961748c..b9a8367ba3e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1415,6 +1415,8 @@ build.json @home-assistant/supervisor /tests/components/skybell/ @tkdrob /homeassistant/components/slack/ @tkdrob @fletcherau /tests/components/slack/ @tkdrob @fletcherau +/homeassistant/components/sleep_as_android/ @tr4nt0r +/tests/components/sleep_as_android/ @tr4nt0r /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 diff --git a/homeassistant/components/sleep_as_android/__init__.py b/homeassistant/components/sleep_as_android/__init__.py new file mode 100644 index 00000000000..09a77504e12 --- /dev/null +++ b/homeassistant/components/sleep_as_android/__init__.py @@ -0,0 +1,66 @@ +"""The Sleep as Android integration.""" + +from __future__ import annotations + +from http import HTTPStatus + +from aiohttp.web import Request, Response +import voluptuous as vol + +from homeassistant.components import webhook +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_WEBHOOK_ID, Platform +from homeassistant.core import HomeAssistant +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] + +type SleepAsAndroidConfigEntry = ConfigEntry + +WEBHOOK_SCHEMA = vol.Schema( + { + vol.Required(ATTR_EVENT): str, + vol.Optional(ATTR_VALUE1): str, + vol.Optional(ATTR_VALUE2): str, + vol.Optional(ATTR_VALUE3): str, + } +) + + +async def handle_webhook( + hass: HomeAssistant, webhook_id: str, request: Request +) -> Response: + """Handle incoming Sleep as Android webhook request.""" + + try: + data = WEBHOOK_SCHEMA(await request.json()) + except vol.MultipleInvalid as error: + return Response( + text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY + ) + + async_dispatcher_send(hass, DOMAIN, webhook_id, data) + return Response(status=HTTPStatus.NO_CONTENT) + + +async def async_setup_entry( + hass: HomeAssistant, entry: SleepAsAndroidConfigEntry +) -> bool: + """Set up Sleep as Android from a config entry.""" + + webhook.async_register( + hass, DOMAIN, entry.title, entry.data[CONF_WEBHOOK_ID], handle_webhook + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: SleepAsAndroidConfigEntry +) -> bool: + """Unload a config entry.""" + webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sleep_as_android/config_flow.py b/homeassistant/components/sleep_as_android/config_flow.py new file mode 100644 index 00000000000..595612cc601 --- /dev/null +++ b/homeassistant/components/sleep_as_android/config_flow.py @@ -0,0 +1,14 @@ +"""Config flow for the Sleep as Android integration.""" + +from __future__ import annotations + +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN + +config_entry_flow.register_webhook_flow( + DOMAIN, + "Sleep as Android", + {"docs_url": "https://www.home-assistant.io/integrations/sleep_as_android"}, + allow_multiple=True, +) diff --git a/homeassistant/components/sleep_as_android/const.py b/homeassistant/components/sleep_as_android/const.py new file mode 100644 index 00000000000..057c326aa86 --- /dev/null +++ b/homeassistant/components/sleep_as_android/const.py @@ -0,0 +1,30 @@ +"""Constants for the Sleep as Android integration.""" + +DOMAIN = "sleep_as_android" + +ATTR_EVENT = "event" +ATTR_VALUE1 = "value1" +ATTR_VALUE2 = "value2" +ATTR_VALUE3 = "value3" + +MAP_EVENTS = { + "sleep_tracking_paused": "paused", + "sleep_tracking_resumed": "resumed", + "sleep_tracking_started": "started", + "sleep_tracking_stopped": "stopped", + "alarm_alert_dismiss": "alert_dismiss", + "alarm_alert_start": "alert_start", + "alarm_rescheduled": "rescheduled", + "alarm_skip_next": "skip_next", + "alarm_snooze_canceled": "snooze_canceled", + "alarm_snooze_clicked": "snooze_clicked", + "alarm_wake_up_check": "wake_up_check", + "sound_event_baby": "baby", + "sound_event_cough": "cough", + "sound_event_laugh": "laugh", + "sound_event_snore": "snore", + "sound_event_talk": "talk", + "lullaby_start": "start", + "lullaby_stop": "stop", + "lullaby_volume_down": "volume_down", +} diff --git a/homeassistant/components/sleep_as_android/entity.py b/homeassistant/components/sleep_as_android/entity.py new file mode 100644 index 00000000000..5a4008d0cdd --- /dev/null +++ b/homeassistant/components/sleep_as_android/entity.py @@ -0,0 +1,33 @@ +"""Base entity for Sleep as Android integration.""" + +from __future__ import annotations + +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription + +from . import SleepAsAndroidConfigEntry +from .const import DOMAIN + + +class SleepAsAndroidEntity(Entity): + """Base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + config_entry: SleepAsAndroidConfigEntry, + entity_description: EntityDescription, + ) -> None: + """Initialize the entity.""" + + self._attr_unique_id = f"{config_entry.entry_id}_{entity_description.key}" + self.entity_description = entity_description + self.webhook_id = config_entry.data[CONF_WEBHOOK_ID] + self._attr_device_info = DeviceInfo( + connections={(DOMAIN, config_entry.entry_id)}, + manufacturer="Urbandroid", + model="Sleep as Android", + name=config_entry.title, + ) diff --git a/homeassistant/components/sleep_as_android/event.py b/homeassistant/components/sleep_as_android/event.py new file mode 100644 index 00000000000..189accd7601 --- /dev/null +++ b/homeassistant/components/sleep_as_android/event.py @@ -0,0 +1,161 @@ +"""Event platform for Sleep as Android integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + 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 .entity import SleepAsAndroidEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class SleepAsAndroidEventEntityDescription(EventEntityDescription): + """Sleep as Android sensor description.""" + + event_types: list[str] + + +class SleepAsAndroidEvent(StrEnum): + """Sleep as Android events.""" + + ALARM_CLOCK = "alarm_clock" + USER_NOTIFICATION = "user_notification" + SMART_WAKEUP = "smart_wakeup" + SLEEP_HEALTH = "sleep_health" + LULLABY = "lullaby" + SLEEP_PHASE = "sleep_phase" + SLEEP_TRACKING = "sleep_tracking" + SOUND_EVENT = "sound_event" + + +EVENT_DESCRIPTIONS: tuple[SleepAsAndroidEventEntityDescription, ...] = ( + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SLEEP_TRACKING, + translation_key=SleepAsAndroidEvent.SLEEP_TRACKING, + device_class=EventDeviceClass.BUTTON, + event_types=[ + "paused", + "resumed", + "started", + "stopped", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.ALARM_CLOCK, + translation_key=SleepAsAndroidEvent.ALARM_CLOCK, + event_types=[ + "alert_dismiss", + "alert_start", + "rescheduled", + "skip_next", + "snooze_canceled", + "snooze_clicked", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SMART_WAKEUP, + translation_key=SleepAsAndroidEvent.SMART_WAKEUP, + event_types=[ + "before_smart_period", + "smart_period", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.USER_NOTIFICATION, + translation_key=SleepAsAndroidEvent.USER_NOTIFICATION, + event_types=[ + "wake_up_check", + "show_skip_next_alarm", + "time_to_bed_alarm_alert", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SLEEP_PHASE, + translation_key=SleepAsAndroidEvent.SLEEP_PHASE, + event_types=[ + "awake", + "deep_sleep", + "light_sleep", + "not_awake", + "rem", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SOUND_EVENT, + translation_key=SleepAsAndroidEvent.SOUND_EVENT, + event_types=[ + "baby", + "cough", + "laugh", + "snore", + "talk", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.LULLABY, + translation_key=SleepAsAndroidEvent.LULLABY, + event_types=[ + "start", + "stop", + "volume_down", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SLEEP_HEALTH, + translation_key=SleepAsAndroidEvent.SLEEP_HEALTH, + event_types=[ + "antisnoring", + "apnea_alarm", + ], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SleepAsAndroidConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the event platform.""" + + async_add_entities( + SleepAsAndroidEventEntity(config_entry, description) + for description in EVENT_DESCRIPTIONS + ) + + +class SleepAsAndroidEventEntity(SleepAsAndroidEntity, EventEntity): + """An event entity.""" + + entity_description: SleepAsAndroidEventEntityDescription + + @callback + def _async_handle_event(self, webhook_id: str, data: dict[str, str]) -> None: + """Handle the Sleep as Android event.""" + event = MAP_EVENTS.get(data[ATTR_EVENT], data[ATTR_EVENT]) + if ( + webhook_id == self.webhook_id + and event in self.entity_description.event_types + ): + 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) + ) diff --git a/homeassistant/components/sleep_as_android/icons.json b/homeassistant/components/sleep_as_android/icons.json new file mode 100644 index 00000000000..001cb5ec561 --- /dev/null +++ b/homeassistant/components/sleep_as_android/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "event": { + "alarm_clock": { + "default": "mdi:alarm" + }, + "user_notification": { + "default": "mdi:cellphone-message" + }, + "smart_wakeup": { + "default": "mdi:brain" + }, + "sleep_phase": { + "default": "mdi:bed" + }, + "sound_event": { + "default": "mdi:chat-sleep-outline" + }, + "sleep_tracking": { + "default": "mdi:record-rec" + }, + "lullaby": { + "default": "mdi:cradle-outline" + }, + "sleep_health": { + "default": "mdi:heart-pulse" + } + } + } +} diff --git a/homeassistant/components/sleep_as_android/manifest.json b/homeassistant/components/sleep_as_android/manifest.json new file mode 100644 index 00000000000..fbac134ffa1 --- /dev/null +++ b/homeassistant/components/sleep_as_android/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sleep_as_android", + "name": "Sleep as Android", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "dependencies": ["webhook"], + "documentation": "https://www.home-assistant.io/integrations/sleep_as_android", + "iot_class": "local_push", + "quality_scale": "silver" +} diff --git a/homeassistant/components/sleep_as_android/quality_scale.yaml b/homeassistant/components/sleep_as_android/quality_scale.yaml new file mode 100644 index 00000000000..5565f9eb834 --- /dev/null +++ b/homeassistant/components/sleep_as_android/quality_scale.yaml @@ -0,0 +1,110 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: has no actions + appropriate-polling: + status: exempt + comment: does not poll + brands: done + common-modules: done + config-flow-test-coverage: + status: done + comment: uses webhook flow helper, already covered + config-flow: done + dependency-transparency: + status: exempt + comment: no dependencies + docs-actions: + status: exempt + comment: no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: has no runtime data + test-before-configure: + status: exempt + comment: nothing to test + test-before-setup: + status: exempt + comment: nothing to test + unique-config-entry: + status: exempt + comment: only 1 webhook can be configured per device. It's not possible to prevent different devices from using the same webhook + + # Silver + action-exceptions: + status: exempt + comment: no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: only state-less entities + integration-owner: done + log-when-unavailable: + status: exempt + comment: only state-less entities + parallel-updates: done + reauthentication-flow: + status: exempt + comment: no authentication required + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: no discovery + discovery: + status: exempt + comment: cannot be discovered + docs-data-update: + status: exempt + comment: does not poll + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: has no devices + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: has no devices + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: exempt + comment: does not raise exceptions + icon-translations: done + reconfiguration-flow: + status: exempt + comment: webhook config flow helper does not implement reconfigure + repair-issues: + status: exempt + comment: has no repairs + stale-devices: + status: exempt + comment: has no stale devices + + # Platinum + async-dependency: + status: exempt + comment: has no external dependencies + inject-websession: + status: exempt + comment: does not do http requests + strict-typing: done diff --git a/homeassistant/components/sleep_as_android/strings.json b/homeassistant/components/sleep_as_android/strings.json new file mode 100644 index 00000000000..2822961c18e --- /dev/null +++ b/homeassistant/components/sleep_as_android/strings.json @@ -0,0 +1,123 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up Sleep as Android", + "description": "Are you sure you want to set up the Sleep as Android integration?" + } + }, + "abort": { + "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", + "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to set up a webhook.\n\nOpen Sleep as Android and go to *Settings → Services → Automation → Webhooks*\n\nEnable *Webhooks* and fill in the following webhook in the URL field:\n\n`{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." + } + }, + "entity": { + "event": { + "sleep_tracking": { + "name": "Sleep tracking", + "state_attributes": { + "event_type": { + "state": { + "paused": "[%key:common::state::paused%]", + "resumed": "Resumed", + "started": "Started", + "stopped": "[%key:common::state::stopped%]" + } + } + } + }, + "alarm_clock": { + "name": "Alarm clock", + "state_attributes": { + "event_type": { + "state": { + "alert_dismiss": "Alarm dismissed", + "alert_start": "Alarm started", + "rescheduled": "Alarm rescheduled", + "skip_next": "Alarm skipped", + "snooze_canceled": "Snooze canceled", + "snooze_clicked": "Snoozing" + } + } + } + }, + "smart_wakeup": { + "name": "Smart wake-up", + "state_attributes": { + "event_type": { + "state": { + "before_smart_period": "45min before smart wake-up", + "smart_period": "Smart wake-up started" + } + } + } + }, + "user_notification": { + "name": "User notification", + "state_attributes": { + "event_type": { + "state": { + "wake_up_check": "Wake-up check", + "show_skip_next_alarm": "Skip next alarm", + "time_to_bed_alarm_alert": "Time to bed" + } + } + } + }, + "sleep_phase": { + "name": "Sleep phase", + "state_attributes": { + "event_type": { + "state": { + "awake": "Woke up", + "deep_sleep": "Deep sleep", + "light_sleep": "Light sleep", + "not_awake": "Fell asleep", + "rem": "REM sleep" + } + } + } + }, + "sound_event": { + "name": "Sound recognition", + "state_attributes": { + "event_type": { + "state": { + "baby": "Baby crying", + "cough": "Coughing or sneezing", + "laugh": "Laughter", + "snore": "Snoring", + "talk": "Talking" + } + } + } + }, + "lullaby": { + "name": "Lullaby", + "state_attributes": { + "event_type": { + "state": { + "start": "Started", + "stop": "[%key:common::state::stopped%]", + "volume_down": "Lowering volume" + } + } + } + }, + "sleep_health": { + "name": "Sleep health", + "state_attributes": { + "event_type": { + "state": { + "antisnoring": "Anti-snoring triggered", + "apnea_alarm": "Sleep apnea detected" + } + } + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 823bd339d51..19fb5491465 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -576,6 +576,7 @@ FLOWS = { "sky_remote", "skybell", "slack", + "sleep_as_android", "sleepiq", "slide_local", "slimproto", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c656958b3cc..10f5ea45427 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5994,6 +5994,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "sleep_as_android": { + "name": "Sleep as Android", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "sleepiq": { "name": "SleepIQ", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 91c75beb64a..ad9196c80c5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4416,6 +4416,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sleep_as_android.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.sleepiq.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/sleep_as_android/__init__.py b/tests/components/sleep_as_android/__init__.py new file mode 100644 index 00000000000..3b970b011e7 --- /dev/null +++ b/tests/components/sleep_as_android/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sleep as Android integration.""" diff --git a/tests/components/sleep_as_android/conftest.py b/tests/components/sleep_as_android/conftest.py new file mode 100644 index 00000000000..97cc6da16a0 --- /dev/null +++ b/tests/components/sleep_as_android/conftest.py @@ -0,0 +1,34 @@ +"""Common fixtures for the Sleep as Android tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.sleep_as_android.const import DOMAIN +from homeassistant.const import CONF_WEBHOOK_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sleep_as_android.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock Sleep as Android configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Sleep as Android", + data={ + "cloudhook": False, + CONF_WEBHOOK_ID: "webhook_id", + }, + entry_id="01JRD840SAZ55DGXBD78PTQ4EF", + ) diff --git a/tests/components/sleep_as_android/snapshots/test_event.ambr b/tests/components/sleep_as_android/snapshots/test_event.ambr new file mode 100644 index 00000000000..27e789351a3 --- /dev/null +++ b/tests/components/sleep_as_android/snapshots/test_event.ambr @@ -0,0 +1,494 @@ +# serializer version: 1 +# name: test_setup[event.sleep_as_android_alarm_clock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'alert_dismiss', + 'alert_start', + 'rescheduled', + 'skip_next', + 'snooze_canceled', + 'snooze_clicked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_alarm_clock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm clock', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_alarm_clock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_alarm_clock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'alert_dismiss', + 'alert_start', + 'rescheduled', + 'skip_next', + 'snooze_canceled', + 'snooze_clicked', + ]), + 'friendly_name': 'Sleep as Android Alarm clock', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_alarm_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_lullaby-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'start', + 'stop', + 'volume_down', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_lullaby', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lullaby', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_lullaby', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_lullaby-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'start', + 'stop', + 'volume_down', + ]), + 'friendly_name': 'Sleep as Android Lullaby', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_lullaby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_health-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'antisnoring', + 'apnea_alarm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_sleep_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sleep health', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_sleep_health', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'antisnoring', + 'apnea_alarm', + ]), + 'friendly_name': 'Sleep as Android Sleep health', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_sleep_health', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'awake', + 'deep_sleep', + 'light_sleep', + 'not_awake', + 'rem', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_sleep_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sleep phase', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_sleep_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'awake', + 'deep_sleep', + 'light_sleep', + 'not_awake', + 'rem', + ]), + 'friendly_name': 'Sleep as Android Sleep phase', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_sleep_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_tracking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'paused', + 'resumed', + 'started', + 'stopped', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_sleep_tracking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sleep tracking', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_sleep_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'paused', + 'resumed', + 'started', + 'stopped', + ]), + 'friendly_name': 'Sleep as Android Sleep tracking', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_sleep_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_smart_wake_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'before_smart_period', + 'smart_period', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_smart_wake_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart wake-up', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_smart_wakeup', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_smart_wake_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'before_smart_period', + 'smart_period', + ]), + 'friendly_name': 'Sleep as Android Smart wake-up', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_smart_wake_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_sound_recognition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'baby', + 'cough', + 'laugh', + 'snore', + 'talk', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_sound_recognition', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound recognition', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_sound_event', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_sound_recognition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'baby', + 'cough', + 'laugh', + 'snore', + 'talk', + ]), + 'friendly_name': 'Sleep as Android Sound recognition', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_sound_recognition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_user_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'wake_up_check', + 'show_skip_next_alarm', + 'time_to_bed_alarm_alert', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_user_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'User notification', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_user_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_user_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'wake_up_check', + 'show_skip_next_alarm', + 'time_to_bed_alarm_alert', + ]), + 'friendly_name': 'Sleep as Android User notification', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_user_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sleep_as_android/test_config_flow.py b/tests/components/sleep_as_android/test_config_flow.py new file mode 100644 index 00000000000..1642263d0ed --- /dev/null +++ b/tests/components/sleep_as_android/test_config_flow.py @@ -0,0 +1,38 @@ +"""Test the Sleep as Android config flow.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.components.sleep_as_android.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + with ( + patch( + "homeassistant.components.webhook.async_generate_id", + return_value="webhook_id", + ), + patch( + "homeassistant.components.webhook.async_generate_url", + return_value="http://example.com:8123", + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Sleep as Android" + assert result["data"] == { + "cloudhook": False, + CONF_WEBHOOK_ID: "webhook_id", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/sleep_as_android/test_event.py b/tests/components/sleep_as_android/test_event.py new file mode 100644 index 00000000000..514b3566dd7 --- /dev/null +++ b/tests/components/sleep_as_android/test_event.py @@ -0,0 +1,161 @@ +"""Test the Sleep as Android event platform.""" + +from http import HTTPStatus + +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.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import ClientSessionGenerator + + +@freeze_time("2025-01-01T03:30:00.000Z") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of event platform.""" + + 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( + ("entity", "payload"), + [ + ("sleep_tracking", {"event": "sleep_tracking_paused"}), + ("sleep_tracking", {"event": "sleep_tracking_resumed"}), + ("sleep_tracking", {"event": "sleep_tracking_started"}), + ("sleep_tracking", {"event": "sleep_tracking_stopped"}), + ( + "alarm_clock", + { + "event": "alarm_alert_dismiss", + "value1": "1582719660934", + "value2": "label", + }, + ), + ( + "alarm_clock", + { + "event": "alarm_alert_start", + "value1": "1582719660934", + "value2": "label", + }, + ), + ("alarm_clock", {"event": "alarm_rescheduled"}), + ( + "alarm_clock", + {"event": "alarm_skip_next", "value1": "1582719660934", "value2": "label"}, + ), + ( + "alarm_clock", + { + "event": "alarm_snooze_canceled", + "value1": "1582719660934", + "value2": "label", + }, + ), + ( + "alarm_clock", + { + "event": "alarm_snooze_clicked", + "value1": "1582719660934", + "value2": "label", + }, + ), + ("smart_wake_up", {"event": "before_smart_period", "value1": "label"}), + ("smart_wake_up", {"event": "smart_period"}), + ("sleep_health", {"event": "antisnoring"}), + ("sleep_health", {"event": "apnea_alarm"}), + ("lullaby", {"event": "lullaby_start"}), + ("lullaby", {"event": "lullaby_stop"}), + ("lullaby", {"event": "lullaby_volume_down"}), + ("sleep_phase", {"event": "awake"}), + ("sleep_phase", {"event": "deep_sleep"}), + ("sleep_phase", {"event": "light_sleep"}), + ("sleep_phase", {"event": "not_awake"}), + ("sleep_phase", {"event": "rem"}), + ("sound_recognition", {"event": "sound_event_baby"}), + ("sound_recognition", {"event": "sound_event_cough"}), + ("sound_recognition", {"event": "sound_event_laugh"}), + ("sound_recognition", {"event": "sound_event_snore"}), + ("sound_recognition", {"event": "sound_event_talk"}), + ("user_notification", {"event": "alarm_wake_up_check"}), + ( + "user_notification", + { + "event": "show_skip_next_alarm", + "value1": "1582719660934", + "value2": "label", + }, + ), + ( + "user_notification", + { + "event": "time_to_bed_alarm_alert", + "value1": "1582719660934", + "value2": "label", + }, + ), + ], +) +@freeze_time("2025-01-01T03:30:00.000+00:00") +async def test_webhook_event( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + entity: str, + payload: dict[str, str], +) -> None: + """Test webhook events.""" + + 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(f"event.sleep_as_android_{entity}")) + assert state.state == STATE_UNKNOWN + + client = await hass_client_no_auth() + + response = await client.post("/api/webhook/webhook_id", json=payload) + assert response.status == HTTPStatus.NO_CONTENT + + assert (state := hass.states.get(f"event.sleep_as_android_{entity}")) + assert state.state == "2025-01-01T03:30:00.000+00:00" + + +async def test_webhook_invalid( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test webhook event call with invalid data.""" + + 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 + + client = await hass_client_no_auth() + + response = await client.post("/api/webhook/webhook_id", json={}) + + assert response.status == HTTPStatus.UNPROCESSABLE_ENTITY diff --git a/tests/components/sleep_as_android/test_init.py b/tests/components/sleep_as_android/test_init.py new file mode 100644 index 00000000000..27177a5a5ad --- /dev/null +++ b/tests/components/sleep_as_android/test_init.py @@ -0,0 +1,22 @@ +"""Test the Sleep as Android integration setup.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED