Compare commits

..

No commits in common. "main" and "0.2" have entirely different histories.
main ... 0.2

8 changed files with 229 additions and 477 deletions

232
README.md
View file

@ -1,78 +1,55 @@
<p align="center">
<img src="https://img.shields.io/badge/⚠_DEPRECATED-red?style=for-the-badge" alt="deprecated">
</p>
# ⚠️ THIS PROJECT HAS BEEN DEPRECATED
**this project has been rebranded and moved to [sleepd](https://github.com/sudoxnym/sleepd)**
please use the new repository for future updates and support.
[![go to sleepd](https://img.shields.io/badge/GO_TO_SLEEPD-blue?style=for-the-badge&logo=github)](https://github.com/sudoxnym/sleepd)
---
<details>
<summary>original readme (archived)</summary>
<p align="center"> <p align="center">
<a href="https://hacs.xyz/docs/faq/custom_repositories"> <a href="https://hacs.xyz/docs/faq/custom_repositories">
<img src="https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge&logo=home%20assistant&labelColor=202020&color=41BDF5" alt="add to hacs"> <img src="https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge&logo=home%20assistant&labelColor=202020&color=41BDF5" alt="Add to HACS">
</a> </a>
</p> </p>
<h1>🌙 saas - Sleep As Android status</h1> <h1>🌙 SAAS - Sleep As Android Status</h1>
<h2>🚨 0.2.0 breaking changes 🚨</h2> <h2>📖 Description:</h2>
due to changes in **Home Assistant** 2025.12, you **must** remove your existing **SAAS** integration entries and re-add them after updating to this version.
---
<h2>📖 description:</h2>
<p> <p>
sleep as android status is my solution for wake/sleep state within HA. it listens for the Sleep As Android MQTT messages, so it does require being on the same network. as of 0.0.4, buttons that link with the companion app have been added. Sleep As Android Status is my solution for wake/sleep state within HA. It listens for the Sleep As Android MQTT Messages, so it does require being on the same network. As of 0.0.4 Buttons that link with the Companion app have been added.
</p> </p>
<h3>🧱 this integration will create:</h3> <h4>💡 This integration works best with a Xioami MiBand (7 or older) mixed with the Notify app and Sleep As Android configured.</h4>
<h3>🧱 This integration will create:</h3>
<ul> <ul>
<li> <li>
<details> <details>
<summary><strong>📡 9 sensors</strong></summary> <summary><strong>📡 8 Sensors</strong></summary>
<ul>
| Sensor | Description | <li>Message Received *State</li>
| ------ | ----------- | <li>Wake Status</li>
| message received | shows the last raw MQTT event payload | <li>Sound</li>
| wake status | indicates awake or asleep based on your sleep stage | <li>Disturbance</li>
| sound | snore, talk, cough, and other sound events | <li>Alarm</li>
| disturbance | reports apnea and antisnoring events | <li>Lullaby</li>
| **next alarm** | upcoming alarm time and label; stores the last ten alarms in attributes | <li>Sleep Tracking</li>
| alarm | alarm related events such as snooze or dismiss | <li>Sleep Stage</li>
| lullaby | lullaby status | </ul>
| sleep tracking | whether sleep tracking is active or paused | <p>This should intelligently and dynamically allow for state changes in the Wake Status Sensor.</p>
| sleep stage | current sleep stage from Sleep As Android |
<p>the wake status sensor adjusts automatically based on the defined awake and asleep states.</p>
</details> </details>
</li> </li>
<li> <li>
<details> <details>
<summary><strong>🎛️ 8 buttons *only if mobile_app selected</strong></summary> <summary><strong>🎛️ 8 Buttons</strong></summary>
<ul> <ul>
<li>alarm dismiss</li> <li>Alarm Dismiss</li>
<li>alarm snooze</li> <li>Alarm Snooze</li>
<li>lullaby stop</li> <li>Lullaby Stop</li>
<li>sleep tracking pause</li> <li>Sleep Tracking Pause</li>
<li>sleep tracking resume</li> <li>Sleep Tracking Resume</li>
<li>sleep tracking start</li> <li>Sleep Tracking Start</li>
<li>sleep tracking start with optimal alarm</li> <li>Sleep Tracking Start with Optimal Alarm</li>
<li>sleep tracking stop</li> <li>Sleep Tracking Stop</li>
</ul> </ul>
</details> </details>
</li> </li>
<li> <li>
<details> <details>
<summary><strong>🛠️ 1 service</strong></summary> <summary><strong>🛠️ 1 Service</strong></summary>
<pre> <pre>
service: saas.saas_example_alarm_set service: saas.saas_example_alarm_set
data: data:
@ -85,89 +62,90 @@ data:
</li> </li>
<li> <li>
<details> <details>
<summary><strong>🔗 1 device per user</strong></summary> <summary><strong>🔗 1 Device per user</strong></summary>
<p>one HA device is created per configured user instance to link sensors, services, and buttons.</p> <p>One HA device is created per configured user instance to link sensors, services, and buttons.</p>
</details> </details>
</li> </li>
</ul> </ul>
<details> <details>
<summary><strong>known working</strong></summary> <summary><strong>Known working</strong></summary>
<ul> <ul>
<li>📟 **Xiaomi Mi Band 7**</li> <li>📟 Xioami Mi Band 7</li>
<li>📟 **Xiaomi Mi Band 8** and **Mi Band 9** may work, but they have a different os that jumps through hoops to work.</li> <li>📟 Xioami Mi Band 8 and 9 may work, but they have a different OS that jumps through hoops to work.</li>
<li>⌚ **Garmin Fenix 7X** with garmin alternative, **not** the free one.</li> <li>⌚ Garmin Fenix 7X with Garmin Alternative, <b>NOT</b> the free one.</li>
<li>⌚ **Xiaomi Amazfit GTR3 Pro** — may require root. i am rooted so i just did what's in this guide, but there may be alternative ways to get the key.</li> <li>⌚ Xioami Amazfit GTR Mini — may require root. I am rooted so I just did what's in this guide, but there may be alternative ways to get the key.</li>
</ul> </ul>
</details> </details>
<h2>🧪 installation:</h2> <h2>🧪 Installation:</h2>
<ul> <ul>
<li>add <code>https://www.github.com/sudoxnym/saas</code> to your custom repositories in HACS</li> <li>Add https://www.github.com/sudoxnym/saas to your Custom Repositories in HACS</li>
<li>search and download **SAAS - Sleep As Android status**</li> <li>Search and Download SAAS - Sleep As Android Status</li>
<li>restart Home Assistant</li> <li>Restart Home Assistant</li>
<li> <li>
<a href="https://my.home-assistant.io/redirect/config_flow_start/?domain=saas"> <a href="https://my.home-assistant.io/redirect/config_flow_start/?domain=saas">
<img src="https://my.home-assistant.io/badges/config_flow_start.svg" alt="add to ha"> <img src="https://my.home-assistant.io/badges/config_flow_start.svg" alt="Add to HA">
</a> </a>
</li> </li>
<li>add integration: **SAAS - Sleep As Android status**</li> <li>Add Integration: SAAS - Sleep As Android Status</li>
</ul> </ul>
<h2>⚙️ configuration:</h2> <h2>⚙️ Configuration:</h2>
<ul> <ul>
<li>name: name of user</li> <li>Name: Name of user</li>
<li>topic: MQTT topic from Sleep As Android *MUST MATCH*</li> <li>Topic: MQTT Topic from Sleep As Android</li>
<li>awake duration: time in seconds in which awake states = true to indicate awake. <b>fixed</b></li> <li>QoS: Quality of Service</li>
<li>asleep duration: time in seconds in which sleep states = true to indicate asleep. <b>fixed</b></li> <li>Awake Duration: Time in seconds in which awake states = true to indicate awake. <s>Sensor usually updates within 30 seconds or so after the duration, not entirely sure why the delay.</s> <b>FIXED</b></li>
<li>awake states: states to indicate being awake</li> <li>Asleep Duration: Time in seconds in which sleep states = true to indicate asleep. <s>Sensor usually updates within 30 seconds or so after the duration, not entirely sure why the delay.</s> <b>FIXED</b></li>
<li>asleep states: states to indicate being asleep</li> <li>Awake States: States to indicate being awake</li>
<li>mobile app: target for buttons <b>requires companion app *OPTIONAL: REQUIRES COMPANION APP*</b></li> <li>Asleep States: States to indicate being asleep</li>
<li>Mobile App: Target for buttons <b>REQUIRES COMPANION APP</b></li>
</ul> </ul>
<details> <details>
<summary><strong>📲 set up Notify for Mi Band 7</strong></summary> <summary><strong>📲 Set Up Notify for Mi Band 7</strong></summary>
<ol> <ol>
<li>pair **Mi Band 7** as you normally would with <a href="https://play.google.com/store/apps/details?id=com.xiaomi.wearable&hl=en_US">Mi Fitness</a></li> <li>Pair MiBand 7 as you normally would with <a href="https://play.google.com/store/apps/details?id=com.xiaomi.wearable&hl=en_US">Mi Fitness</a></li>
<li>obtain auth key for Notify app using ADB</li> <li>Obtain auth key for Notify app using ADB</li>
</ol> </ol>
<pre> <pre>
adb shell adb shell
grep -E "authKey=[a-z0-9]*," /sdcard/Android/data/com.xiaomi.wearable/files/log/XiaomiFit.device.log | grep -E "authKey=[a-z0-9]*," /sdcard/Android/data/com.xiaomi.wearable/files/log/XiaomiFit.device.log |
awk -F ", " '{print $17}' | grep authKey | tail -1 | awk -F "=" '{print $2}' awk -F ", " '{print $17}' | grep authKey | tail -1 | awk -F "=" '{print $2}'
</pre> </pre>
<p>credit: <a href="https://www.reddit.com/r/miband/comments/15j0rfq/comment/kxlyzc6/">iamfosscad</a></p> <p>Credit: <a href="https://www.reddit.com/r/miband/comments/15j0rfq/comment/kxlyzc6/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button">iamfosscad</a></p>
<ol start="3"> <ol start="3">
<li>uninstall **Mi Fitness**</li> <li>Uninstall Mi Fitness</li>
<li>download/install <a href="https://play.google.com/store/apps/details?id=com.mc.miband1&hl=en_US">Notify for Mi Band</a></li> <li>Download/Install <a href="https://play.google.com/store/apps/details?id=com.mc.miband1&hl=en_US">Notify for Mi Band</a></li>
<li>follow prompts, input auth key, select Mi Fitness is not installed</li> <li>Follow prompts, input auth key, select Mi Fitness is not installed</li>
<li>enable Sleep As Android in Notify settings</li> <li>Enable Sleep as Android in Notify settings</li>
</ol> </ol>
</details> </details>
<details> <details>
<summary><strong>🔐 extracting the Zepp <code>authKey</code> on a rooted android device</strong></summary> <summary><strong>🔐 Extracting the Zepp <code>authKey</code> on a Rooted Android Device</strong></summary>
<pre> <pre>
su su
cd /data/data/com.huami.watch.hmwatchmanager/databases/ cd /data/data/com.huami.watch.hmwatchmanager/databases/
ls origin_db_* ls origin_db_*
sqlite3 origin_db_1234567890 "SELECT AUTHKEY FROM DEVICE;" sqlite3 origin_db_1234567890 "SELECT AUTHKEY FROM DEVICE;"
</pre> </pre>
<ul> <ul>
<li>⚠️ do not unpair before extracting</li> <li>⚠️ Do Not Unpair before extracting</li>
<li>use with caution root required</li> <li>Use with caution root required</li>
<li>modified apps are available on <a href="https://geekdoing.com">GeekDoing</a> and <a href="https://freemyband.com">FreeMyBand</a></li> <li>Modified apps are available on <a href="https://geekdoing.com">GeekDoing</a> and <a href="https://freemyband.com">freemyband.com</a></li>
</ul> </ul>
</details> </details>
<h3>🛌 <a href="https://play.google.com/store/apps/details?id=com.urbandroid.sleep&hl=en_US">sleep as android setup</a></h3> <h3>🛌 <a href="https://play.google.com/store/apps/details?id=com.urbandroid.sleep&hl=en_US">Sleep as Android Setup</a></h3>
<ol> <ol>
<li>open the app and follow setup</li> <li>Open the app and follow setup</li>
<li>settings wheel > services > automation > MQTT</li> <li>Settings wheel > Services > Automation > MQTT</li>
</ol> </ol>
<pre> <pre>
@ -175,54 +153,42 @@ sqlite3 origin_db_1234567890 "SELECT AUTHKEY FROM DEVICE;"
</pre> </pre>
<ul> <ul>
<li>topic: must match config</li> <li>Topic: must match config</li>
<li>client id: any unique id</li> <li>Client ID: any unique ID</li>
</ul> </ul>
<ol start="4"> <ol start="4">
<li>enable automatic tracking</li> <li>Enable automatic tracking</li>
<li>sensor: sonar or accelerometer</li> <li>Sensor: Sonar or Accelerometer</li>
<li>wearables > **Xiaomi Mi Band** > test sensor</li> <li>Wearables > Xiaomi Mi Band > Test sensor</li>
</ol> </ol>
<details> <details>
<summary><strong>📦 changes</strong></summary> <summary><strong>📦 Changes</strong></summary>
<b>0.2.2</b> <b>0.0.6a</b>
<ul> <ul>
<li>added Next Alarm sensor with alarm label tracking</li> <li>Initial Beta Release</li>
<li>stores up to ten previous alarms in sensor attributes</li> <li>Added persistent states</li>
</ul> <li>Alarm Event sensor attributes</li>
<b>0.2.1</b>
<ul>
<li>fixed manifest error preventing config setup</li>
<li>fixed fine tuning in the configure section, now changing time or device actually works</li>
</ul>
<b>0.2.0</b>
<ul>
<li>added services.yaml to resolve known NoneType error</li>
<li>fixed deprecation warnings for future Home Assistant releases</li>
<li>breaking changes: remove and re-add existing integration entries after update</li>
</ul> </ul>
<b>0.1.0</b> <b>0.1.0</b>
<ul> <ul>
<li>fixed wake status timing</li> <li>Fixed Wake Status Timing</li>
<li>bug fixes on sound sensor</li> <li>Bug fixes on Sound sensor</li>
<li>accurate updates to alarmevent, disturbance, sound</li> <li>Accurate updates to AlarmEvent, Disturbance, Sound</li>
<li>organized readme</li> <li>Organized README</li>
</ul>
<b>0.0.6a</b>
<ul>
<li>initial beta release</li>
<li>added persistent states</li>
<li>alarm event sensor attributes</li>
</ul> </ul>
</details> </details>
<details> <details>
<summary><strong>🚨 known issues</strong></summary> <summary><strong>🚨 Known Issues</strong></summary>
<p>💬 no known issues at this time.</p> <pre>
</details> Logger: homeassistant.helpers.service
Source: /usr/src/homeassistant/homeassistant/helpers/service.py:708
Failed to load integration: saas
NoneType: None
</pre>
<p>💬 No known effects. Just an error message, everything works as expected.</p>
<p>This is my first integration.</p>
</details> </details>

View file

@ -5,53 +5,50 @@ from homeassistant.core import HomeAssistant
from .const import DOMAIN from .const import DOMAIN
from .services import async_setup_services # Import the service setup function 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: async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the SAAS - Sleep As Android Status component.""" """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 return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry.""" """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, {}) hass.data.setdefault(DOMAIN, {})
# merge original data + any saved options so runtime sees edits if entry.entry_id not in hass.data[DOMAIN] and entry.data:
merged = {**entry.data, **entry.options} hass.data[DOMAIN][entry.entry_id] = entry.data
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],
)
# forward setup to sensor and button platforms _logger.info(f"hass.data[DOMAIN] after adding entry data: {hass.data[DOMAIN]}")
# Forward the setup to the sensor and button platforms.
await hass.config_entries.async_forward_entry_setups(entry, ["sensor", "button"]) await hass.config_entries.async_forward_entry_setups(entry, ["sensor", "button"])
# set up any custom services _logger.info(f"hass.data[DOMAIN] before async_setup_services: {hass.data[DOMAIN]}")
_LOGGER.info("Starting setup of services")
await async_setup_services(hass)
_LOGGER.info("Finished setup of services")
_LOGGER.info("Finished setup of config entry") # Setup the services.
_logger.info("Starting setup of services")
await async_setup_services(hass)
_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")
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """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"]) unload_ok = await hass.config_entries.async_unload_platforms(entry, ["sensor", "button"])
if not unload_ok: if not unload_ok:
_LOGGER.error("Failed to unload platforms for saas") _logger.error("Failed to unload platforms for saas")
return False return False
# clean up our stored data if isinstance(hass.data.get(DOMAIN, {}), dict):
hass.data[DOMAIN].pop(entry.entry_id, None) hass.data[DOMAIN].pop(entry.entry_id, None)
_LOGGER.info(f"hass.data[{DOMAIN}] after unload: {hass.data.get(DOMAIN)}") _logger.info(f"hass.data[DOMAIN] after removing entry data: {hass.data[DOMAIN]}")
_LOGGER.info("Finished unload of config entry") _logger.info("Finished unload of config entry")
return True return True

View file

@ -1,7 +1,7 @@
import logging import logging
from homeassistant.components.button import ButtonEntity from homeassistant.components.button import ButtonEntity
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, INTEGRATION_NAME, MODEL, CONF_NAME, CONF_NOTIFY_TARGET from .const import DOMAIN, INTEGRATION_NAME, MODEL, CONF_NAME
import asyncio import asyncio
# Set up logging # Set up logging
@ -50,12 +50,6 @@ class SAASSleepTrackingStart(ButtonEntity):
return device_info return device_info
def press(self): 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.""" """Press the button."""
service_name = self._notify_target # Remove the "notify." prefix service_name = self._notify_target # Remove the "notify." prefix
@ -123,12 +117,6 @@ class SAASSleepTrackingStop(ButtonEntity):
return device_info return device_info
def press(self): 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.""" """Press the button."""
service_name = self._notify_target # Remove the "notify." prefix service_name = self._notify_target # Remove the "notify." prefix
@ -196,12 +184,6 @@ class SAASSleepTrackingPause(ButtonEntity):
return device_info return device_info
def press(self): 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.""" """Press the button."""
service_name = self._notify_target # Remove the "notify." prefix service_name = self._notify_target # Remove the "notify." prefix
@ -269,12 +251,6 @@ class SAASSleepTrackingResume(ButtonEntity):
return device_info return device_info
def press(self): 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.""" """Press the button."""
service_name = self._notify_target # Remove the "notify." prefix service_name = self._notify_target # Remove the "notify." prefix
@ -342,12 +318,6 @@ class SAASAlarmClockSnooze(ButtonEntity):
return device_info return device_info
def press(self): 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.""" """Press the button."""
service_name = self._notify_target # Remove the "notify." prefix service_name = self._notify_target # Remove the "notify." prefix
@ -415,12 +385,6 @@ class SAASAlarmClockDisable(ButtonEntity):
return device_info return device_info
def press(self): 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.""" """Press the button."""
service_name = self._notify_target # Remove the "notify." prefix service_name = self._notify_target # Remove the "notify." prefix
@ -488,12 +452,6 @@ class SAASSleepTrackingStartWithAlarm(ButtonEntity):
return device_info return device_info
def press(self): 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.""" """Press the button."""
service_name = self._notify_target # Remove the "notify." prefix service_name = self._notify_target # Remove the "notify." prefix
@ -561,12 +519,6 @@ class SAASLullabyStop(ButtonEntity):
return device_info return device_info
def press(self): 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.""" """Press the button."""
service_name = self._notify_target # Remove the "notify." prefix service_name = self._notify_target # Remove the "notify." prefix
@ -594,15 +546,12 @@ class SAASLullabyStop(ButtonEntity):
async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_entry(hass, config_entry, async_add_entities):
notify_target = config_entry.data.get(CONF_NOTIFY_TARGET) """Set up the SAAS Sleep Tracking Start, Stop and Pause buttons from a config entry."""
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) # _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 # Extract the necessary data from config_entry.data
name = config_entry.data[CONF_NAME] name = config_entry.data[CONF_NAME]
notify_target = config_entry.data.get(CONF_NOTIFY_TARGET) notify_target = config_entry.data['notify_target']
# Create instances of SAASSleepTrackingStart, SAASSleepTrackingStop and SAASSleepTrackingPause # Create instances of SAASSleepTrackingStart, SAASSleepTrackingStop and SAASSleepTrackingPause
entities = [ entities = [

View file

@ -4,6 +4,7 @@ from .const import (
DOMAIN, DOMAIN,
CONF_NAME, CONF_NAME,
CONF_TOPIC, CONF_TOPIC,
CONF_QOS,
AVAILABLE_STATES, AVAILABLE_STATES,
CONF_AWAKE_DURATION, CONF_AWAKE_DURATION,
CONF_SLEEP_DURATION, CONF_SLEEP_DURATION,
@ -16,169 +17,129 @@ from .const import (
CONF_NOTIFY_TARGET, CONF_NOTIFY_TARGET,
) )
from homeassistant import config_entries from homeassistant import config_entries
from voluptuous import Schema, Required, Optional, In from homeassistant.core import callback
from voluptuous import Schema, Required, In, Optional
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class MyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class MyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle the initial config flow for SAAS.""" """Handle a config flow for SAAS."""
VERSION = 2 VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input=None):
"""Initial setup step.""" """Handle the initial step."""
errors = {} errors = {}
# discover available mobile_app notify services # Build notify targets list
notify_services = self.hass.services.async_services().get("notify", {}) notify_services = self.hass.services.async_services().get("notify", {})
notify_targets = { notify_targets = {
svc.replace("mobile_app_", "") key.replace("mobile_app_", "").title(): key
.replace("_", " ") for key in notify_services.keys()
.lower(): svc if key.startswith("mobile_app_")
for svc in notify_services
if svc.startswith("mobile_app_")
} }
if user_input is not None: if user_input is not None:
# map chosen label back to service name, or remove if invalid # Map back the chosen label to service name
nt_label = user_input.get(CONF_NOTIFY_TARGET) if user_input.get(CONF_NOTIFY_TARGET):
if nt_label in notify_targets: user_input[CONF_NOTIFY_TARGET] = notify_targets.get(user_input[CONF_NOTIFY_TARGET])
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): if not user_input.get(CONF_NAME):
errors[CONF_NAME] = "required" errors[CONF_NAME] = "required"
if not errors: if not errors:
return self.async_create_entry( return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
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( return self.async_show_form(
step_id="user", step_id="user",
data_schema=Schema(schema), 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())),
}
),
errors=errors, 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 @staticmethod
@config_entries.callback @callback
def async_get_options_flow(entry): def async_get_options_flow(entry):
"""Return options flow handler.""" """Get the options flow handler."""
return OptionsFlowHandler(entry) return OptionsFlowHandler(entry)
class OptionsFlowHandler(config_entries.OptionsFlow): class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle SAAS options editing.""" """Handle SAAS options."""
def __init__(self, entry): def __init__(self, entry):
"""Initialize options flow."""
super().__init__() super().__init__()
self._config_entry = entry self._config_entry = entry # use private attribute
async def async_step_init(self, user_input=None): async def async_step_init(self, user_input=None):
"""Manage the options form (edit).""" """Manage the options."""
current = dict(self._config_entry.data) # 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]
# discover mobile_app notify services again # Build notify targets list
notify_services = self.hass.services.async_services().get("notify", {}) notify_services = self.hass.services.async_services().get("notify", {})
notify_targets = { notify_targets = {
svc.replace("mobile_app_", "") key.replace("mobile_app_", "").title(): key
.replace("_", " ") for key in notify_services.keys()
.lower(): svc if key.startswith("mobile_app_")
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: if user_input is not None:
new_data = current.copy() return self.async_create_entry(title="", data=user_input)
# 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( return self.async_show_form(
step_id="init", step_id="init",
data_schema=Schema(schema), 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())),
}
),
errors={}, errors={},
) )

View file

@ -5,24 +5,21 @@ DOMAIN = "saas"
INTEGRATION_NAME = "SAAS - Sleep As Android Stats" INTEGRATION_NAME = "SAAS - Sleep As Android Stats"
MODEL = "SAAS - Version 0.0.1" MODEL = "SAAS - Version 0.0.1"
CONF_NAME = "name" # Name of the Integration CONF_NAME = "name" # Name of the Integration
CONF_TOPIC = "topic_template" # MQTT Topic for Sleep As Android Events CONF_TOPIC = "topic_template" # MQTT Topic for Sleep As Android Events
CONF_AWAKE_DURATION = "awake_duration" # Awake Duration CONF_QOS = "qos" # Quality of Service
CONF_AWAKE_DURATION = "awake_duration" # Awake Duration
CONF_SLEEP_DURATION = "sleep_duration" # Sleep Duration CONF_SLEEP_DURATION = "sleep_duration" # Sleep Duration
CONF_AWAKE_STATES = "awake_states" # Awake States
CONF_SLEEP_STATES = "sleep_states" # Sleep States
CONF_NOTIFY_TARGET = "notify_target" # Notify Target
DEFAULT_AWAKE_DURATION = 10 # Default Awake Duration CONF_AWAKE_STATES = "awake_states" # Awake States
DEFAULT_SLEEP_DURATION = 10 # Default Sleep Duration CONF_SLEEP_STATES = "sleep_states" # Sleep States
DEFAULT_AWAKE_STATES = ["Awake", "Sleep Tracking Stopped"] # Default Awake States
DEFAULT_SLEEP_STATES = [ CONF_NOTIFY_TARGET = "notify_target" # Notify Target
"Not Awake",
"Rem", DEFAULT_AWAKE_DURATION = 10 # Default Awake Duration
"Light Sleep", DEFAULT_SLEEP_DURATION = 10 # Default Sleep Duration
"Deep Sleep", DEFAULT_AWAKE_STATES = ["Awake", "Sleep Tracking Stopped"] # Default Awake States
"Sleep Tracking Started", DEFAULT_SLEEP_STATES = ["Not Awake", "Rem", "Light Sleep", "Deep Sleep", "Sleep Tracking Started"]# Default Sleep States
] # Default Sleep States
SENSOR_TYPES = { SENSOR_TYPES = {
"state": {"name": "State", "device_class": None}, "state": {"name": "State", "device_class": None},
@ -36,7 +33,7 @@ DAY_MAPPING = {
"wednesday": 4, "wednesday": 4,
"thursday": 5, "thursday": 5,
"friday": 6, "friday": 6,
"saturday": 7, "saturday": 7
} }
AVAILABLE_STATES = [ AVAILABLE_STATES = [
@ -70,9 +67,8 @@ AVAILABLE_STATES = [
'Sound Event Laugh', 'Sound Event Laugh',
'Sound Event Snore', 'Sound Event Snore',
'Sound Event Talk', 'Sound Event Talk',
'Time for Bed', 'Time for Bed'
] ]
STATE_MAPPING = { STATE_MAPPING = {
"unknown": "Unknown", "unknown": "Unknown",
"sleep_tracking_started": "Sleep Tracking Started", "sleep_tracking_started": "Sleep Tracking Started",
@ -104,7 +100,7 @@ STATE_MAPPING = {
"sound_event_cough": "Cough Detected", "sound_event_cough": "Cough Detected",
"sound_event_baby": "Baby Cry Detected", "sound_event_baby": "Baby Cry Detected",
"sound_event_laugh": "Laugh 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()} REVERSE_STATE_MAPPING = {v: k for k, v in STATE_MAPPING.items()}
@ -115,15 +111,14 @@ SLEEP_STAGE_MAPPING = {
"deep_sleep": "Deep Sleep", "deep_sleep": "Deep Sleep",
"light_sleep": "Light Sleep", "light_sleep": "Light Sleep",
"awake": "Awake", "awake": "Awake",
"not_awake": "Not Awake", "not_awake": "Not Awake"
} }
SOUND_MAPPING = { SOUND_MAPPING = {
'sound_event_snore': "Snore Detected", 'sound_event_snore': "Snore Detected",
'sound_event_talk': "Talk Detected", 'sound_event_talk': "Talk Detected",
'sound_event_cough': "Cough Detected", 'sound_event_cough': "Cough Detected",
'sound_event_baby': "Baby Cry Detected", 'sound_event_baby': "Baby Cry Detected",
'sound_event_laugh': "Laugh Detected", 'sound_event_laugh': "Laugh Detected"
} }
LULLABY_MAPPING = { LULLABY_MAPPING = {
@ -134,7 +129,7 @@ LULLABY_MAPPING = {
DISTURBANCE_MAPPING = { DISTURBANCE_MAPPING = {
'apnea_alarm': "Apnea Alarm", 'apnea_alarm': "Apnea Alarm",
'antisnoring': "Antisnoring", 'antisnoring': "Antisnoring"
} }
ALARM_EVENT_MAPPING = { ALARM_EVENT_MAPPING = {
@ -148,12 +143,12 @@ ALARM_EVENT_MAPPING = {
'show_skip_next_alarm': "Show Skip Next Alarm", 'show_skip_next_alarm': "Show Skip Next Alarm",
'smart_period': "Smart Period", 'smart_period': "Smart Period",
'before_smart_period': "Before Smart Period", 'before_smart_period': "Before Smart Period",
"alarm_rescheduled": "Alarm Rescheduled", "alarm_rescheduled": "Alarm Rescheduled"
} }
SLEEP_TRACKING_MAPPING = { SLEEP_TRACKING_MAPPING = {
'sleep_tracking_started': "Sleep Tracking Started", 'sleep_tracking_started': "Sleep Tracking Started",
'sleep_tracking_stopped': "Sleep Tracking Stopped", 'sleep_tracking_stopped': "Sleep Tracking Stopped",
'sleep_tracking_paused': "Sleep Tracking Paused", 'sleep_tracking_paused': "Sleep Tracking Paused",
'sleep_tracking_resumed': "Sleep Tracking Resumed", 'sleep_tracking_resumed': "Sleep Tracking Resumed"
} }

View file

@ -6,5 +6,5 @@
"dependencies": ["mqtt", "mobile_app"], "dependencies": ["mqtt", "mobile_app"],
"documentation": "https://www.github.com/sudoxnym/saas", "documentation": "https://www.github.com/sudoxnym/saas",
"issue_tracker": "https://www.github.com/sudoxnym/saas/issues", "issue_tracker": "https://www.github.com/sudoxnym/saas/issues",
"version": "0.2.1" "version": "0.1.0"
} }

View file

@ -219,120 +219,6 @@ class SAASAlarmEventSensor(RestoreEntity):
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to 'None' due to timeout for sensor {self.name}") _LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to 'None' due to timeout for sensor {self.name}")
self.async_schedule_update_ha_state() self.async_schedule_update_ha_state()
class SAASNextAlarmSensor(RestoreEntity):
"""Sensor that exposes the next scheduled alarm time and label."""
def __init__(self, hass, name, entry_id):
self._hass = hass
self._name = name
self.entry_id = entry_id
self._state = None
self._label = None
self._alarms = []
@property
def unique_id(self):
return f"saas_next_alarm_{self._name}"
@property
def name(self):
return f"SAAS {self._name} Next Alarm"
@property
def state(self):
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,
}
@property
def extra_state_attributes(self):
attrs = {}
if self._label:
attrs["Label"] = self._label
if self._alarms:
attrs["Alarms"] = self._alarms
return attrs
async def async_added_to_hass(self):
await super().async_added_to_hass()
state = await self.async_get_last_state()
if state:
self._state = state.state if state.state != "None" else None
self._label = state.attributes.get("Label")
saved = state.attributes.get("Alarms")
if isinstance(saved, list):
self._alarms = saved
async def message_received(msg):
msg_json = json.loads(msg.payload)
event = msg_json.get("event")
value1 = msg_json.get("value1")
value2 = msg_json.get("value2")
if event == "alarm_rescheduled":
if value1:
ts = int(value1) / 1000.0
dt = dt_util.as_local(datetime.fromtimestamp(ts))
epoch = dt.timestamp()
found = False
for alarm in self._alarms:
if alarm["timestamp"] == epoch:
alarm["label"] = value2
found = True
break
if not found:
self._alarms.append({"timestamp": epoch, "label": value2})
self._alarms.sort(key=lambda x: x["timestamp"])
self._alarms = self._alarms[:10]
elif event in ("alarm_alert_dismiss", "alarm_skip_next"):
if value1:
ts = int(value1) / 1000.0
dt = dt_util.as_local(datetime.fromtimestamp(ts))
epoch = dt.timestamp()
self._alarms = [a for a in self._alarms if a["timestamp"] != epoch]
elif self._alarms:
self._alarms.pop(0)
else:
return
if self._alarms:
next_alarm = self._alarms[0]
dt = dt_util.as_local(datetime.fromtimestamp(next_alarm["timestamp"]))
self._state = dt.strftime("%Y-%m-%d %H:%M")
self._label = next_alarm.get("label")
else:
self._state = None
self._label = None
self.async_schedule_update_ha_state()
await async_subscribe(
self._hass,
self._hass.data[DOMAIN][self.entry_id][CONF_TOPIC],
message_received,
)
async def async_will_remove_from_hass(self):
"""Run when entity will be removed from hass."""
self.hass.states.async_set(
self.entity_id,
self._state,
{"Label": self._label, "Alarms": self._alarms},
)
_LOGGER.info(
f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Saved state: {self._state} for sensor {self.name}"
)
class SAASSoundSensor(RestoreEntity): class SAASSoundSensor(RestoreEntity):
"""Representation of a SAAS - Sleep As Android Stats sensor for Sound Events.""" """Representation of a SAAS - Sleep As Android Stats sensor for Sound Events."""
@ -934,7 +820,6 @@ async def async_setup_entry(hass, entry, async_add_entities):
entities = [ entities = [
SAASSensor(hass, name, STATE_MAPPING, entry_id), SAASSensor(hass, name, STATE_MAPPING, entry_id),
SAASAlarmEventSensor(hass, name, ALARM_EVENT_MAPPING, entry_id), SAASAlarmEventSensor(hass, name, ALARM_EVENT_MAPPING, entry_id),
SAASNextAlarmSensor(hass, name, entry_id),
SAASSoundSensor(hass, name, SOUND_MAPPING, entry_id), SAASSoundSensor(hass, name, SOUND_MAPPING, entry_id),
SAASSleepTrackingSensor(hass, name, SLEEP_TRACKING_MAPPING, entry_id), SAASSleepTrackingSensor(hass, name, SLEEP_TRACKING_MAPPING, entry_id),
SAASDisturbanceSensor(hass, name, DISTURBANCE_MAPPING, entry_id), SAASDisturbanceSensor(hass, name, DISTURBANCE_MAPPING, entry_id),
@ -947,4 +832,4 @@ async def async_setup_entry(hass, entry, async_add_entities):
if hasattr(entity, "async_setup"): if hasattr(entity, "async_setup"):
await entity.async_setup() await entity.async_setup()
async_add_entities(entities) async_add_entities(entities)

View file

@ -1 +0,0 @@
#empty on purpose