From f5161159d739d2e892e8e04a497b1b651716e3cc Mon Sep 17 00:00:00 2001 From: sudoxnym <76703581+sudoxnym@users.noreply.github.com> Date: Sat, 3 May 2025 14:39:55 -0600 Subject: [PATCH] Add files via upload --- custom_components/saas/__init__.py | 47 +++--- custom_components/saas/button.py | 57 +++++++- custom_components/saas/config_flow.py | 199 +++++++++++++++----------- custom_components/saas/const.py | 47 +++--- custom_components/saas/manifest.json | 2 +- custom_components/saas/services.yaml | 1 + 6 files changed, 226 insertions(+), 127 deletions(-) diff --git a/custom_components/saas/__init__.py b/custom_components/saas/__init__.py index a9055ff..0f7911e 100644 --- a/custom_components/saas/__init__.py +++ b/custom_components/saas/__init__.py @@ -5,50 +5,53 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .services import async_setup_services # Import the service setup function -_logger = logging.getLogger(__name__) +_LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the SAAS - Sleep As Android Status component.""" - _logger.info("Starting setup of the SAAS component") + _LOGGER.info("Starting setup of the SAAS component") return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - _logger.info(f"Starting setup of config entry with ID: {entry.entry_id}") + _LOGGER.info(f"Starting setup of config entry with ID: {entry.entry_id}") + + # ensure we have a dict for this integration hass.data.setdefault(DOMAIN, {}) - if entry.entry_id not in hass.data[DOMAIN] and entry.data: - hass.data[DOMAIN][entry.entry_id] = entry.data + # merge original data + any saved options so runtime sees edits + merged = {**entry.data, **entry.options} + hass.data[DOMAIN][entry.entry_id] = merged + _LOGGER.debug( + "Merged entry.data and entry.options for %s: %s", + entry.entry_id, + hass.data[DOMAIN][entry.entry_id], + ) - _logger.info(f"hass.data[DOMAIN] after adding entry data: {hass.data[DOMAIN]}") - - # Forward the setup to the sensor and button platforms. + # forward setup to sensor and button platforms await hass.config_entries.async_forward_entry_setups(entry, ["sensor", "button"]) - _logger.info(f"hass.data[DOMAIN] before async_setup_services: {hass.data[DOMAIN]}") - - # Setup the services. - _logger.info("Starting setup of services") + # set up any custom services + _LOGGER.info("Starting setup of services") await async_setup_services(hass) - _logger.info("Finished setup of services") + _LOGGER.info("Finished setup of services") - _logger.info(f"hass.data[DOMAIN] after setup of services: {hass.data[DOMAIN]}") - _logger.info("Finished setup of config entry") + _LOGGER.info("Finished setup of config entry") return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - _logger.info(f"Starting unload of config entry with ID: {entry.entry_id}") + _LOGGER.info(f"Starting unload of config entry with ID: {entry.entry_id}") - # Unload sensor and button platforms. + # unload sensor and button platforms unload_ok = await hass.config_entries.async_unload_platforms(entry, ["sensor", "button"]) if not unload_ok: - _logger.error("Failed to unload platforms for saas") + _LOGGER.error("Failed to unload platforms for saas") return False - if isinstance(hass.data.get(DOMAIN, {}), dict): - hass.data[DOMAIN].pop(entry.entry_id, None) + # clean up our stored data + hass.data[DOMAIN].pop(entry.entry_id, None) - _logger.info(f"hass.data[DOMAIN] after removing entry data: {hass.data[DOMAIN]}") - _logger.info("Finished unload of config entry") + _LOGGER.info(f"hass.data[{DOMAIN}] after unload: {hass.data.get(DOMAIN)}") + _LOGGER.info("Finished unload of config entry") return True diff --git a/custom_components/saas/button.py b/custom_components/saas/button.py index 945dcb2..4a24eaa 100644 --- a/custom_components/saas/button.py +++ b/custom_components/saas/button.py @@ -1,7 +1,7 @@ import logging from homeassistant.components.button import ButtonEntity from homeassistant.helpers import device_registry as dr -from .const import DOMAIN, INTEGRATION_NAME, MODEL, CONF_NAME +from .const import DOMAIN, INTEGRATION_NAME, MODEL, CONF_NAME, CONF_NOTIFY_TARGET import asyncio # Set up logging @@ -50,6 +50,12 @@ class SAASSleepTrackingStart(ButtonEntity): return device_info def press(self): + if not self._notify_target: + self._hass.components.persistent_notification.async_create( + "add a mobile device to use this function", + title=self.name, + ) + return """Press the button.""" service_name = self._notify_target # Remove the "notify." prefix @@ -117,6 +123,12 @@ class SAASSleepTrackingStop(ButtonEntity): return device_info def press(self): + if not self._notify_target: + self._hass.components.persistent_notification.async_create( + "add a mobile device to use this function", + title=self.name, + ) + return """Press the button.""" service_name = self._notify_target # Remove the "notify." prefix @@ -184,6 +196,12 @@ class SAASSleepTrackingPause(ButtonEntity): return device_info def press(self): + if not self._notify_target: + self._hass.components.persistent_notification.async_create( + "add a mobile device to use this function", + title=self.name, + ) + return """Press the button.""" service_name = self._notify_target # Remove the "notify." prefix @@ -251,6 +269,12 @@ class SAASSleepTrackingResume(ButtonEntity): return device_info def press(self): + if not self._notify_target: + self._hass.components.persistent_notification.async_create( + "add a mobile device to use this function", + title=self.name, + ) + return """Press the button.""" service_name = self._notify_target # Remove the "notify." prefix @@ -318,6 +342,12 @@ class SAASAlarmClockSnooze(ButtonEntity): return device_info def press(self): + if not self._notify_target: + self._hass.components.persistent_notification.async_create( + "add a mobile device to use this function", + title=self.name, + ) + return """Press the button.""" service_name = self._notify_target # Remove the "notify." prefix @@ -385,6 +415,12 @@ class SAASAlarmClockDisable(ButtonEntity): return device_info def press(self): + if not self._notify_target: + self._hass.components.persistent_notification.async_create( + "add a mobile device to use this function", + title=self.name, + ) + return """Press the button.""" service_name = self._notify_target # Remove the "notify." prefix @@ -452,6 +488,12 @@ class SAASSleepTrackingStartWithAlarm(ButtonEntity): return device_info def press(self): + if not self._notify_target: + self._hass.components.persistent_notification.async_create( + "add a mobile device to use this function", + title=self.name, + ) + return """Press the button.""" service_name = self._notify_target # Remove the "notify." prefix @@ -519,6 +561,12 @@ class SAASLullabyStop(ButtonEntity): return device_info def press(self): + if not self._notify_target: + self._hass.components.persistent_notification.async_create( + "add a mobile device to use this function", + title=self.name, + ) + return """Press the button.""" service_name = self._notify_target # Remove the "notify." prefix @@ -546,12 +594,15 @@ class SAASLullabyStop(ButtonEntity): async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the SAAS Sleep Tracking Start, Stop and Pause buttons from a config entry.""" + notify_target = config_entry.data.get(CONF_NOTIFY_TARGET) + if not notify_target: + _LOGGER.warning("no notify_target configured; skipping button setup") + return # _LOGGER.debug("Setting up SAAS Sleep Tracking buttons from a config entry with data: %s", config_entry.data) # Extract the necessary data from config_entry.data name = config_entry.data[CONF_NAME] - notify_target = config_entry.data['notify_target'] + notify_target = config_entry.data.get(CONF_NOTIFY_TARGET) # Create instances of SAASSleepTrackingStart, SAASSleepTrackingStop and SAASSleepTrackingPause entities = [ diff --git a/custom_components/saas/config_flow.py b/custom_components/saas/config_flow.py index 8d6af05..348a47f 100644 --- a/custom_components/saas/config_flow.py +++ b/custom_components/saas/config_flow.py @@ -4,7 +4,6 @@ from .const import ( DOMAIN, CONF_NAME, CONF_TOPIC, - CONF_QOS, AVAILABLE_STATES, CONF_AWAKE_DURATION, CONF_SLEEP_DURATION, @@ -17,129 +16,169 @@ from .const import ( CONF_NOTIFY_TARGET, ) from homeassistant import config_entries -from homeassistant.core import callback -from voluptuous import Schema, Required, In, Optional +from voluptuous import Schema, Required, Optional, In from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) + class MyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for SAAS.""" + """Handle the initial config flow for SAAS.""" VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH async def async_step_user(self, user_input=None): - """Handle the initial step.""" + """Initial setup step.""" errors = {} - # Build notify targets list + # discover available mobile_app notify services notify_services = self.hass.services.async_services().get("notify", {}) notify_targets = { - key.replace("mobile_app_", "").title(): key - for key in notify_services.keys() - if key.startswith("mobile_app_") + svc.replace("mobile_app_", "") + .replace("_", " ") + .lower(): svc + for svc in notify_services + if svc.startswith("mobile_app_") } if user_input is not None: - # Map back the chosen label to service name - if user_input.get(CONF_NOTIFY_TARGET): - user_input[CONF_NOTIFY_TARGET] = notify_targets.get(user_input[CONF_NOTIFY_TARGET]) + # map chosen label back to service name, or remove if invalid + nt_label = user_input.get(CONF_NOTIFY_TARGET) + if nt_label in notify_targets: + user_input[CONF_NOTIFY_TARGET] = notify_targets[nt_label] + else: + user_input.pop(CONF_NOTIFY_TARGET, None) + + # basic validation if not user_input.get(CONF_NAME): errors[CONF_NAME] = "required" + if not errors: - return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input + ) + + # build initial form schema + schema = { + Required(CONF_NAME): str, + Required(CONF_TOPIC): str, + Required(CONF_AWAKE_DURATION, default=DEFAULT_AWAKE_DURATION): int, + Required(CONF_SLEEP_DURATION, default=DEFAULT_SLEEP_DURATION): int, + Required( + CONF_AWAKE_STATES, default=DEFAULT_AWAKE_STATES + ): cv.multi_select(AVAILABLE_STATES), + Required( + CONF_SLEEP_STATES, default=DEFAULT_SLEEP_STATES + ): cv.multi_select(AVAILABLE_STATES), + } + if notify_targets: + # truly optional, only real targets, no blank choice + schema[Optional(CONF_NOTIFY_TARGET)] = In(list(notify_targets.keys())) return self.async_show_form( step_id="user", - data_schema=Schema( - { - Required(CONF_NAME): str, - Required(CONF_TOPIC): str, - Required(CONF_QOS, default=0): In([0, 1, 2]), - Required(CONF_AWAKE_DURATION, default=DEFAULT_AWAKE_DURATION): int, - Required(CONF_SLEEP_DURATION, default=DEFAULT_SLEEP_DURATION): int, - Required(CONF_AWAKE_STATES, default=DEFAULT_AWAKE_STATES): cv.multi_select(AVAILABLE_STATES), - Required(CONF_SLEEP_STATES, default=DEFAULT_SLEEP_STATES): cv.multi_select(AVAILABLE_STATES), - Optional(CONF_NOTIFY_TARGET): vol.In(list(notify_targets.keys())), - } - ), + data_schema=Schema(schema), errors=errors, ) - async def async_migrate_entry(self, hass, entry): - """Migrate old config entries to the new schema.""" - _LOGGER.debug("Migrating config entry %s from version %s", entry.entry_id, entry.version) - data = {**entry.data} - options = {**entry.options} - - # If you renamed keys in entry.data/options, do it here when entry.version == 1 - # e.g.: - # if entry.version == 1: - # data["topic_template"] = data.pop("topic") - # entry.version = 2 - - # For no data changes, just bump the version: - entry.version = self.VERSION - hass.config_entries.async_update_entry(entry, data=data, options=options) - _LOGGER.info("Migrated config entry %s to version %s", entry.entry_id, entry.version) - return True - @staticmethod - @callback + @config_entries.callback def async_get_options_flow(entry): - """Get the options flow handler.""" + """Return options flow handler.""" return OptionsFlowHandler(entry) class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle SAAS options.""" + """Handle SAAS options editing.""" def __init__(self, entry): - """Initialize options flow.""" super().__init__() - self._config_entry = entry # use private attribute + self._config_entry = entry async def async_step_init(self, user_input=None): - """Manage the options.""" - # Load current options or fall back to data - current = self._config_entry.options.copy() - for key in [ - CONF_NAME, - CONF_TOPIC, - CONF_QOS, - CONF_AWAKE_DURATION, - CONF_SLEEP_DURATION, - CONF_AWAKE_STATES, - CONF_SLEEP_STATES, - CONF_NOTIFY_TARGET, - ]: - if key not in current and key in self._config_entry.data: - current[key] = self._config_entry.data[key] + """Manage the options form (edit).""" + current = dict(self._config_entry.data) - # Build notify targets list + # discover mobile_app notify services again notify_services = self.hass.services.async_services().get("notify", {}) notify_targets = { - key.replace("mobile_app_", "").title(): key - for key in notify_services.keys() - if key.startswith("mobile_app_") + svc.replace("mobile_app_", "") + .replace("_", " ") + .lower(): svc + for svc in notify_services + if svc.startswith("mobile_app_") } + # reverse map for defaults + reverse_map = {v: k for k, v in notify_targets.items()} if user_input is not None: - return self.async_create_entry(title="", data=user_input) + new_data = current.copy() + + # standard fields + for key in ( + CONF_NAME, + CONF_TOPIC, + CONF_AWAKE_DURATION, + CONF_SLEEP_DURATION, + CONF_AWAKE_STATES, + CONF_SLEEP_STATES, + ): + if key in user_input: + new_data[key] = user_input[key] + + # handle notify_target with "no mobile" option + sel = user_input.get(CONF_NOTIFY_TARGET) + if sel == "no mobile": + new_data.pop(CONF_NOTIFY_TARGET, None) + elif sel in notify_targets: + new_data[CONF_NOTIFY_TARGET] = notify_targets[sel] + + # persist back into entry.data and reload + self.hass.config_entries.async_update_entry( + self._config_entry, + data=new_data, + ) + await self.hass.config_entries.async_reload(self._config_entry.entry_id) + + return self.async_create_entry(title="", data=None) + + # build edit form schema with defaults + schema = { + Required( + CONF_NAME, default=current.get(CONF_NAME, "") + ): str, + Required( + CONF_TOPIC, default=current.get(CONF_TOPIC, "") + ): str, + Required( + CONF_AWAKE_DURATION, + default=current.get(CONF_AWAKE_DURATION, DEFAULT_AWAKE_DURATION), + ): int, + Required( + CONF_SLEEP_DURATION, + default=current.get(CONF_SLEEP_DURATION, DEFAULT_SLEEP_DURATION), + ): int, + Required( + CONF_AWAKE_STATES, + default=current.get(CONF_AWAKE_STATES, DEFAULT_AWAKE_STATES), + ): cv.multi_select(AVAILABLE_STATES), + Required( + CONF_SLEEP_STATES, + default=current.get(CONF_SLEEP_STATES, DEFAULT_SLEEP_STATES), + ): cv.multi_select(AVAILABLE_STATES), + } + + if notify_targets: + # prepend "no mobile", then all real targets, all lowercase with spaces + labels = ["no mobile"] + list(notify_targets.keys()) + default_label = reverse_map.get( + current.get(CONF_NOTIFY_TARGET), "no mobile" + ) + schema[Optional(CONF_NOTIFY_TARGET, default=default_label)] = In(labels) return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - Required(CONF_NAME, default=current.get(CONF_NAME, "")): str, - Required(CONF_TOPIC, default=current.get(CONF_TOPIC, "")): str, - Required(CONF_QOS, default=current.get(CONF_QOS, 0)): In([0, 1, 2]), - Required(CONF_AWAKE_DURATION, default=current.get(CONF_AWAKE_DURATION, DEFAULT_AWAKE_DURATION)): int, - Required(CONF_SLEEP_DURATION, default=current.get(CONF_SLEEP_DURATION, DEFAULT_SLEEP_DURATION)): int, - Required(CONF_AWAKE_STATES, default=current.get(CONF_AWAKE_STATES, DEFAULT_AWAKE_STATES)): cv.multi_select(AVAILABLE_STATES), - Required(CONF_SLEEP_STATES, default=current.get(CONF_SLEEP_STATES, DEFAULT_SLEEP_STATES)): cv.multi_select(AVAILABLE_STATES), - Optional(CONF_NOTIFY_TARGET, default=current.get(CONF_NOTIFY_TARGET, "")): vol.In(list(notify_targets.keys())), - } - ), + data_schema=Schema(schema), errors={}, ) diff --git a/custom_components/saas/const.py b/custom_components/saas/const.py index 4d75d51..95974a7 100644 --- a/custom_components/saas/const.py +++ b/custom_components/saas/const.py @@ -5,21 +5,24 @@ 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_NAME = "name" # Name of the Integration +CONF_TOPIC = "topic_template" # MQTT Topic for Sleep As Android Events +CONF_AWAKE_DURATION = "awake_duration" # Awake Duration CONF_SLEEP_DURATION = "sleep_duration" # Sleep Duration - CONF_AWAKE_STATES = "awake_states" # Awake States -CONF_SLEEP_STATES = "sleep_states" # Sleep States +CONF_SLEEP_STATES = "sleep_states" # Sleep States +CONF_NOTIFY_TARGET = "notify_target" # Notify Target -CONF_NOTIFY_TARGET = "notify_target" # Notify Target - -DEFAULT_AWAKE_DURATION = 10 # Default Awake Duration -DEFAULT_SLEEP_DURATION = 10 # Default Sleep Duration -DEFAULT_AWAKE_STATES = ["Awake", "Sleep Tracking Stopped"] # Default Awake States -DEFAULT_SLEEP_STATES = ["Not Awake", "Rem", "Light Sleep", "Deep Sleep", "Sleep Tracking Started"]# Default Sleep States +DEFAULT_AWAKE_DURATION = 10 # Default Awake Duration +DEFAULT_SLEEP_DURATION = 10 # Default Sleep Duration +DEFAULT_AWAKE_STATES = ["Awake", "Sleep Tracking Stopped"] # Default Awake States +DEFAULT_SLEEP_STATES = [ + "Not Awake", + "Rem", + "Light Sleep", + "Deep Sleep", + "Sleep Tracking Started", +] # Default Sleep States SENSOR_TYPES = { "state": {"name": "State", "device_class": None}, @@ -33,7 +36,7 @@ DAY_MAPPING = { "wednesday": 4, "thursday": 5, "friday": 6, - "saturday": 7 + "saturday": 7, } AVAILABLE_STATES = [ @@ -67,8 +70,9 @@ AVAILABLE_STATES = [ 'Sound Event Laugh', 'Sound Event Snore', 'Sound Event Talk', - 'Time for Bed' + 'Time for Bed', ] + STATE_MAPPING = { "unknown": "Unknown", "sleep_tracking_started": "Sleep Tracking Started", @@ -100,7 +104,7 @@ STATE_MAPPING = { "sound_event_cough": "Cough Detected", "sound_event_baby": "Baby Cry Detected", "sound_event_laugh": "Laugh Detected", - "alarm_rescheduled": "Alarm Rescheduled" + "alarm_rescheduled": "Alarm Rescheduled", } REVERSE_STATE_MAPPING = {v: k for k, v in STATE_MAPPING.items()} @@ -111,14 +115,15 @@ SLEEP_STAGE_MAPPING = { "deep_sleep": "Deep Sleep", "light_sleep": "Light Sleep", "awake": "Awake", - "not_awake": "Not Awake" + "not_awake": "Not Awake", } + SOUND_MAPPING = { 'sound_event_snore': "Snore Detected", 'sound_event_talk': "Talk Detected", 'sound_event_cough': "Cough Detected", 'sound_event_baby': "Baby Cry Detected", - 'sound_event_laugh': "Laugh Detected" + 'sound_event_laugh': "Laugh Detected", } LULLABY_MAPPING = { @@ -129,7 +134,7 @@ LULLABY_MAPPING = { DISTURBANCE_MAPPING = { 'apnea_alarm': "Apnea Alarm", - 'antisnoring': "Antisnoring" + 'antisnoring': "Antisnoring", } ALARM_EVENT_MAPPING = { @@ -143,12 +148,12 @@ ALARM_EVENT_MAPPING = { 'show_skip_next_alarm': "Show Skip Next Alarm", 'smart_period': "Smart Period", 'before_smart_period': "Before Smart Period", - "alarm_rescheduled": "Alarm Rescheduled" + "alarm_rescheduled": "Alarm Rescheduled", } SLEEP_TRACKING_MAPPING = { 'sleep_tracking_started': "Sleep Tracking Started", 'sleep_tracking_stopped': "Sleep Tracking Stopped", 'sleep_tracking_paused': "Sleep Tracking Paused", - 'sleep_tracking_resumed': "Sleep Tracking Resumed" -} + 'sleep_tracking_resumed': "Sleep Tracking Resumed", +} \ No newline at end of file diff --git a/custom_components/saas/manifest.json b/custom_components/saas/manifest.json index 1c8e803..94a7912 100644 --- a/custom_components/saas/manifest.json +++ b/custom_components/saas/manifest.json @@ -7,4 +7,4 @@ "documentation": "https://www.github.com/sudoxnym/saas", "issue_tracker": "https://www.github.com/sudoxnym/saas/issues", "version": "0.2.0" -} +} \ No newline at end of file diff --git a/custom_components/saas/services.yaml b/custom_components/saas/services.yaml index e69de29..57db2bb 100644 --- a/custom_components/saas/services.yaml +++ b/custom_components/saas/services.yaml @@ -0,0 +1 @@ +#empty on purpose \ No newline at end of file