Compare commits

...

15 commits
0.2 ... main

Author SHA1 Message Date
sudoxnym
e2e35f8f20 deprecate in favor of sleepd 2025-12-07 08:05:27 -06:00
sudoxnym
0cbe7ba589
Merge pull request #12 from sudoxnym/codex/update-readme-with-new-sensor
Update README with next alarm sensor
2025-06-28 22:45:28 -06:00
sudoxnym
e4b0fa0375 docs: document next alarm sensor 2025-06-28 22:43:57 -06:00
sudoxnym
45767967a4
Add files via upload 2025-06-28 22:28:11 -06:00
sudoxnym
7c359a4b6e
Merge pull request #10 from sudoxnym/codex/add-sensor-entity-with-alarm-and-label
Add next alarm sensor
2025-06-28 21:43:30 -06:00
sudoxnym
23f5d5dd18 Add next alarm sensor 2025-06-28 21:40:21 -06:00
sudoxnym
5f3a7e78f5
Update README.md 2025-05-27 15:57:09 -06:00
sudoxnym
359dd530fe
Update README.md 2025-05-05 09:03:04 -06:00
sudoxnym
6de713d0c6
Update README.md 2025-05-03 14:50:35 -06:00
sudoxnym
6dc8fa9afa
Update manifest.json 2025-05-03 14:47:06 -06:00
sudoxnym
f5161159d7
Add files via upload 2025-05-03 14:39:55 -06:00
sudoxnym
ccbdd154f2
Update README.md 2025-05-03 14:39:19 -06:00
sudoxnym
3f7c73ab46
Update manifest.json 2025-05-03 10:27:47 -06:00
sudoxnym
3a41318279
Update manifest.json 2025-04-25 23:01:16 -06:00
sudoxnym
1f52692d07
Update README.md 2025-04-15 12:37:36 -06:00
8 changed files with 475 additions and 227 deletions

232
README.md
View file

