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">
<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>
</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>
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>
<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>
<li>
<details>
<summary><strong>📡 8 Sensors</strong></summary>
<ul>
<li>Message Received *State</li>
<li>Wake Status</li>
<li>Sound</li>
<li>Disturbance</li>
<li>Alarm</li>
<li>Lullaby</li>
<li>Sleep Tracking</li>
<li>Sleep Stage</li>
</ul>
<p>This should intelligently and dynamically allow for state changes in the Wake Status Sensor.</p>
<summary><strong>📡 9 sensors</strong></summary>
| Sensor | Description |
| ------ | ----------- |
| message received | shows the last raw MQTT event payload |
| wake status | indicates awake or asleep based on your sleep stage |
| sound | snore, talk, cough, and other sound events |
| disturbance | reports apnea and antisnoring events |
| **next alarm** | upcoming alarm time and label; stores the last ten alarms in attributes |
| alarm | alarm related events such as snooze or dismiss |
| lullaby | lullaby status |
| 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>
</li>
<li>
<details>
<summary><strong>🎛️ 8 Buttons</strong></summary>
<summary><strong>🎛️ 8 buttons *only if mobile_app selected</strong></summary>
<ul>
<li>Alarm Dismiss</li>
<li>Alarm Snooze</li>
<li>Lullaby Stop</li>
<li>Sleep Tracking Pause</li>
<li>Sleep Tracking Resume</li>
<li>Sleep Tracking Start</li>
<li>Sleep Tracking Start with Optimal Alarm</li>
<li>Sleep Tracking Stop</li>
<li>alarm dismiss</li>
<li>alarm snooze</li>
<li>lullaby stop</li>
<li>sleep tracking pause</li>
<li>sleep tracking resume</li>
<li>sleep tracking start</li>
<li>sleep tracking start with optimal alarm</li>
<li>sleep tracking stop</li>
</ul>
</details>
</li>
<li>
<details>
<summary><strong>🛠️ 1 Service</strong></summary>
<summary><strong>🛠️ 1 service</strong></summary>
<pre>
service: saas.saas_example_alarm_set
data:
@ -62,90 +85,89 @@ data:
</li>
<li>
<details>
<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>
<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>
</details>
</li>
</ul>
<details>
<summary><strong>Known working</strong></summary>
<summary><strong>known working</strong></summary>
<ul>
<li>📟 Xioami 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>⌚ Garmin Fenix 7X with Garmin Alternative, <b>NOT</b> 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 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>⌚ **Garmin Fenix 7X** with garmin alternative, **not** 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>
</ul>
</details>
<h2>🧪 Installation:</h2>
<h2>🧪 installation:</h2>
<ul>
<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>Restart Home Assistant</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>restart Home Assistant</li>
<li>
<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>
</li>
<li>Add Integration: SAAS - Sleep As Android Status</li>
<li>add integration: **SAAS - Sleep As Android status**</li>
</ul>
<h2>⚙️ Configuration:</h2>
<h2>⚙️ configuration:</h2>
<ul>
<li>Name: Name of user</li>
<li>Topic: MQTT Topic from Sleep As Android</li>
<li>QoS: Quality of Service</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. <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 States: States to indicate being asleep</li>
<li>Mobile App: Target for buttons <b>REQUIRES COMPANION APP</b></li>
<li>name: name of user</li>
<li>topic: MQTT topic from Sleep As Android *MUST MATCH*</li>
<li>awake duration: time in seconds in which awake states = true to indicate awake. <b>fixed</b></li>
<li>asleep duration: time in seconds in which sleep states = true to indicate asleep. <b>fixed</b></li>
<li>awake states: states to indicate being awake</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>
</ul>
<details>
<summary><strong>📲 Set Up Notify for Mi Band 7</strong></summary>
<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>Obtain auth key for Notify app using ADB</li>
</ol>
<summary><strong>📲 set up Notify for Mi Band 7</strong></summary>
<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>obtain auth key for Notify app using ADB</li>
</ol>
<pre>
<pre>
adb shell
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}'
</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">
<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>Follow prompts, input auth key, select Mi Fitness is not installed</li>
<li>Enable Sleep as Android in Notify settings</li>
</ol>
<ol start="3">
<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>follow prompts, input auth key, select Mi Fitness is not installed</li>
<li>enable Sleep As Android in Notify settings</li>
</ol>
</details>
<details>
<summary><strong>🔐 Extracting the Zepp <code>authKey</code> on a Rooted Android Device</strong></summary>
<pre>
<summary><strong>🔐 extracting the Zepp <code>authKey</code> on a rooted android device</strong></summary>
<pre>
su
cd /data/data/com.huami.watch.hmwatchmanager/databases/
ls origin_db_*
sqlite3 origin_db_1234567890 "SELECT AUTHKEY FROM DEVICE;"
</pre>
</pre>
<ul>
<li>⚠️ Do Not Unpair before extracting</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>
</ul>
<ul>
<li>⚠️ do not unpair before extracting</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>
</ul>
</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>
<li>Open the app and follow setup</li>
<li>Settings wheel > Services > Automation > MQTT</li>
<li>open the app and follow setup</li>
<li>settings wheel > services > automation > MQTT</li>
</ol>
<pre>
@ -153,42 +175,54 @@ sqlite3 origin_db_1234567890 "SELECT AUTHKEY FROM DEVICE;"
</pre>
<ul>
<li>Topic: must match config</li>
<li>Client ID: any unique ID</li>
<li>topic: must match config</li>
<li>client id: any unique id</li>
</ul>
<ol start="4">
<li>Enable automatic tracking</li>
<li>Sensor: Sonar or Accelerometer</li>
<li>Wearables > Xiaomi Mi Band > Test sensor</li>
<li>enable automatic tracking</li>
<li>sensor: sonar or accelerometer</li>
<li>wearables > **Xiaomi Mi Band** > test sensor</li>
</ol>
<details>
<summary><strong>📦 Changes</strong></summary>
<b>0.0.6a</b>
<summary><strong>📦 changes</strong></summary>
<b>0.2.2</b>
<ul>
<li>Initial Beta Release</li>
<li>Added persistent states</li>
<li>Alarm Event sensor attributes</li>
<li>added Next Alarm sensor with alarm label tracking</li>
<li>stores up to ten previous alarms in 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>
<b>0.1.0</b>
<ul>
<li>Fixed Wake Status Timing</li>
<li>Bug fixes on Sound sensor</li>
<li>Accurate updates to AlarmEvent, Disturbance, Sound</li>
<li>Organized README</li>
<li>fixed wake status timing</li>
<li>bug fixes on sound sensor</li>
<li>accurate updates to alarmevent, disturbance, sound</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>
</details>
<details>
<summary><strong>🚨 Known Issues</strong></summary>
<pre>
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>
<summary><strong>🚨 known issues</strong></summary>
<p>💬 no known issues at this time.</p>
</details>
</details>

View file

@ -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

View file

@ -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 = [

View file

@ -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={},
)

View file

@ -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",
}

View file

@ -6,5 +6,5 @@
"dependencies": ["mqtt", "mobile_app"],
"documentation": "https://www.github.com/sudoxnym/saas",
"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}")
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):
"""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 = [
SAASSensor(hass, name, STATE_MAPPING, entry_id),
SAASAlarmEventSensor(hass, name, ALARM_EVENT_MAPPING, entry_id),
SAASNextAlarmSensor(hass, name, entry_id),
SAASSoundSensor(hass, name, SOUND_MAPPING, entry_id),
SAASSleepTrackingSensor(hass, name, SLEEP_TRACKING_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"):
await entity.async_setup()
async_add_entities(entities)
async_add_entities(entities)

View file

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