From 1954f1696483b1f20de8fae18e5c7ed675562edf Mon Sep 17 00:00:00 2001 From: sudoxnym <76703581+sudoxnym@users.noreply.github.com> Date: Sat, 4 May 2024 21:25:57 -0600 Subject: [PATCH] Add files via upload This integration creates sensors for Sleep As Android. It's nothing special, but the main goal is to create a dynamic binary sensor for sleep/awake status for automations. --- saas/__init__.py | 17 +++++++ saas/binary_sensor.py | 47 +++++++++++++++++ saas/config_flow.py | 68 +++++++++++++++++++++++++ saas/const.py | 58 +++++++++++++++++++++ saas/manifest.json | 13 +++++ saas/sensor.py | 116 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 319 insertions(+) create mode 100644 saas/__init__.py create mode 100644 saas/binary_sensor.py create mode 100644 saas/config_flow.py create mode 100644 saas/const.py create mode 100644 saas/manifest.json create mode 100644 saas/sensor.py diff --git a/saas/__init__.py b/saas/__init__.py new file mode 100644 index 0000000..90e219b --- /dev/null +++ b/saas/__init__.py @@ -0,0 +1,17 @@ +"""The SAAS - Sleep As Android Stats integration.""" +import voluptuous as vol +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery +from .const import DOMAIN + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the SAAS - Sleep As Android Status component.""" + return True + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up SAAS - Sleep As Android Status from a config entry.""" + hass.data[DOMAIN] = entry.data + discovery.load_platform(hass, "sensor", DOMAIN, {}, entry.data) + discovery.load_platform(hass, "binary_sensor", DOMAIN, {}, entry.data) + return True \ No newline at end of file diff --git a/saas/binary_sensor.py b/saas/binary_sensor.py new file mode 100644 index 0000000..dc310ff --- /dev/null +++ b/saas/binary_sensor.py @@ -0,0 +1,47 @@ +from homeassistant.helpers.entity import Entity +from homeassistant.components.mqtt import async_subscribe +from .const import DOMAIN, CONF_NAME, INTEGRATION_NAME, MODEL + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the SAAS binary sensor platform.""" + name = hass.data[DOMAIN].get(CONF_NAME, "Default Name") + add_entities([SAASBinarySensor(hass, name)]) + +class SAASBinarySensor(Entity): + """Representation of a SAAS - Sleep As Android Stats binary sensor.""" + def __init__(self, hass, name): + """Initialize the binary sensor.""" + self._state = None + self._name = name + self._hass = hass + + @property + def name(self): + """Return the name of the binary sensor.""" + return f"SAAS {self._name} Awake" + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state in self._hass.data[DOMAIN]["awake_states"] + + @property + def device_info(self): + """Return information about the device.""" + return { + "identifiers": {(DOMAIN, self._name)}, + "name": self._name, + "manufacturer": INTEGRATION_NAME, + "model": MODEL, + } + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + async def message_received(msg): + """Handle new MQTT messages.""" + self._state = msg.payload + self.async_schedule_update_ha_state() + + await async_subscribe(self._hass, self._hass.data[DOMAIN]["topic_template"], message_received) \ No newline at end of file diff --git a/saas/config_flow.py b/saas/config_flow.py new file mode 100644 index 0000000..dd63bb5 --- /dev/null +++ b/saas/config_flow.py @@ -0,0 +1,68 @@ +from homeassistant import config_entries +from homeassistant.helpers import config_validation as cv +import voluptuous as vol +from homeassistant.core import callback +from .const import DOMAIN, CONF_NAME, CONF_TOPIC, CONF_QOS, DOMAIN, AVAILABLE_STATES, CONF_AWAKE_DURATION, CONF_ASLEEP_DURATION, CONF_AWAKE_STATES, CONF_SLEEP_STATES, DEFAULT_AWAKE_DURATION, DEFAULT_ASLEEP_DURATION, DEFAULT_AWAKE_STATES, DEFAULT_SLEEP_STATES + +class MyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + + if user_input is not None: + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(CONF_TOPIC): str, + vol.Required(CONF_QOS, default=0): vol.In([0, 1, 2]), + vol.Required(CONF_AWAKE_DURATION, default=DEFAULT_AWAKE_DURATION): int, + vol.Required(CONF_ASLEEP_DURATION, default=DEFAULT_ASLEEP_DURATION): int, + vol.Required(CONF_AWAKE_STATES, default=DEFAULT_AWAKE_STATES): cv.multi_select(AVAILABLE_STATES), + vol.Required(CONF_SLEEP_STATES, default=DEFAULT_SLEEP_STATES): cv.multi_select(AVAILABLE_STATES), + } + ), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_NAME, default=self.config_entry.options.get(CONF_NAME)): str, + vol.Required(CONF_TOPIC, default=self.config_entry.options.get(CONF_TOPIC)): str, + vol.Required(CONF_QOS, default=self.config_entry.options.get(CONF_QOS, 0)): vol.In([0, 1, 2]), + vol.Required(CONF_AWAKE_DURATION, default=self.config_entry.options.get(CONF_AWAKE_DURATION, DEFAULT_AWAKE_DURATION)): int, + vol.Required(CONF_ASLEEP_DURATION, default=self.config_entry.options.get(CONF_ASLEEP_DURATION, DEFAULT_ASLEEP_DURATION)): int, + vol.Required(CONF_AWAKE_STATES, default=self.config_entry.options.get(CONF_AWAKE_STATES, DEFAULT_AWAKE_STATES)): cv.multi_select(AVAILABLE_STATES), + vol.Required(CONF_SLEEP_STATES, default=self.config_entry.options.get(CONF_SLEEP_STATES, DEFAULT_SLEEP_STATES)): cv.multi_select(AVAILABLE_STATES), + } + ), + ) \ No newline at end of file diff --git a/saas/const.py b/saas/const.py new file mode 100644 index 0000000..a744593 --- /dev/null +++ b/saas/const.py @@ -0,0 +1,58 @@ +"""Constants for the SAAS - Sleep As Android Stats integration.""" + +DOMAIN = "saas" + +INTEGRATION_NAME = "SAAS - Sleep As Android Stats" +MODEL = "SAAS - Version 0.0.1" + +CONF_NAME = "name" # Name of the Integration +CONF_TOPIC = "topic_template" # MQTT Topic for Sleep As Android Events +CONF_QOS = "qos" # Quality of Service +CONF_AWAKE_DURATION = "awake_duration" # Awake Duration +CONF_ASLEEP_DURATION = "asleep_duration" # Asleep Duration +CONF_AWAKE_STATES = "awake_states" # Awake States +CONF_SLEEP_STATES = "sleep_states" # Sleep States + +DEFAULT_AWAKE_DURATION = 10 # Default Awake Duration +DEFAULT_ASLEEP_DURATION = 10 # Default Asleep Duration +DEFAULT_AWAKE_STATES = ["awake", "sleep_tracking_started"] # Default Awake States +DEFAULT_SLEEP_STATES = ["not_awake", "rem", "light_sleep", "deep_sleep", "sleep_tracking_stopped"] # Default Sleep States + +SENSOR_TYPES = { + "received": {"name": "State", "device_class": None}, + "awake": {"name": "Awake", "device_class": "motion"}, +} + +AVAILABLE_STATES = [ + 'unknown', + '{"event":"alarm_alert_dismiss"}', + '{"event":"alarm_alert_start"}', + '{"event":"alarm_rescheduled"}', + '{"event":"alarm_skip_next"}', + '{"event":"alarm_snooze_canceled"}', + '{"event":"alarm_snooze_clicked"}', + '{"event":"antisnoring"}', + '{"event":"apnea_alarm"}', + '{"event":"awake"}', + '{"event":"before_alarm"}', + '{"event":"before_smart_period"}', + '{"event":"deep_sleep"}', + '{"event":"light_sleep"}', + '{"event":"lullaby_start"}', + '{"event":"lullaby_stop"}', + '{"event":"lullaby_volume_down"}', + '{"event":"not_awake"}', + '{"event":"rem"}', + '{"event":"show_skip_next_alarm"}', + '{"event":"sleep_tracking_paused"}', + '{"event":"sleep_tracking_resumed"}', + '{"event":"sleep_tracking_started"}', + '{"event":"sleep_tracking_stopped"}', + '{"event":"smart_period"}', + '{"event":"sound_event_baby"}', + '{"event":"sound_event_cough"}', + '{"event":"sound_event_laugh"}', + '{"event":"sound_event_snore"}', + '{"event":"sound_event_talk"}', + '{"event":"time_to_bed_alarm_alert"}' +] \ No newline at end of file diff --git a/saas/manifest.json b/saas/manifest.json new file mode 100644 index 0000000..ae3ae19 --- /dev/null +++ b/saas/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "saas", + "name": "SAAS - Sleep As Android Status", + "codeowners": ["@sudoxnym"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "", + "iot_class": "local_push", + "issue_tracker": "", + "quality_scale": "silver", + "requirements": ["pyhaversion"], + "version": "0.0.1" +} \ No newline at end of file diff --git a/saas/sensor.py b/saas/sensor.py new file mode 100644 index 0000000..2a39142 --- /dev/null +++ b/saas/sensor.py @@ -0,0 +1,116 @@ +from homeassistant.helpers.entity import Entity +from homeassistant.components.mqtt import async_subscribe +from .const import DOMAIN, CONF_NAME, INTEGRATION_NAME, MODEL + +# Define the state mapping directly in the file +state_mapping = { + "unknown": "Unknown", + '{"event":"sleep_tracking_started"}': "Sleep Tracking Started", + '{"event":"sleep_tracking_stopped"}': "Sleep Tracking Stopped", + '{"event":"sleep_tracking_paused"}': "Sleep Tracking Paused", + '{"event":"sleep_tracking_resumed"}': "Sleep Tracking Resumed", + '{"event":"alarm_snooze_clicked"}': "Alarm Snoozed", + '{"event":"alarm_snooze_canceled"}': "Snooze Canceled", + '{"event":"time_to_bed_alarm_alert"}': "Time To Bed Alarm Alert", + '{"event":"alarm_alert_start"}': "Alarm Alert Started", + '{"event":"alarm_alert_dismiss"}': "Alarm Dismissed", + '{"event":"alarm_skip_next"}': "Skip Next Alarm", + '{"event":"show_skip_next_alarm"}': "Show Skip Next Alarm", + '{"event":"rem"}': "REM", + '{"event":"smart_period"}': "Smart Period", + '{"event":"before_smart_period"}': "Before Smart Period", + '{"event":"lullaby_start"}': "Lullaby Start", + '{"event":"lullaby_stop"}': "Lullaby Stop", + '{"event":"lullaby_volume_down"}': "Lullaby Volume Down", + '{"event":"deep_sleep"}': "Deep Sleep", + '{"event":"light_sleep"}': "Light Sleep", + '{"event":"awake"}': "Awake", + '{"event":"not_awake"}': "Not Awake", + '{"event":"apnea_alarm"}': "Apnea Alarm", + '{"event":"antisnoring"}': "Antisnoring", + '{"event":"before_alarm"}': "Before Alarm", + '{"event":"sound_event_snore"}': "Snore Detected", + '{"event":"sound_event_talk"}': "Talk Detected", + '{"event":"sound_event_cough"}': "Cough Detected", + '{"event":"sound_event_baby"}': "Baby Cry Detected", + '{"event":"sound_event_laugh"}': "Laugh Detected", + '{"event":"alarm_rescheduled"}': "Alarm Rescheduled" +} + +# Define the sound mapping directly in the file +sound_mapping = { + '{"event":"sound_event_snore"}': "Snore Detected", + '{"event":"sound_event_talk"}': "Talk Detected", + '{"event":"sound_event_cough"}': "Cough Detected", + '{"event":"sound_event_baby"}': "Baby Cry Detected", + '{"event":"sound_event_laugh"}': "Laugh Detected", +} + +class SAASSensor(Entity): + """Representation of a SAAS - Sleep As Android Stats sensor.""" + def __init__(self, hass, name, mapping): + """Initialize the sensor.""" + self._state = None + self._name = name + self._hass = hass + self._mapping = mapping + + @property + def name(self): + """Return the name of the sensor.""" + return f"SAAS {self._name} State" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_info(self): + """Return information about the device.""" + return { + "identifiers": {(DOMAIN, self._name)}, + "name": self._name, + "manufacturer": INTEGRATION_NAME, + "model": MODEL, + } + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + async def message_received(msg): + """Handle new MQTT messages.""" + # Use the mapping to convert the MQTT payload to the corresponding state + new_state = self._mapping.get(msg.payload) + if new_state is not None: + self._state = new_state + self.async_schedule_update_ha_state() + + await async_subscribe(self._hass, self._hass.data[DOMAIN]["topic_template"], message_received) + +class SAASSoundSensor(SAASSensor): + """Representation of a SAAS - Sleep As Android Stats sound sensor.""" + + @property + def name(self): + """Return the name of the sensor.""" + return f"SAAS {self._name} Sound" + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + async def message_received(msg): + """Handle new MQTT messages.""" + # Use the mapping to convert the MQTT payload to the corresponding state + new_state = self._mapping.get(msg.payload, "None") + self._state = new_state + self.async_schedule_update_ha_state() + + await async_subscribe(self._hass, self._hass.data[DOMAIN]["topic_template"], message_received) + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the SAAS sensor platform.""" + name = hass.data[DOMAIN].get(CONF_NAME, "Default Name") + add_entities([SAASSensor(hass, name, state_mapping), SAASSoundSensor(hass, name, sound_mapping)]) \ No newline at end of file