Compare commits

..

50 commits
Beta ... 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
sudoxnym
e94fe3a72b
BREAKING CHANGES!!!!!
BREAKING CHANGES!!!!!
YOU WILL HAVE TO REMOVE YOUR ORIGINAL ENTRIES AND READD THEM
2025-04-14 18:43:09 -06:00
sudoxnym
f8f8c7927d
Add files via upload 2025-04-14 18:17:05 -06:00
sudoxnym
71d8c6dd2b
Add files via upload
WOO Fixed the ghost error!
2025-04-14 17:42:58 -06:00
sudoxnym
0213d6b688
Add files via upload 2025-04-14 15:21:52 -06:00
sudoxnym
61ab71c61e
Add files via upload 2025-04-14 15:16:55 -06:00
sudoxnym
8929718dc9
Update manifest.json 2025-04-14 13:38:54 -06:00
sudoxnym
1b2611b790
Add files via upload 2025-04-14 13:37:19 -06:00
sudoxnym
dd1cc5ed5e
Update manifest.json 2025-04-14 12:31:31 -06:00
sudoxnym
64cf96e422
Update manifest.json 2025-04-14 12:21:28 -06:00
sudoxnym
ab909041b9
Update manifest.json 2025-04-14 12:11:13 -06:00
sudoxnym
3fa756c7c5
Update manifest.json 2025-04-13 13:45:43 -06:00
sudoxnym
1d64ce87bf
Update README.md 2025-04-13 13:38:44 -06:00
sudoxnym
ed4d2f082a
Add files via upload 2025-04-13 13:24:26 -06:00
sudoxnym
6f03717197
Add files via upload 2025-04-13 13:23:52 -06:00
sudoxnym
a5cac60ef3
Create bug_report.md 2025-04-13 13:22:59 -06:00
sudoxnym
1b50745c55
Add files via upload 2025-04-13 13:17:57 -06:00
sudoxnym
7097c56dc5
Add files via upload 2025-04-13 13:15:19 -06:00
sudoxnym
9321e724c6
Update README.md
added GTR Mini
2025-04-13 12:59:27 -06:00
sudoxnym
aeca762661
Update README.md 2024-09-15 11:10:23 -06:00
sudoxnym
63e4b7766b
Update README.md 2024-09-15 11:09:53 -06:00
sudoxnym
4314466d9f
Update README.md
Updated README.
Added Known Devices list
Changed to (tcp/ssl)://(MQTT User):(MQTT Pass)@(HA URL):(port) as per a merge request, but for some reason when I accepted it didn't change it, so doing it manually.
2024-09-15 11:08:25 -06:00
sudoxnym
8310b8b822
Merge pull request #2 from VilkkuKoo/patch-1
Changed the url under "sleep as android" to the same that is in the app
2024-09-12 08:48:08 -06:00
VilkkuKoo
2b0bbd0213
Changed the url under "sleep as android" to the same that is in the app 2024-09-09 15:26:13 +03:00
sudoxnym
3a88eccaac
Update README.md 2024-08-10 14:45:08 -06:00
sudoxnym
e3850855eb
Update README.md 2024-08-10 14:39:32 -06:00
sudoxnym
bc9de1b864
Update README.md 2024-05-31 18:04:25 -06:00
sudoxnym
25202727d2
Update README.md 2024-05-30 20:48:30 -06:00
sudoxnym
a6b523716c
Update README.md 2024-05-30 20:40:43 -06:00
sudoxnym
d46fb7f144
Update README.md 2024-05-30 20:31:32 -06:00
sudoxnym
289801380f
Update README.md v0.1.0 2024-05-30 19:51:59 -06:00
sudoxnym
5c6b43cc36
Update manifest.json v0.1.0
updated version
2024-05-30 19:31:47 -06:00
sudoxnym
c78453549e
Update sensor.py v0.1.0
v0.1.0
FIXED THE TIMER ON THE WAKE STATUS SENSOR!!
Bug fix on sound sensor.
Updated logic for Sound, Disturbance, and Alarm Event
2024-05-30 19:31:09 -06:00
sudoxnym
fee64e8e5f
Bug Fixes: Update sensor.py
Fixed alarm event sensor bug.
2024-05-30 10:48:04 -06:00
sudoxnym
7fbbcc3fcb
Update README.md for Beta Release 0.0.6a 2024-05-29 16:55:41 -06:00
sudoxnym
50d09aea3a
Update README.md 2024-05-29 16:08:52 -06:00
15 changed files with 1037 additions and 521 deletions

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,32 @@
---
name: 🐛 Bug Report
about: Report a problem with SAAS - Sleep As Android Status
title: "[BUG]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots or Logs**
If applicable, add screenshots or error logs to help explain your problem.
**System Info:**
- Home Assistant Version:
- Integration Version:
- Device (Mi Band, Zepp, etc):
- Android Version:
**Additional context**
Add any other context about the problem here.

View file

@ -0,0 +1,19 @@
---
name: 🚀 Feature Request
about: Suggest a new idea or feature
title: "[FEATURE]"
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem?**
Please describe what you're trying to solve.
**Describe the solution you'd like**
What should be added, improved, or changed?
**Describe alternatives you've considered**
Is there a workaround or a different approach?
**Additional context**
Add any other context or mockups here.

25
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,25 @@
# 🛠️ Pull Request
## Description
Please include a summary of the changes and the related issue.
Fixes #[issue-number]
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Checklist
- [ ] Ive tested these changes locally
- [ ] Ive added appropriate comments and documentation
- [ ] Ive added tests or updated existing ones
- [ ] Ive updated the README if necessary
## Additional Info
Anything else to include?