@ -1,55 +1,78 @@
<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>📖 Description:</h2> <h2>🚨 0.2.0 breaking changes 🚨</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>
<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>
<h3>🧱 This integration will create:</h3>
<ul> <ul>
<li> <li>
<details> <details>
<summary><strong>📡 8 Sensors</strong></summary> <summary><strong>📡 9 sensors</strong></summary>
<ul>
<li>Message Received *State</li> | Sensor | Description |
<li>Wake Status</li> | ------ | ----------- |
<li>Sound</li> | message received | shows the last raw MQTT event payload |
<li>Disturbance</li> | wake status | indicates awake or asleep based on your sleep stage |
<li>Alarm</li> | sound | snore, talk, cough, and other sound events |
<li>Lullaby</li> | disturbance | reports apnea and antisnoring events |
<li>Sleep Tracking</li> | **next alarm** | upcoming alarm time and label; stores the last ten alarms in attributes |
<li>Sleep Stage</li> | alarm | alarm related events such as snooze or dismiss |
</ul> | lullaby | lullaby status |
<p>This should intelligently and dynamically allow for state changes in the Wake Status Sensor.</p> | sleep tracking | whether sleep tracking is active or paused |
| 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</strong></summary> <summary><strong>🎛️ 8 buttons *only if mobile_app selected</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:
@ -62,90 +85,89 @@ 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>📟 Xioami Mi Band 7</li> <li>📟 **Xiaomi Mi Band 7**</li>
<li>📟 Xioami Mi Band 8 and 9 may work, but they have a different OS that jumps through hoops to work.</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>⌚ Garmin Fenix 7X with Garmin Alternative, <b>NOT</b> the free one.</li> <li>⌚ **Garmin Fenix 7X** with garmin alternative, **not** the free one.</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> <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>
</ul> </ul>
</details> </details>
<h2>🧪 Installation:</h2> <h2>🧪 installation:</h2>
<ul> <ul>
<li>Add https://www.github.com/sudoxnym/saas to your Custom Repositories in HACS</li> <li>add <code>https://www.github.com/sudoxnym/saas</code> 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</li> <li>topic: MQTT topic from Sleep As Android *MUST MATCH*</li>
<li>QoS: Quality of Service</li> <li>awake duration: time in seconds in which awake states = true to indicate awake. <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>asleep duration: time in seconds in which sleep states = true to indicate asleep. <b>fixed</b></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>awake states: states to indicate being awake</li>
<li>Awake States: States to indicate being awake</li> <li>asleep states: states to indicate being asleep</li>
<li>Asleep States: States to indicate being asleep</li> <li>mobile app: target for buttons <b>requires companion app *OPTIONAL: REQUIRES COMPANION APP*</b></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 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>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>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/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button">iamfosscad</a></p> <p>credit: <a href="https://www.reddit.com/r/miband/comments/15j0rfq/comment/kxlyzc6/">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.com</a></li> <li>modified apps are available on <a href="https://geekdoing.com">GeekDoing</a> and <a href="https://freemyband.com">FreeMyBand</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>
@ -153,42 +175,54 @@ 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.0.6a</b> <b>0.2.2</b>
<ul> <ul>
<li>Initial Beta Release</li> <li>added Next Alarm sensor with alarm label tracking</li>
<li>Added persistent states</li> <li>stores up to ten previous alarms in sensor attributes</li>
<li>Alarm Event sensor attributes</li> </ul>
<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>
<pre> <p>💬 no known issues at this time.</p>
Logger: homeassistant.helpers.service </details>
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,50 +5,53 @@ 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, {})
if entry.entry_id not in hass.data[DOMAIN] and entry.data: # merge original data + any saved options so runtime sees edits
hass.data[DOMAIN][entry.entry_id] = entry.data 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 setup to sensor and button platforms
# 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"])
_logger.info(f"hass.data[DOMAIN] before async_setup_services: {hass.data[DOMAIN]}") # set up any custom services
_LOGGER.info("Starting setup of services")
# Setup the services.
_logger.info("Starting setup of services")
await async_setup_services(hass) 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 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
if isinstance(hass.data.get(DOMAIN, {}), dict): # clean up our stored data
hass.data[DOMAIN].pop(entry.entry_id, None) hass.data[DOMAIN].pop(entry.entry_id, None)
_logger.info(f"hass.data[DOMAIN] after removing entry data: {hass.data[DOMAIN]}") _LOGGER.info(f"hass.data[{DOMAIN}] after unload: {hass.data.get(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 from .const import DOMAIN, INTEGRATION_NAME, MODEL, CONF_NAME, CONF_NOTIFY_TARGET
import asyncio import asyncio
# Set up logging # Set up logging
@ -50,6 +50,12 @@ 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
@ -117,6 +123,12 @@ 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
@ -184,6 +196,12 @@ 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
@ -251,6 +269,12 @@ 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
@ -318,6 +342,12 @@ 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
@ -385,6 +415,12 @@ 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
@ -452,6 +488,12 @@ 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
@ -519,6 +561,12 @@ 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
@ -546,12 +594,15 @@ 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):
"""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) # _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['notify_target'] notify_target = config_entry.data.get(CONF_NOTIFY_TARGET)
# Create instances of SAASSleepTrackingStart, SAASSleepTrackingStop and SAASSleepTrackingPause # Create instances of SAASSleepTrackingStart, SAASSleepTrackingStop and SAASSleepTrackingPause
entities = [ entities = [

View file

@ -4,7 +4,6 @@ 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,
@ -17,129 +16,169 @@ from .const import (
CONF_NOTIFY_TARGET, CONF_NOTIFY_TARGET,
) )
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.core import callback from voluptuous import Schema, Required, Optional, In
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 a config flow for SAAS.""" """Handle the initial 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):
"""Handle the initial step.""" """Initial setup step."""
errors = {} errors = {}
# Build notify targets list # discover available mobile_app notify services
notify_services = self.hass.services.async_services().get("notify", {}) notify_services = self.hass.services.async_services().get("notify", {})
notify_targets = { notify_targets = {
key.replace("mobile_app_", "").title(): key svc.replace("mobile_app_", "")
for key in notify_services.keys() .replace("_", " ")
if key.startswith("mobile_app_") .lower(): svc
for svc in notify_services
if svc.startswith("mobile_app_")
} }
if user_input is not None: if user_input is not None:
# Map back the chosen label to service name # map chosen label back to service name, or remove if invalid
if user_input.get(CONF_NOTIFY_TARGET): nt_label = user_input.get(CONF_NOTIFY_TARGET)
user_input[CONF_NOTIFY_TARGET] = notify_targets.get(user_input[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): 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(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( return self.async_show_form(
step_id="user", step_id="user",
data_schema=Schema( data_schema=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
@callback @config_entries.callback
def async_get_options_flow(entry): def async_get_options_flow(entry):
"""Get the options flow handler.""" """Return options flow handler."""
return OptionsFlowHandler(entry) return OptionsFlowHandler(entry)
class OptionsFlowHandler(config_entries.OptionsFlow): class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle SAAS options.""" """Handle SAAS options editing."""
def __init__(self, entry): def __init__(self, entry):
"""Initialize options flow."""
super().__init__() super().__init__()
self._config_entry = entry # use private attribute self._config_entry = entry
async def async_step_init(self, user_input=None): async def async_step_init(self, user_input=None):
"""Manage the options.""" """Manage the options form (edit)."""
# Load current options or fall back to data current = dict(self._config_entry.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]
# Build notify targets list # discover mobile_app notify services again
notify_services = self.hass.services.async_services().get("notify", {}) notify_services = self.hass.services.async_services().get("notify", {})
notify_targets = { notify_targets = {
key.replace("mobile_app_", "").title(): key svc.replace("mobile_app_", "")
for key in notify_services.keys() .replace("_", " ")
if key.startswith("mobile_app_") .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: 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( return self.async_show_form(
step_id="init", step_id="init",
data_schema=vol.Schema( data_schema=Schema(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,21 +5,24 @@ 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_QOS = "qos" # Quality of Service CONF_AWAKE_DURATION = "awake_duration" # Awake Duration
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_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_DURATION = 10 # Default Awake Duration DEFAULT_AWAKE_STATES = ["Awake", "Sleep Tracking Stopped"] # Default Awake States
DEFAULT_SLEEP_DURATION = 10 # Default Sleep Duration DEFAULT_SLEEP_STATES = [
DEFAULT_AWAKE_STATES = ["Awake", "Sleep Tracking Stopped"] # Default Awake States "Not Awake",
DEFAULT_SLEEP_STATES = ["Not Awake", "Rem", "Light Sleep", "Deep Sleep", "Sleep Tracking Started"]# Default Sleep States "Rem",
"Light Sleep",
"Deep Sleep",
"Sleep Tracking Started",
] # Default Sleep States
SENSOR_TYPES = { SENSOR_TYPES = {
"state": {"name": "State", "device_class": None}, "state": {"name": "State", "device_class": None},
@ -33,7 +36,7 @@ DAY_MAPPING = {
"wednesday": 4, "wednesday": 4,
"thursday": 5, "thursday": 5,
"friday": 6, "friday": 6,
"saturday": 7 "saturday": 7,
} }
AVAILABLE_STATES = [ AVAILABLE_STATES = [
@ -67,8 +70,9 @@ 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",
@ -100,7 +104,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()}
@ -111,14 +115,15 @@ 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 = {
@ -129,7 +134,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 = {
@ -143,12 +148,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.1.0" "version": "0.2.1"
} }

View file

@ -219,6 +219,120 @@ 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."""
@ -820,6 +934,7 @@ 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),
@ -832,4 +947,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

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