30
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,30 @@
# Code of Conduct
## Contributor Covenant Code of Conduct
### Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone.
### Our Standards
Examples of behavior that contributes to a positive environment:
- Using welcoming and inclusive language
- Respecting differing viewpoints and experiences
- Accepting constructive criticism gracefully
- Showing empathy toward others
Examples of unacceptable behavior:
- The use of sexualized language or imagery
- Trolling, insulting or derogatory comments
- Personal or political attacks
- Public or private harassment
- Publishing others' private information without consent
### Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported
to the maintainers at sudoxnym@thearkdropship.com.
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.

28
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,28 @@
# Contributing to SAAS - Sleep As Android Status
Thanks for helping improve the project!
## 🧰 Getting Started
1. Fork this repo
2. Clone your fork
3. Create a feature branch (`git checkout -b feature/new-thing`)
4. Make your changes and commit (`git commit -m 'Add cool thing'`)
5. Push to your fork (`git push origin feature/new-thing`)
6. Open a Pull Request on this repo
## 🛠️ Development Tips
- This integration is a custom HACS component written in Python.
- Please test your changes with valid MQTT messages using the same topic format.
- Use clear commit messages and PR descriptions.
## 🐞 Bug Reports
Before opening an issue, check existing issues and include:
- Home Assistant version
- Logs (if possible)
- Steps to reproduce
- Device details
Thanks again for your help!

30
DEPLOY.md Normal file
View file

@ -0,0 +1,30 @@
# 🧪 Deploying SAAS - Sleep As Android Status via HACS
## 🛠️ Pre-requisites
- Home Assistant (2023.x or newer recommended)
- HACS installed (https://hacs.xyz/)
- MQTT broker set up and working
- Android device running Sleep As Android
## 🚀 Installation Steps
1. In HACS, go to "Integrations"
2. Click the 3-dot menu > Custom repositories
3. Paste: `https://github.com/sudoxnym/saas`
4. Set Category to "Integration"
5. Click "Add"
6. Refresh, search for `SAAS - Sleep As Android Status`, and install
7. Restart Home Assistant
8. Go to Settings > Devices & Services > Add Integration > search "SAAS"
9. Configure the integration with your MQTT topic and states
## 🧪 Testing
- Trigger sleep/wake events from Sleep As Android
- Confirm sensor states update in Home Assistant
- Optional: Use HA Companion App to link button services
## 📄 More
See the [README](./README.md) for configuration, supported devices, and example automations.

46
LICENSE
View file

@ -1,33 +1,21 @@
# License for SAAS - Sleep As Android Status Integration
MIT License
## 1. Terms
Copyright (c) 2025 Joseph Ayers
The software, "SAAS - Sleep As Android Status" (hereafter referred to as "the software"), is owned and copyrighted by @sudoxnym. The source code is available on GitHub at [SAAS Repository](https://www.github.com/sudoxnym/saas).
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
## 2. Grant of License
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
This License grants you, free of charge, the non-exclusive right to use the software for personal use only. You are not permitted to redistribute, modify, or use the software for commercial purposes without explicit permission from the copyright owner.
## 3. Restrictions
- **No Redistribution**: You may not distribute copies of the software to third parties.
- **No Modification**: You may not modify, adapt, translate, or create derivative works based on the software without prior written consent from the copyright owner.
- **No Commercial Use**: You may not use the software for commercial purposes without obtaining a license to do so from the copyright owner.
## 4. Disclaimer of Warranty
The software is provided "AS IS", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software.
## 5. Limitation of Liability
In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.
## 6. Termination
This License is effective until terminated. You can terminate it at any time by destroying all copies of the software in your possession. It will also automatically terminate if you fail to comply with any term or condition of this License. Upon termination, you agree to destroy all copies of the software.
## 7. Miscellaneous
This License constitutes the entire agreement between the parties concerning the subject matter hereof. If any provision of this License is found to be unenforceable, that provision shall be enforced to the maximum extent permissible so as to effect the intent of the parties, and the remainder of this License shall continue in full force and effect.
By downloading, installing, or using the software, you agree to be bound by the terms of this License. If you do not agree to the terms of this License, do not download, install, or use the software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

262
README.md
View file

@ -1,72 +1,228 @@
[![Add to HACS](https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge&logo=home%20assistant&labelColor=202020&color=41BDF5)](https://hacs.xyz/docs/faq/custom_repositories)<br>
<h1>SAAS - Sleep As Android Status</h1>
<h2>Description:</h2></br>
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.</br></br>
<h3>This integration works best with a Xioami MiBand (7 or older) mixed with the Notify app and Sleep As Android configured.</h3>
<p align="center">
<img src="https://img.shields.io/badge/⚠_DEPRECATED-red?style=for-the-badge" alt="deprecated">
</p>
<h3>This integration will create 8 Sensors, 8 Buttons, 1 service, and 1 device per user:</h3></br>
# ⚠️ THIS PROJECT HAS BEEN DEPRECATED
<h3>Sensors</h3>
Message Received *State</br>
Wake Status</br>
Sound</br>
Disturbance</br>
Alarm</br>
Lullaby</br>
Sleep Tracking</br>
Sleep Statge</br>
**this project has been rebranded and moved to [sleepd](https://github.com/sudoxnym/sleepd)**
This should intelligently and dynamically allow for state changes in the Wake Status Sensor.</br></br>
please use the new repository for future updates and support.
<h3>Buttons</h3>
Alarm Dismiss</br>
Alarm Snooze</br>
Lullaby Stop</br>
Sleep Tracking Pause</br>
Sleep Tracking Resum</br>
Sleep Tracking Start</br>
Sleep Tracking Start with Optimal Alarm</br>
Sleep Tracking Stop</br>
[![go to sleepd](https://img.shields.io/badge/GO_TO_SLEEPD-blue?style=for-the-badge&logo=github)](https://github.com/sudoxnym/sleepd)
<h3>Service</h3>
Set alarm service</br>
<pre>
---
<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">
</a>
</p>
<h1>🌙 saas - Sleep As Android status</h1>
<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.
</p>
<h3>🧱 this integration will create:</h3>
<ul>
<li>
<details>
<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 *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>
</ul>
</details>
</li>
<li>
<details>
<summary><strong>🛠️ 1 service</strong></summary>
<pre>
service: saas.saas_example_alarm_set
data:
message: Example Message!
day: monday
hour: 7
minute: 30
</pre>
</details>
</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>
</details>
</li>
</ul>
<details>
<summary><strong>✅ known working</strong></summary>
<ul>
<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>
<ul>
<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">
</a>
</li>
<li>add integration: **SAAS - Sleep As Android status**</li>
</ul>
<h2>⚙️ configuration:</h2>
<ul>
<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 **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>
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>
<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>
</details>
<details>
<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>
<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>
<ol>
<li>open the app and follow setup</li>
<li>settings wheel > services > automation > MQTT</li>
</ol>
<pre>
(tcp/ssl)://(MQTT User):(MQTT Pass)@(HA URL):(port)
</pre>
<ul>
<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>
</ol>
<details>
<summary><strong>📦 changes</strong></summary>
<b>0.2.2</b>
<ul>
<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>
</ul>
<h2>Installation:</h2>
<b>0.0.6a</b>
<ul>
<li>initial beta release</li>
<li>added persistent states</li>
<li>alarm event sensor attributes</li>
</ul>
</details>
&nbsp;&nbsp;Add https://www.github.com/sudoxnym/saas to your Custom Repositories in HACS</br>
&nbsp;&nbsp;Search and Download SAAS - Sleep As Android Status</br>
&nbsp;&nbsp;Restart Home Assistant</br>
&nbsp;&nbsp;[![Add To My Home Assistant](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=saas)<br>
&nbsp;&nbsp;Add Integration: SAAS - Sleep As Android Status</br></br>
<h2>Configuration:</h3>
&nbsp;&nbsp;Name: Name of user</br>
&nbsp;&nbsp;Topic: MQTT Topic from Sleep As Android</br>
&nbsp;&nbsp;QoS: Quality of Service</br></br>
&nbsp;&nbsp;Awake Duration: This is for tuning. Time in seconds in which awake states = true to indicate awake. Sensor usually updates within 30 seconds or so after the duration, not entirely sure why the delay.</br>
&nbsp;&nbsp;Asleep Duration: This is for tuning. Time in seconds in which sleep states = true to indicate asleep Sensor usually updates within 30 seconds or so after the duration, not entirely sure why the delay.</br>
&nbsp;&nbsp;Awake States: States to indicate being awake</br>
&nbsp;&nbsp;Asleep States: States to indicate being asleep</br>
&nbsp;&nbsp;Mobile App: Target for buttons </br></br>
Please report any issues.</br>
This is my first integration.
Built this in less than a week, with no prior knowledge of Python.
<details>
<summary><strong>🚨 known issues</strong></summary>
<p>💬 no known issues at this time.</p>
</details>
</details>

View file

@ -1,64 +1,57 @@
import asyncio
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from homeassistant.helpers.dispatcher import async_dispatcher_connect
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):
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):
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 setup to sensor and button platforms
await hass.config_entries.async_forward_entry_setups(entry, ["sensor", "button"])
# Forward the setup to the sensor and button platforms
for platform in ["sensor", "button"]:
_logger.info(f"Forwarding setup to {platform} platform")
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, platform)
)
_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]}") # New log
async def reload_entry():
_logger.info("Reloading entry")
await hass.config_entries.async_reload(entry.entry_id)
async_dispatcher_connect(hass, f"{DOMAIN}_reload_{entry.entry_id}", reload_entry)
_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):
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}")
# Remove the sensor platform
_logger.info("Removing sensor platform")
await hass.config_entries.async_forward_entry_unload(entry, "sensor")
# 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")
return False
# Ensure hass.data[DOMAIN] is a dictionary before popping
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")
return True
_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

@ -1,139 +1,184 @@
import logging
import traceback
import voluptuous as vol
from .const import DOMAIN, CONF_NAME, CONF_TOPIC, CONF_QOS, AVAILABLE_STATES, CONF_AWAKE_DURATION, CONF_SLEEP_DURATION, CONF_AWAKE_STATES, CONF_SLEEP_STATES, DEFAULT_AWAKE_DURATION, DEFAULT_SLEEP_DURATION, DEFAULT_AWAKE_STATES, DEFAULT_SLEEP_STATES, CONF_NOTIFY_TARGET
from .const import (
DOMAIN,
CONF_NAME,
CONF_TOPIC,
AVAILABLE_STATES,
CONF_AWAKE_DURATION,
CONF_SLEEP_DURATION,
CONF_AWAKE_STATES,
CONF_SLEEP_STATES,
DEFAULT_AWAKE_DURATION,
DEFAULT_SLEEP_DURATION,
DEFAULT_AWAKE_STATES,
DEFAULT_SLEEP_STATES,
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
from homeassistant.helpers.dispatcher import async_dispatcher_send
_logger = logging.getLogger(__name__)
_LOGGER = logging.getLogger(__name__)
class MyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
"""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 a flow initialized by the user."""
"""Initial setup step."""
errors = {}
# Get the list of notify targets
notify_services = self.hass.services.async_services().get('notify', {})
notify_targets = {target.replace('mobile_app_', '').title(): target for target in notify_services.keys() if target.startswith('mobile_app_')}
# discover available mobile_app notify services
notify_services = self.hass.services.async_services().get("notify", {})
notify_targets = {
svc.replace("mobile_app_", "")
.replace("_", " ")
.lower(): svc
for svc in notify_services
if svc.startswith("mobile_app_")
}
if user_input is not None:
# Map the selected option back to the actual notify target name
user_input[CONF_NOTIFY_TARGET] = notify_targets[user_input[CONF_NOTIFY_TARGET]]
# Validate the user input here
# If the input is valid, create an entry
# If the input is not valid, add an error message to the 'errors' dictionary
# For example:
if not user_input[CONF_NAME]:
# 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,
)
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
@config_entries.callback
def async_get_options_flow(entry):
"""Return options flow handler."""
return OptionsFlowHandler(entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options."""
def __init__(self, config_entry):
"""Initialize options flow."""
self.config_entry = config_entry
"""Handle SAAS options editing."""
def __init__(self, entry):
super().__init__()
self._config_entry = entry
async def async_step_init(self, user_input=None):
"""Manage the options."""
_logger.debug("Entering async_step_init with user_input: %s", user_input)
errors = {} # Define errors here
try:
# Fetch the initial configuration data
current_data = self.hass.data[DOMAIN].get(self.config_entry.entry_id, self.config_entry.options)
_logger.debug("Current data fetched: %s", current_data)
# Get the list of notify targets
notify_services = self.hass.services.async_services().get('notify', {})
notify_targets = {target.replace('mobile_app_', '').title(): target for target in notify_services.keys() if target.startswith('mobile_app_')}
if user_input is not None:
# Validate the user input here
# If the input is valid, create an entry
# If the input is not valid, add an error message to the 'errors' dictionary
# For example:
if not user_input[CONF_NAME]:
errors[CONF_NAME] = "required"
if errors:
return self.async_show_form(step_id="init", data_schema=self.get_data_schema(current_data), errors=errors) # Pass errors to async_show_form
# Map the selected option back to the actual notify target name
user_input[CONF_NOTIFY_TARGET] = notify_targets[user_input[CONF_NOTIFY_TARGET]]
# Merge current_data with user_input
updated_data = {**current_data, **user_input}
_logger.debug("User input is not None, updated data: %s", updated_data)
_logger.debug("Updating entry with updated data: %s", updated_data)
if updated_data is not None:
self.hass.data[DOMAIN][self.config_entry.entry_id] = updated_data # Save updated data
# Update the entry data
self.hass.config_entries.async_update_entry(self.config_entry, data=updated_data)
# Send a signal to reload the integration
async_dispatcher_send(self.hass, f"{DOMAIN}_reload_{self.config_entry.entry_id}")
return self.async_create_entry(title="", data=updated_data)
_logger.debug("User input is None, showing form with current_data: %s", current_data)
return self.async_show_form(step_id="init", data_schema=self.get_data_schema(current_data), errors=errors) # Pass errors to async_show_form
except Exception as e:
_logger.error("Error in async_step_init: %s", str(e))
return self.async_abort(reason=str(e))
"""Manage the options form (edit)."""
current = dict(self._config_entry.data)
def get_data_schema(self, current_data):
# Get the list of notify targets
notify_services = self.hass.services.async_services().get('notify', {})
notify_targets = {target.replace('mobile_app_', '').title(): target for target in notify_services.keys() if target.startswith('mobile_app_')}
# Extract the part after 'mobile_app_' and capitalize
notify_target = current_data.get(CONF_NOTIFY_TARGET, "")
notify_target = notify_target.replace('mobile_app_', '').title() if notify_target.startswith('mobile_app_') else notify_target
return Schema(
{
Required(CONF_NAME, default=current_data.get(CONF_NAME, "")): str,
Required(CONF_TOPIC, default=current_data.get(CONF_TOPIC, "")): str,
Required(CONF_QOS, default=current_data.get(CONF_QOS, 0)): In([0, 1, 2]),
Required(CONF_AWAKE_DURATION, default=current_data.get(CONF_AWAKE_DURATION, DEFAULT_AWAKE_DURATION)): int,
Required(CONF_SLEEP_DURATION, default=current_data.get(CONF_SLEEP_DURATION, DEFAULT_SLEEP_DURATION)): int,
Required(CONF_AWAKE_STATES, default=current_data.get(CONF_AWAKE_STATES, DEFAULT_AWAKE_STATES)): cv.multi_select(AVAILABLE_STATES),
Required(CONF_SLEEP_STATES, default=current_data.get(CONF_SLEEP_STATES, DEFAULT_SLEEP_STATES)): cv.multi_select(AVAILABLE_STATES),
Optional(CONF_NOTIFY_TARGET, default=notify_target): vol.In(list(notify_targets.keys())),
}
)
# discover mobile_app notify services again
notify_services = self.hass.services.async_services().get("notify", {})
notify_targets = {
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:
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=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.0.6"
"version": "0.2.1"
}

View file

@ -10,13 +10,9 @@ from homeassistant.util import dt as dt_util
from homeassistant.components import mqtt
from .const import DOMAIN, CONF_NAME, CONF_TOPIC, CONF_AWAKE_STATES, CONF_SLEEP_STATES, CONF_AWAKE_DURATION, CONF_SLEEP_DURATION, INTEGRATION_NAME, MODEL, STATE_MAPPING, SOUND_MAPPING, DISTURBANCE_MAPPING, ALARM_EVENT_MAPPING, SLEEP_TRACKING_MAPPING, LULLABY_MAPPING, REVERSE_STATE_MAPPING, SLEEP_STAGE_MAPPING
_LOGGER = logging.getLogger(__name__)
_LOGGER.setLevel(logging.DEBUG)
import inspect
from datetime import datetime
class SAASSensor(RestoreEntity):
"""Representation of a SAAS - Sleep As Android Stats sensor."""
@ -105,8 +101,8 @@ class SAASAlarmEventSensor(RestoreEntity):
self._value2 = None
self._time = None
self.entry_id = entry_id
self._reset_timer = None
self._last_event = None
self._timeout_task = None
@property
def unique_id(self):
@ -133,12 +129,256 @@ class SAASAlarmEventSensor(RestoreEntity):
"model": MODEL,
}
@property
def extra_state_attributes(self):
"""Return the extra state attributes."""
if self._state is not None:
return {
"Last Event": self._last_event,
"Message": self._value2 if self._value2 else "No message received",
"Timestamp": self._value1 if self._value1 else "No timestamp received",
"Time": self._time.strftime('%H:%M') if self._time else "No time received",
"Date": self._time.strftime('%m/%d/%Y') if self._time else "No date received",
}
return {}
async def async_added_to_hass(self):
"""Run when entity about to be added."""
await super().async_added_to_hass()
# Load the previous state from the state machine
state = await self.async_get_last_state()
if state:
self._state = state.state
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Loaded state: {self._state} for sensor {self.name}")
# Start the timeout task as soon as the sensor is loaded
self._timeout_task = asyncio.create_task(self.timeout())
async def message_received(msg):
"""Handle new MQTT messages."""
# Cancel the previous timeout task if it exists
if self._timeout_task:
self._timeout_task.cancel()
# Parse the incoming message
msg_json = json.loads(msg.payload)
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Received MQTT message: {msg_json} for sensor {self.name}")
# Extract the 'value1' and 'value2' fields
value1 = msg_json.get('value1')
# Parse 'value1' as a datetime
if value1:
timestamp = int(value1) / 1000.0
self._time = datetime.fromtimestamp(timestamp)
self._value1 = value1
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Parsed 'value1' as datetime: {self._time} for sensor {self.name}")
# Extract the 'value2' field
value2 = msg_json.get('value2')
# Store 'value2' as the message if it exists
self._value2 = value2 if value2 else "None"
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Stored 'value2' as message: {self._value2} for sensor {self.name}")
# Extract the EVENT field
event = msg_json.get('event')
if event is None:
_LOGGER.warning(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): No 'event' key in the MQTT message: {msg_json} for sensor {self.name}")
return
# Use the mapping to convert the event to the corresponding state
new_state = self._mapping.get(event) # Default to "None" if no mapping found
if new_state is not None:
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped {event} to {new_state} for sensor {self.name}")
self._state = new_state
self._last_event = new_state # Update the last event
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to {new_state} for sensor {self.name}")
self.async_schedule_update_ha_state()
# Create a new timeout task
self._timeout_task = asyncio.create_task(self.timeout())
# Subscribe to the topic from the user input
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."""
# Save the current state to the state machine
self._hass.states.async_set(self.entity_id, self._state)
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Saved state: {self._state} for sensor {self.name}")
async def timeout(self):
"""Set the state to 'None' after a timeout."""
await asyncio.sleep(15)
self._state = "None"
self._last_event = "None"
_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."""
def __init__(self, hass, name, mapping, entry_id):
"""Initialize the sensor."""
self._state = None
self._name = name
self._hass = hass
self._mapping = mapping
self._value1 = None
self._value2 = None
self._time = None
self.entry_id = entry_id
self._last_event = None
self._timeout_task = None
@property
def unique_id(self):
"""Return a unique ID."""
return f"saas_sound_sensor_{self._name}"
@property
def name(self):
"""Return the name of the sensor."""
return f"SAAS {self._name} Sound"
@property
def state(self):
"""Return the state of the sensor."""
return self._state
@property
def device_info(self):
"""Return information about the device."""
return {
"identifiers": {(DOMAIN, self._name)},
"name": self._name,
"manufacturer": INTEGRATION_NAME,
"model": MODEL,
}
@property
def extra_state_attributes(self):
"""Return the extra state attributes."""
return {
"Last Event": self._last_event,
"Message": self._value2 if self._value2 else "No message received",
"Timestamp": self._value1 if self._value1 else "No timestamp received",
"Time": self._time.strftime('%H:%M') if self._time else "No time received",
"Date": self._time.strftime('%m/%d/%Y') if self._time else "No date received",
@ -154,211 +394,56 @@ class SAASAlarmEventSensor(RestoreEntity):
self._state = state.state
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Loaded state: {self._state} for sensor {self.name}")
async def reset_state(_):
"""Reset the state to None after a delay."""
self._state = 'None'
self.async_write_ha_state()
# Start the timeout task as soon as the sensor is loaded
self._timeout_task = asyncio.create_task(self.timeout())
async def message_received(msg):
"""Handle new MQTT messages."""
# Cancel the previous state reset timer
if self._reset_timer:
self._reset_timer.cancel()
# Schedule a state reset after 15 seconds
self._reset_timer = async_call_later(self._hass, 15, reset_state)
# Cancel the previous timeout task if it exists
if self._timeout_task:
self._timeout_task.cancel()
# Parse the incoming message
msg_json = json.loads(msg.payload)
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Received MQTT message: {msg_json} for sensor {self.name}")
# Extract the 'value1' and 'value2' fields
value1 = msg_json.get('value1')
# Parse 'value1' as a datetime
if value1:
timestamp = int(value1) / 1000.0
self._time = datetime.fromtimestamp(timestamp)
self._value1 = value1
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Parsed 'value1' as datetime: {self._time} for sensor {self.name}")
# Extract the 'value2' field
value2 = msg_json.get('value2')
# Store 'value2' as the message if it exists
self._value2 = value2 if value2 else "None"
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Stored 'value2' as message: {self._value2} for sensor {self.name}")
# Extract the EVENT field
event = msg_json.get('event')
if event is None:
_LOGGER.warning(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): No 'event' key in the MQTT message: {msg_json} for sensor {self.name}")
return
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Extracted Event {event} from message for sensor {self.name}")
# Use the mapping to convert the event to the corresponding state
new_state = self._mapping.get(event)
new_state = self._mapping.get(event)
if new_state is not None:
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped {event} to {new_state} for sensor {self.name}")
self._state = new_state
self._last_event = new_state # Update the last event
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to {new_state} for sensor {self.name}")
self.async_write_ha_state()
# Subscribe to the topic from the user input
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."""
# Save the current state to the state machine
self._hass.states.async_set(self.entity_id, self._state)
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Saved state: {self._state} for sensor {self.name}")
# Extract the 'value1' and 'value2' fields
value1 = msg_json.get('value1')
class SAASSoundSensor(RestoreEntity):
"""Representation of a SAAS - Sleep As Android Stats sensor for Sound Events."""
def __init__(self, hass, name, mapping, entry_id):
"""Initialize the sensor."""
self._state = None
self._name = name
self._hass = hass
self._mapping = mapping
self.entry_id = entry_id
# Parse 'value1' as a datetime
if value1:
timestamp = int(value1) / 1000.0
self._time = datetime.fromtimestamp(timestamp)
self._value1 = value1
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Parsed 'value1' as datetime: {self._time} for sensor {self.name}")
@property
def unique_id(self):
"""Return a unique ID."""
return f"saas_sound_sensor_{self._name}"
# Extract the 'value2' field
value2 = msg_json.get('value2')
@property
def name(self):
"""Return the name of the sensor."""
return f"SAAS {self._name} Sound"
# Store 'value2' as the message if it exists
self._value2 = value2 if value2 else "None"
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Stored 'value2' as message: {self._value2} for sensor {self.name}")
@property
def state(self):
"""Return the state of the sensor."""
return self._state
@property
def device_info(self):
"""Return information about the device."""
return {
"identifiers": {(DOMAIN, self._name)},
"name": self._name,
"manufacturer": INTEGRATION_NAME,
"model": MODEL,
}
async def async_added_to_hass(self):
"""Run when entity about to be added."""
await super().async_added_to_hass()
# Load the previous state from the state machine
state = await self.async_get_last_state()
if state:
self._state = state.state
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Loaded state: {self._state} for sensor {self.name}")
async def message_received(msg):
"""Handle new MQTT messages."""
# Parse the incoming message
msg_json = json.loads(msg.payload)
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Received MQTT message: {msg_json} for sensor {self.name}")
# Extract the EVENT field
event = msg_json.get('event')
if event is None:
_LOGGER.warning(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): No 'event' key in the MQTT message: {msg_json} for sensor {self.name}")
return
# Use the mapping to convert the event to the corresponding state
new_state = self._mapping.get(event, "None") # Default to "None" if no mapping found
if new_state is not None:
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped {event} to {new_state} for sensor {self.name}")
self._state = new_state
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to {new_state} for sensor {self.name}")
self.async_schedule_update_ha_state()
# Subscribe to the topic from the user input
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."""
# Save the current state to the state machine
self._hass.states.async_set(self.entity_id, self._state)
_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."""
def __init__(self, hass, name, mapping, entry_id):
"""Initialize the sensor."""
self._state = None
self._name = name
self._hass = hass
self._mapping = mapping
self.entry_id = entry_id
@property
def unique_id(self):
"""Return a unique ID."""
return f"saas_sound_sensor_{self._name}"
@property
def name(self):
"""Return the name of the sensor."""
return f"SAAS {self._name} Sound"
@property
def state(self):
"""Return the state of the sensor."""
return self._state
@property
def device_info(self):
"""Return information about the device."""
return {
"identifiers": {(DOMAIN, self._name)},
"name": self._name,
"manufacturer": INTEGRATION_NAME,
"model": MODEL,
}
async def async_added_to_hass(self):
"""Run when entity about to be added."""
await super().async_added_to_hass()
# Load the previous state from the state machine
state = await self.async_get_last_state()
if state:
self._state = state.state
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Loaded state: {self._state} for sensor {self.name}")
async def message_received(msg):
"""Handle new MQTT messages."""
# Parse the incoming message
msg_json = json.loads(msg.payload)
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Received MQTT message: {msg_json} for sensor {self.name}")
# Extract the EVENT field
event = msg_json.get('event')
if event is None:
_LOGGER.warning(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): No 'event' key in the MQTT message: {msg_json} for sensor {self.name}")
return
# Use the mapping to convert the event to the corresponding state
new_state = self._mapping.get(event, "None") # Default to "None" if no mapping found
if new_state is not None:
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped {event} to {new_state} for sensor {self.name}")
self._state = new_state
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to {new_state} for sensor {self.name}")
self.async_schedule_update_ha_state()
# Create a new timeout task
self._timeout_task = asyncio.create_task(self.timeout())
# Subscribe to the topic from the user input
await async_subscribe(self._hass, self._hass.data[DOMAIN][self.entry_id][CONF_TOPIC], message_received)
@ -369,6 +454,14 @@ class SAASSoundSensor(RestoreEntity):
self._hass.states.async_set(self.entity_id, self._state)
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Saved state: {self._state} for sensor {self.name}")
async def timeout(self):
"""Set the state to 'None' after a timeout."""
await asyncio.sleep(15)
self._state = "None"
self._last_event = "None"
_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 SAASSleepTrackingSensor(RestoreEntity):
"""Representation of a SAAS - Sleep As Android Stats sensor for Sleep Tracking."""
def __init__(self, hass, name, mapping, entry_id):
@ -446,19 +539,25 @@ class SAASSleepTrackingSensor(RestoreEntity):
class SAASDisturbanceSensor(RestoreEntity):
"""Representation of a SAAS - Sleep As Android Stats sensor for Disturbance Events."""
def __init__(self, hass, name, mapping, entry_id):
"""Initialize the sensor."""
self._state = None
self._name = name
self._hass = hass
self._mapping = mapping
self._value1 = None
self._value2 = None
self._time = None
self.entry_id = entry_id
self._last_event = None
self._timeout_task = None
@property
def unique_id(self):
"""Return a unique ID."""
return f"saas_disturbance_sensor_{self._name}"
@property
def name(self):
"""Return the name of the sensor."""
@ -468,7 +567,7 @@ class SAASDisturbanceSensor(RestoreEntity):
def state(self):
"""Return the state of the sensor."""
return self._state
@property
def device_info(self):
"""Return information about the device."""
@ -478,6 +577,16 @@ class SAASDisturbanceSensor(RestoreEntity):
"manufacturer": INTEGRATION_NAME,
"model": MODEL,
}
@property
def extra_state_attributes(self):
"""Return the extra state attributes."""
return {
"Last Event": self._last_event,
"Timestamp": self._value1 if self._value1 else "No timestamp received",
"Time": self._time.strftime('%H:%M') if self._time else "No time received",
"Date": self._time.strftime('%m/%d/%Y') if self._time else "No date received",
}
async def async_added_to_hass(self):
"""Run when entity about to be added."""
@ -489,8 +598,15 @@ class SAASDisturbanceSensor(RestoreEntity):
self._state = state.state
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Loaded state: {self._state} for sensor {self.name}")
# Start the timeout task as soon as the sensor is loaded
self._timeout_task = asyncio.create_task(self.timeout())
async def message_received(msg):
"""Handle new MQTT messages."""
# Cancel the previous timeout task if it exists
if self._timeout_task:
self._timeout_task.cancel()
# Parse the incoming message
msg_json = json.loads(msg.payload)
@ -504,10 +620,34 @@ class SAASDisturbanceSensor(RestoreEntity):
return
# Use the mapping to convert the event to the corresponding state
new_state = self._mapping.get(event, "None") # Default to "None" if no mapping found
self._state = new_state
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to {new_state} for sensor {self.name}")
self.async_schedule_update_ha_state()
new_state = self._mapping.get(event)
if new_state is not None:
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped {event} to {new_state} for sensor {self.name}")
self._state = new_state
self._last_event = new_state # Update the last event
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to {new_state} for sensor {self.name}")
# Extract the 'value1' and 'value2' fields
value1 = msg_json.get('value1')
# Parse 'value1' as a datetime
if value1:
timestamp = int(value1) / 1000.0
self._time = datetime.fromtimestamp(timestamp)
self._value1 = value1
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Parsed 'value1' as datetime: {self._time} for sensor {self.name}")
# Extract the 'value2' field
value2 = msg_json.get('value2')
# Store 'value2' as the message if it exists
self._value2 = value2 if value2 else "None"
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Stored 'value2' as message: {self._value2} for sensor {self.name}")
self.async_schedule_update_ha_state()
# Create a new timeout task
self._timeout_task = asyncio.create_task(self.timeout())
# Subscribe to the topic from the user input
await async_subscribe(self._hass, self._hass.data[DOMAIN][self.entry_id][CONF_TOPIC], message_received)
@ -518,6 +658,14 @@ class SAASDisturbanceSensor(RestoreEntity):
self._hass.states.async_set(self.entity_id, self._state)
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Saved state: {self._state} for sensor {self.name}")
async def timeout(self):
"""Set the state to 'None' after a timeout."""
await asyncio.sleep(15)
self._state = "None"
self._last_event = "None"
_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 SAASLullabySensor(RestoreEntity):
"""Representation of a SAAS - Sleep As Android Stats sensor for Lullaby."""
def __init__(self, hass, name, mapping, entry_id):
@ -670,8 +818,6 @@ class SAASWakeStatusSensor(RestoreEntity):
def __init__(self, hass, name, awake_states, sleep_states, awake_duration, sleep_duration, mqtt_topic, entry_id):
self._state = None
self._name = name
self.awake_bucket = []
self.sleep_bucket = []
self.awake_duration = timedelta(seconds=awake_duration)
self.sleep_duration = timedelta(seconds=sleep_duration)
self.awake_states = awake_states
@ -679,6 +825,8 @@ class SAASWakeStatusSensor(RestoreEntity):
self.hass = hass
self.mqtt_topic = mqtt_topic
self.entry_id = entry_id
self.awake_timer = None
self.sleep_timer = None
@property
def unique_id(self):
@ -708,93 +856,57 @@ class SAASWakeStatusSensor(RestoreEntity):
def process_message(self, message):
try:
now = dt_util.utcnow() # Define 'now' before using it for logging
# Extract the 'event' from the incoming message
event = message.get('event')
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Extracted Event {event} from message.")
# Map the event to a known state, or "Unknown" if the event is not recognized
mapped_value = STATE_MAPPING.get(event, "Unknown")
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped {event} to {mapped_value}.")
# If the event could not be mapped to a known state, set the sensor state to "Unknown"
if mapped_value == "Unknown":
self._state = "Unknown"
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Event {event} could not be mapped to a known state. Setting sensor state to 'Unknown'.")
# If the mapped value is in the awake states, add it to the awake bucket and clear the sleep bucket
# If the mapped value is in the awake states, start or restart the awake timer and cancel the sleep timer
if mapped_value in self.awake_states:
self.awake_bucket.append((mapped_value, now))
self.sleep_bucket = []
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped value {mapped_value} is in awake states. Adding to awake bucket and clearing sleep bucket.")
# If the mapped value is in the sleep states, add it to the sleep bucket and clear the awake bucket
if self.awake_timer:
self.awake_timer.cancel()
self.awake_timer = self.hass.loop.call_later(self.awake_duration.total_seconds(), self.set_state, "Awake")
if self.sleep_timer:
self.sleep_timer.cancel()
self.sleep_timer = None
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped value {mapped_value} is in awake states. Starting or restarting awake timer and cancelling sleep timer.")
# If the mapped value is in the sleep states, start or restart the sleep timer and cancel the awake timer
elif mapped_value in self.sleep_states:
self.sleep_bucket.append((mapped_value, now))
self.awake_bucket = []
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped value {mapped_value} is in sleep states. Adding to sleep bucket and clearing awake bucket.")
if self.sleep_timer:
self.sleep_timer.cancel()
self.sleep_timer = self.hass.loop.call_later(self.sleep_duration.total_seconds(), self.set_state, "Asleep")
if self.awake_timer:
self.awake_timer.cancel()
self.awake_timer = None
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped value {mapped_value} is in sleep states. Starting or restarting sleep timer and cancelling awake timer.")
except Exception as e:
_LOGGER.error(f"Error processing message: {e}")
async def async_update(self, _=None):
"""Update the state."""
now = dt_util.utcnow()
# If any message in the awake bucket has reached the awake duration, set the state to "Awake"
if self.awake_bucket and any(now - timestamp >= self.awake_duration for _, timestamp in self.awake_bucket):
if self._state != "Awake":
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): State changed to 'Awake'")
self._state = "Awake"
# If any message in the sleep bucket has reached the sleep duration, set the state to "Asleep"
elif self.sleep_bucket and any(now - timestamp >= self.sleep_duration for _, timestamp in self.sleep_bucket):
if self._state != "Asleep":
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): State changed to 'Asleep'")
self._state = "Asleep"
# Remove messages from the awake bucket that are older than the awake duration and log if a message is removed
self.awake_bucket = [(val, timestamp) for val, timestamp in self.awake_bucket if now - timestamp < self.awake_duration]
for val, timestamp in self.awake_bucket:
if now - timestamp >= self.awake_duration:
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Removed message from awake bucket.")
# Remove messages from the sleep bucket that are older than the sleep duration and log if a message is removed
self.sleep_bucket = [(val, timestamp) for val, timestamp in self.sleep_bucket if now - timestamp < self.sleep_duration]
for val, timestamp in self.sleep_bucket:
if now - timestamp >= self.sleep_duration:
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Removed message from sleep bucket.")
# Log the contents of the awake bucket if it is not empty
if self.awake_bucket:
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Awake bucket: {self.awake_bucket}")
# Log the contents of the sleep bucket if it is not empty
if self.sleep_bucket:
_LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Sleep bucket: {self.sleep_bucket}")
async def interval_callback(self, _):
"""Wrapper function for async_track_time_interval."""
# Call the async_update method to update the state
await self.async_update()
def set_state(self, state):
self._state = state
self.async_schedule_update_ha_state()
async def async_added_to_hass(self):
"""Run when entity about to be added."""
await super().async_added_to_hass()
# Load the previous state from the state machine
state = await self.async_get_last_state()
if state:
self._state = state.state
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Loaded state: {self._state} for sensor {self.name}")
# Schedule the interval callback to run every second
async_track_time_interval(
self.hass,
self.interval_callback,
timedelta(seconds=1)
)
# Subscribe to the MQTT topic to receive messages
await mqtt.async_subscribe(
self.hass,
@ -807,7 +919,7 @@ class SAASWakeStatusSensor(RestoreEntity):
# Save the current state to the state machine
self.hass.states.async_set(self.entity_id, self._state)
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Saved state: {self._state} for sensor {self.name}")
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up the SAAS sensor platform from a config entry."""
name = entry.data.get(CONF_NAME, "Default Name")
@ -822,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),

View file

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