mirror of
https://github.com/sudoxnym/saas.git
synced 2026-05-23 14:17:00 +00:00
Compare commits
50 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e35f8f20 | ||
|
|
0cbe7ba589 | ||
|
|
e4b0fa0375 | ||
|
|
45767967a4 | ||
|
|
7c359a4b6e | ||
|
|
23f5d5dd18 | ||
|
|
5f3a7e78f5 | ||
|
|
359dd530fe | ||
|
|
6de713d0c6 | ||
|
|
6dc8fa9afa | ||
|
|
f5161159d7 | ||
|
|
ccbdd154f2 | ||
|
|
3f7c73ab46 | ||
|
|
3a41318279 | ||
|
|
1f52692d07 | ||
|
|
e94fe3a72b | ||
|
|
f8f8c7927d | ||
|
|
71d8c6dd2b | ||
|
|
0213d6b688 | ||
|
|
61ab71c61e | ||
|
|
8929718dc9 | ||
|
|
1b2611b790 | ||
|
|
dd1cc5ed5e | ||
|
|
64cf96e422 | ||
|
|
ab909041b9 | ||
|
|
3fa756c7c5 | ||
|
|
1d64ce87bf | ||
|
|
ed4d2f082a | ||
|
|
6f03717197 | ||
|
|
a5cac60ef3 | ||
|
|
1b50745c55 | ||
|
|
7097c56dc5 | ||
|
|
9321e724c6 | ||
|
|
aeca762661 | ||
|
|
63e4b7766b | ||
|
|
4314466d9f | ||
|
|
8310b8b822 | ||
|
|
2b0bbd0213 | ||
|
|
3a88eccaac | ||
|
|
e3850855eb | ||
|
|
bc9de1b864 | ||
|
|
25202727d2 | ||
|
|
a6b523716c | ||
|
|
d46fb7f144 | ||
|
|
289801380f | ||
|
|
5c6b43cc36 | ||
|
|
c78453549e | ||
|
|
fee64e8e5f | ||
|
|
7fbbcc3fcb | ||
|
|
50d09aea3a |
15 changed files with 1037 additions and 521 deletions
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
25
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
|
||||||
|
|
||||||
|
- [ ] I’ve tested these changes locally
|
||||||
|
- [ ] I’ve added appropriate comments and documentation
|
||||||
|
- [ ] I’ve added tests or updated existing ones
|
||||||
|
- [ ] I’ve updated the README if necessary
|
||||||
|
|
||||||
|
## Additional Info
|
||||||
|
Anything else to include?
|
||||||
30
CODE_OF_CONDUCT.md
Normal file
30
CODE_OF_CONDUCT.md
Normal 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
28
CONTRIBUTING.md
Normal 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
30
DEPLOY.md
Normal 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
46
LICENSE
|
|
@ -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.
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
## 3. Restrictions
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
- **No Redistribution**: You may not distribute copies of the software to third parties.
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
- **No Modification**: You may not modify, adapt, translate, or create derivative works based on the software without prior written consent from the copyright owner.
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
- **No Commercial Use**: You may not use the software for commercial purposes without obtaining a license to do so from the copyright owner.
|
SOFTWARE.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
|
||||||
262
README.md
262
README.md
|
|
@ -1,72 +1,228 @@
|
||||||
[](https://hacs.xyz/docs/faq/custom_repositories)<br>
|
<p align="center">
|
||||||
<h1>SAAS - Sleep As Android Status</h1>
|
<img src="https://img.shields.io/badge/⚠️_DEPRECATED-red?style=for-the-badge" alt="deprecated">
|
||||||
<h2>Description:</h2></br>
|
</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.</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>
|
|
||||||
|
|
||||||
<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>
|
**this project has been rebranded and moved to [sleepd](https://github.com/sudoxnym/sleepd)**
|
||||||
Message Received *State</br>
|
|
||||||
Wake Status</br>
|
|
||||||
Sound</br>
|
|
||||||
Disturbance</br>
|
|
||||||
Alarm</br>
|
|
||||||
Lullaby</br>
|
|
||||||
Sleep Tracking</br>
|
|
||||||
Sleep Statge</br>
|
|
||||||
|
|
||||||
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>
|
[](https://github.com/sudoxnym/sleepd)
|
||||||
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>
|
|
||||||
|
|
||||||
<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
|
service: saas.saas_example_alarm_set
|
||||||
data:
|
data:
|
||||||
message: Example Message!
|
message: Example Message!
|
||||||
day: monday
|
day: monday
|
||||||
hour: 7
|
hour: 7
|
||||||
minute: 30
|
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>
|
</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>
|
||||||
|
|
||||||
|
<details>
|
||||||
Add https://www.github.com/sudoxnym/saas to your Custom Repositories in HACS</br>
|
<summary><strong>🚨 known issues</strong></summary>
|
||||||
|
<p>💬 no known issues at this time.</p>
|
||||||
Search and Download SAAS - Sleep As Android Status</br>
|
</details>
|
||||||
Restart Home Assistant</br>
|
</details>
|
||||||
[](https://my.home-assistant.io/redirect/config_flow_start/?domain=saas)<br>
|
|
||||||
Add Integration: SAAS - Sleep As Android Status</br></br>
|
|
||||||
|
|
||||||
|
|
||||||
<h2>Configuration:</h3>
|
|
||||||
|
|
||||||
Name: Name of user</br>
|
|
||||||
Topic: MQTT Topic from Sleep As Android</br>
|
|
||||||
QoS: Quality of Service</br></br>
|
|
||||||
|
|
||||||
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>
|
|
||||||
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>
|
|
||||||
Awake States: States to indicate being awake</br>
|
|
||||||
Asleep States: States to indicate being asleep</br>
|
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,57 @@
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
|
||||||
from .services import async_setup_services # Import the service setup function
|
from .services import async_setup_services # Import the service setup function
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict):
|
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||||
"""Set up the SAAS - Sleep As Android Status component."""
|
"""Set up the SAAS - Sleep As Android Status component."""
|
||||||
_logger.info("Starting setup of the SAAS component")
|
_LOGGER.info("Starting setup of the SAAS component")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up a config entry."""
|
"""Set up a config entry."""
|
||||||
_logger.info(f"Starting setup of config entry with ID: {entry.entry_id}")
|
_LOGGER.info(f"Starting setup of config entry with ID: {entry.entry_id}")
|
||||||
|
|
||||||
|
# ensure we have a dict for this integration
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
|
||||||
if entry.entry_id not in hass.data[DOMAIN] and entry.data:
|
# merge original data + any saved options so runtime sees edits
|
||||||
hass.data[DOMAIN][entry.entry_id] = entry.data
|
merged = {**entry.data, **entry.options}
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = merged
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Merged entry.data and entry.options for %s: %s",
|
||||||
|
entry.entry_id,
|
||||||
|
hass.data[DOMAIN][entry.entry_id],
|
||||||
|
)
|
||||||
|
|
||||||
_logger.info(f"hass.data[DOMAIN] after adding entry data: {hass.data[DOMAIN]}")
|
# forward setup to sensor and button platforms
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, ["sensor", "button"])
|
||||||
|
|
||||||
# Forward the setup to the sensor and button platforms
|
# set up any custom services
|
||||||
for platform in ["sensor", "button"]:
|
_LOGGER.info("Starting setup of services")
|
||||||
_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")
|
|
||||||
await async_setup_services(hass)
|
await async_setup_services(hass)
|
||||||
_logger.info("Finished setup of services")
|
_LOGGER.info("Finished setup of services")
|
||||||
|
|
||||||
_logger.info(f"hass.data[DOMAIN] after setup of services: {hass.data[DOMAIN]}") # New log
|
_LOGGER.info("Finished setup of config entry")
|
||||||
|
|
||||||
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")
|
|
||||||
return True
|
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."""
|
"""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
|
# unload sensor and button platforms
|
||||||
_logger.info("Removing sensor platform")
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, ["sensor", "button"])
|
||||||
await hass.config_entries.async_forward_entry_unload(entry, "sensor")
|
if not unload_ok:
|
||||||
|
_LOGGER.error("Failed to unload platforms for saas")
|
||||||
|
return False
|
||||||
|
|
||||||
# Ensure hass.data[DOMAIN] is a dictionary before popping
|
# clean up our stored data
|
||||||
if isinstance(hass.data.get(DOMAIN, {}), dict):
|
hass.data[DOMAIN].pop(entry.entry_id, None)
|
||||||
hass.data[DOMAIN].pop(entry.entry_id, None)
|
|
||||||
|
|
||||||
_logger.info(f"hass.data[DOMAIN] after removing entry data: {hass.data[DOMAIN]}")
|
_LOGGER.info(f"hass.data[{DOMAIN}] after unload: {hass.data.get(DOMAIN)}")
|
||||||
_logger.info("Finished unload of config entry")
|
_LOGGER.info("Finished unload of config entry")
|
||||||
return True
|
return True
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
from homeassistant.components.button import ButtonEntity
|
from homeassistant.components.button import ButtonEntity
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
from .const import DOMAIN, INTEGRATION_NAME, MODEL, CONF_NAME
|
from .const import DOMAIN, INTEGRATION_NAME, MODEL, CONF_NAME, CONF_NOTIFY_TARGET
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
|
|
@ -50,6 +50,12 @@ class SAASSleepTrackingStart(ButtonEntity):
|
||||||
return device_info
|
return device_info
|
||||||
|
|
||||||
def press(self):
|
def press(self):
|
||||||
|
if not self._notify_target:
|
||||||
|
self._hass.components.persistent_notification.async_create(
|
||||||
|
"add a mobile device to use this function",
|
||||||
|
title=self.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
service_name = self._notify_target # Remove the "notify." prefix
|
service_name = self._notify_target # Remove the "notify." prefix
|
||||||
|
|
||||||
|
|
@ -117,6 +123,12 @@ class SAASSleepTrackingStop(ButtonEntity):
|
||||||
return device_info
|
return device_info
|
||||||
|
|
||||||
def press(self):
|
def press(self):
|
||||||
|
if not self._notify_target:
|
||||||
|
self._hass.components.persistent_notification.async_create(
|
||||||
|
"add a mobile device to use this function",
|
||||||
|
title=self.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
service_name = self._notify_target # Remove the "notify." prefix
|
service_name = self._notify_target # Remove the "notify." prefix
|
||||||
|
|
||||||
|
|
@ -184,6 +196,12 @@ class SAASSleepTrackingPause(ButtonEntity):
|
||||||
return device_info
|
return device_info
|
||||||
|
|
||||||
def press(self):
|
def press(self):
|
||||||
|
if not self._notify_target:
|
||||||
|
self._hass.components.persistent_notification.async_create(
|
||||||
|
"add a mobile device to use this function",
|
||||||
|
title=self.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
service_name = self._notify_target # Remove the "notify." prefix
|
service_name = self._notify_target # Remove the "notify." prefix
|
||||||
|
|
||||||
|
|
@ -251,6 +269,12 @@ class SAASSleepTrackingResume(ButtonEntity):
|
||||||
return device_info
|
return device_info
|
||||||
|
|
||||||
def press(self):
|
def press(self):
|
||||||
|
if not self._notify_target:
|
||||||
|
self._hass.components.persistent_notification.async_create(
|
||||||
|
"add a mobile device to use this function",
|
||||||
|
title=self.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
service_name = self._notify_target # Remove the "notify." prefix
|
service_name = self._notify_target # Remove the "notify." prefix
|
||||||
|
|
||||||
|
|
@ -318,6 +342,12 @@ class SAASAlarmClockSnooze(ButtonEntity):
|
||||||
return device_info
|
return device_info
|
||||||
|
|
||||||
def press(self):
|
def press(self):
|
||||||
|
if not self._notify_target:
|
||||||
|
self._hass.components.persistent_notification.async_create(
|
||||||
|
"add a mobile device to use this function",
|
||||||
|
title=self.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
service_name = self._notify_target # Remove the "notify." prefix
|
service_name = self._notify_target # Remove the "notify." prefix
|
||||||
|
|
||||||
|
|
@ -385,6 +415,12 @@ class SAASAlarmClockDisable(ButtonEntity):
|
||||||
return device_info
|
return device_info
|
||||||
|
|
||||||
def press(self):
|
def press(self):
|
||||||
|
if not self._notify_target:
|
||||||
|
self._hass.components.persistent_notification.async_create(
|
||||||
|
"add a mobile device to use this function",
|
||||||
|
title=self.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
service_name = self._notify_target # Remove the "notify." prefix
|
service_name = self._notify_target # Remove the "notify." prefix
|
||||||
|
|
||||||
|
|
@ -452,6 +488,12 @@ class SAASSleepTrackingStartWithAlarm(ButtonEntity):
|
||||||
return device_info
|
return device_info
|
||||||
|
|
||||||
def press(self):
|
def press(self):
|
||||||
|
if not self._notify_target:
|
||||||
|
self._hass.components.persistent_notification.async_create(
|
||||||
|
"add a mobile device to use this function",
|
||||||
|
title=self.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
service_name = self._notify_target # Remove the "notify." prefix
|
service_name = self._notify_target # Remove the "notify." prefix
|
||||||
|
|
||||||
|
|
@ -519,6 +561,12 @@ class SAASLullabyStop(ButtonEntity):
|
||||||
return device_info
|
return device_info
|
||||||
|
|
||||||
def press(self):
|
def press(self):
|
||||||
|
if not self._notify_target:
|
||||||
|
self._hass.components.persistent_notification.async_create(
|
||||||
|
"add a mobile device to use this function",
|
||||||
|
title=self.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
service_name = self._notify_target # Remove the "notify." prefix
|
service_name = self._notify_target # Remove the "notify." prefix
|
||||||
|
|
||||||
|
|
@ -546,12 +594,15 @@ class SAASLullabyStop(ButtonEntity):
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||||
"""Set up the SAAS Sleep Tracking Start, Stop and Pause buttons from a config entry."""
|
notify_target = config_entry.data.get(CONF_NOTIFY_TARGET)
|
||||||
|
if not notify_target:
|
||||||
|
_LOGGER.warning("no notify_target configured; skipping button setup")
|
||||||
|
return
|
||||||
# _LOGGER.debug("Setting up SAAS Sleep Tracking buttons from a config entry with data: %s", config_entry.data)
|
# _LOGGER.debug("Setting up SAAS Sleep Tracking buttons from a config entry with data: %s", config_entry.data)
|
||||||
|
|
||||||
# Extract the necessary data from config_entry.data
|
# Extract the necessary data from config_entry.data
|
||||||
name = config_entry.data[CONF_NAME]
|
name = config_entry.data[CONF_NAME]
|
||||||
notify_target = config_entry.data['notify_target']
|
notify_target = config_entry.data.get(CONF_NOTIFY_TARGET)
|
||||||
|
|
||||||
# Create instances of SAASSleepTrackingStart, SAASSleepTrackingStop and SAASSleepTrackingPause
|
# Create instances of SAASSleepTrackingStart, SAASSleepTrackingStop and SAASSleepTrackingPause
|
||||||
entities = [
|
entities = [
|
||||||
|
|
|
||||||
|
|
@ -1,139 +1,184 @@
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
|
||||||
import voluptuous as vol
|
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 import config_entries
|
||||||
from homeassistant.core import callback
|
from voluptuous import Schema, Required, Optional, In
|
||||||
from voluptuous import Schema, Required, In, Optional
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
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
|
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
|
||||||
|
|
||||||
async def async_step_user(self, user_input=None):
|
async def async_step_user(self, user_input=None):
|
||||||
"""Handle a flow initialized by the user."""
|
"""Initial setup step."""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
# Get the list of notify targets
|
# discover available mobile_app notify services
|
||||||
notify_services = self.hass.services.async_services().get('notify', {})
|
notify_services = self.hass.services.async_services().get("notify", {})
|
||||||
notify_targets = {target.replace('mobile_app_', '').title(): target for target in notify_services.keys() if target.startswith('mobile_app_')}
|
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:
|
if user_input is not None:
|
||||||
# Map the selected option back to the actual notify target name
|
# map chosen label back to service name, or remove if invalid
|
||||||
user_input[CONF_NOTIFY_TARGET] = notify_targets[user_input[CONF_NOTIFY_TARGET]]
|
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)
|
||||||
|
|
||||||
# Validate the user input here
|
# basic validation
|
||||||
# If the input is valid, create an entry
|
if not user_input.get(CONF_NAME):
|
||||||
# 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"
|
errors[CONF_NAME] = "required"
|
||||||
|
|
||||||
if not errors:
|
if not errors:
|
||||||
return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
|
return self.async_create_entry(
|
||||||
|
title=user_input[CONF_NAME],
|
||||||
|
data=user_input
|
||||||
|
)
|
||||||
|
|
||||||
|
# build initial form schema
|
||||||
|
schema = {
|
||||||
|
Required(CONF_NAME): str,
|
||||||
|
Required(CONF_TOPIC): str,
|
||||||
|
Required(CONF_AWAKE_DURATION, default=DEFAULT_AWAKE_DURATION): int,
|
||||||
|
Required(CONF_SLEEP_DURATION, default=DEFAULT_SLEEP_DURATION): int,
|
||||||
|
Required(
|
||||||
|
CONF_AWAKE_STATES, default=DEFAULT_AWAKE_STATES
|
||||||
|
): cv.multi_select(AVAILABLE_STATES),
|
||||||
|
Required(
|
||||||
|
CONF_SLEEP_STATES, default=DEFAULT_SLEEP_STATES
|
||||||
|
): cv.multi_select(AVAILABLE_STATES),
|
||||||
|
}
|
||||||
|
if notify_targets:
|
||||||
|
# truly optional, only real targets, no blank choice
|
||||||
|
schema[Optional(CONF_NOTIFY_TARGET)] = In(list(notify_targets.keys()))
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
data_schema=Schema(
|
data_schema=Schema(schema),
|
||||||
{
|
|
||||||
Required(CONF_NAME): str,
|
|
||||||
Required(CONF_TOPIC): str,
|
|
||||||
Required(CONF_QOS, default=0): In([0, 1, 2]),
|
|
||||||
Required(CONF_AWAKE_DURATION, default=DEFAULT_AWAKE_DURATION): int,
|
|
||||||
Required(CONF_SLEEP_DURATION, default=DEFAULT_SLEEP_DURATION): int,
|
|
||||||
Required(CONF_AWAKE_STATES, default=DEFAULT_AWAKE_STATES): cv.multi_select(AVAILABLE_STATES),
|
|
||||||
Required(CONF_SLEEP_STATES, default=DEFAULT_SLEEP_STATES): cv.multi_select(AVAILABLE_STATES),
|
|
||||||
Optional(CONF_NOTIFY_TARGET): vol.In(list(notify_targets.keys())),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@config_entries.callback
|
||||||
def async_get_options_flow(config_entry):
|
def async_get_options_flow(entry):
|
||||||
"""Get the options flow for this handler."""
|
"""Return options flow handler."""
|
||||||
return OptionsFlowHandler(config_entry)
|
return OptionsFlowHandler(entry)
|
||||||
|
|
||||||
|
|
||||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||||
"""Handle options."""
|
"""Handle SAAS options editing."""
|
||||||
def __init__(self, config_entry):
|
|
||||||
"""Initialize options flow."""
|
def __init__(self, entry):
|
||||||
self.config_entry = config_entry
|
super().__init__()
|
||||||
|
self._config_entry = entry
|
||||||
|
|
||||||
async def async_step_init(self, user_input=None):
|
async def async_step_init(self, user_input=None):
|
||||||
"""Manage the options."""
|
"""Manage the options form (edit)."""
|
||||||
_logger.debug("Entering async_step_init with user_input: %s", user_input)
|
current = dict(self._config_entry.data)
|
||||||
|
|
||||||
errors = {} # Define errors here
|
# 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()}
|
||||||
|
|
||||||
try:
|
if user_input is not None:
|
||||||
# Fetch the initial configuration data
|
new_data = current.copy()
|
||||||
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
|
# standard fields
|
||||||
notify_services = self.hass.services.async_services().get('notify', {})
|
for key in (
|
||||||
notify_targets = {target.replace('mobile_app_', '').title(): target for target in notify_services.keys() if target.startswith('mobile_app_')}
|
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]
|
||||||
|
|
||||||
if user_input is not None:
|
# handle notify_target with "no mobile" option
|
||||||
# Validate the user input here
|
sel = user_input.get(CONF_NOTIFY_TARGET)
|
||||||
# If the input is valid, create an entry
|
if sel == "no mobile":
|
||||||
# If the input is not valid, add an error message to the 'errors' dictionary
|
new_data.pop(CONF_NOTIFY_TARGET, None)
|
||||||
# For example:
|
elif sel in notify_targets:
|
||||||
if not user_input[CONF_NAME]:
|
new_data[CONF_NOTIFY_TARGET] = notify_targets[sel]
|
||||||
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
|
# persist back into entry.data and reload
|
||||||
user_input[CONF_NOTIFY_TARGET] = notify_targets[user_input[CONF_NOTIFY_TARGET]]
|
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)
|
||||||
|
|
||||||
# Merge current_data with user_input
|
return self.async_create_entry(title="", data=None)
|
||||||
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)
|
# 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 updated_data is not None:
|
if notify_targets:
|
||||||
self.hass.data[DOMAIN][self.config_entry.entry_id] = updated_data # Save updated data
|
# 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)
|
||||||
|
|
||||||
# Update the entry data
|
return self.async_show_form(
|
||||||
self.hass.config_entries.async_update_entry(self.config_entry, data=updated_data)
|
step_id="init",
|
||||||
|
data_schema=Schema(schema),
|
||||||
# Send a signal to reload the integration
|
errors={},
|
||||||
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))
|
|
||||||
|
|
||||||
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())),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
@ -5,21 +5,24 @@ DOMAIN = "saas"
|
||||||
INTEGRATION_NAME = "SAAS - Sleep As Android Stats"
|
INTEGRATION_NAME = "SAAS - Sleep As Android Stats"
|
||||||
MODEL = "SAAS - Version 0.0.1"
|
MODEL = "SAAS - Version 0.0.1"
|
||||||
|
|
||||||
CONF_NAME = "name" # Name of the Integration
|
CONF_NAME = "name" # Name of the Integration
|
||||||
CONF_TOPIC = "topic_template" # MQTT Topic for Sleep As Android Events
|
CONF_TOPIC = "topic_template" # MQTT Topic for Sleep As Android Events
|
||||||
CONF_QOS = "qos" # Quality of Service
|
CONF_AWAKE_DURATION = "awake_duration" # Awake Duration
|
||||||
CONF_AWAKE_DURATION = "awake_duration" # Awake Duration
|
|
||||||
CONF_SLEEP_DURATION = "sleep_duration" # Sleep Duration
|
CONF_SLEEP_DURATION = "sleep_duration" # Sleep Duration
|
||||||
|
|
||||||
CONF_AWAKE_STATES = "awake_states" # Awake States
|
CONF_AWAKE_STATES = "awake_states" # Awake States
|
||||||
CONF_SLEEP_STATES = "sleep_states" # Sleep States
|
CONF_SLEEP_STATES = "sleep_states" # Sleep States
|
||||||
|
CONF_NOTIFY_TARGET = "notify_target" # Notify Target
|
||||||
|
|
||||||
CONF_NOTIFY_TARGET = "notify_target" # Notify Target
|
DEFAULT_AWAKE_DURATION = 10 # Default Awake Duration
|
||||||
|
DEFAULT_SLEEP_DURATION = 10 # Default Sleep Duration
|
||||||
DEFAULT_AWAKE_DURATION = 10 # Default Awake Duration
|
DEFAULT_AWAKE_STATES = ["Awake", "Sleep Tracking Stopped"] # Default Awake States
|
||||||
DEFAULT_SLEEP_DURATION = 10 # Default Sleep Duration
|
DEFAULT_SLEEP_STATES = [
|
||||||
DEFAULT_AWAKE_STATES = ["Awake", "Sleep Tracking Stopped"] # Default Awake States
|
"Not Awake",
|
||||||
DEFAULT_SLEEP_STATES = ["Not Awake", "Rem", "Light Sleep", "Deep Sleep", "Sleep Tracking Started"]# Default Sleep States
|
"Rem",
|
||||||
|
"Light Sleep",
|
||||||
|
"Deep Sleep",
|
||||||
|
"Sleep Tracking Started",
|
||||||
|
] # Default Sleep States
|
||||||
|
|
||||||
SENSOR_TYPES = {
|
SENSOR_TYPES = {
|
||||||
"state": {"name": "State", "device_class": None},
|
"state": {"name": "State", "device_class": None},
|
||||||
|
|
@ -33,7 +36,7 @@ DAY_MAPPING = {
|
||||||
"wednesday": 4,
|
"wednesday": 4,
|
||||||
"thursday": 5,
|
"thursday": 5,
|
||||||
"friday": 6,
|
"friday": 6,
|
||||||
"saturday": 7
|
"saturday": 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
AVAILABLE_STATES = [
|
AVAILABLE_STATES = [
|
||||||
|
|
@ -67,8 +70,9 @@ AVAILABLE_STATES = [
|
||||||
'Sound Event Laugh',
|
'Sound Event Laugh',
|
||||||
'Sound Event Snore',
|
'Sound Event Snore',
|
||||||
'Sound Event Talk',
|
'Sound Event Talk',
|
||||||
'Time for Bed'
|
'Time for Bed',
|
||||||
]
|
]
|
||||||
|
|
||||||
STATE_MAPPING = {
|
STATE_MAPPING = {
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"sleep_tracking_started": "Sleep Tracking Started",
|
"sleep_tracking_started": "Sleep Tracking Started",
|
||||||
|
|
@ -100,7 +104,7 @@ STATE_MAPPING = {
|
||||||
"sound_event_cough": "Cough Detected",
|
"sound_event_cough": "Cough Detected",
|
||||||
"sound_event_baby": "Baby Cry Detected",
|
"sound_event_baby": "Baby Cry Detected",
|
||||||
"sound_event_laugh": "Laugh Detected",
|
"sound_event_laugh": "Laugh Detected",
|
||||||
"alarm_rescheduled": "Alarm Rescheduled"
|
"alarm_rescheduled": "Alarm Rescheduled",
|
||||||
}
|
}
|
||||||
|
|
||||||
REVERSE_STATE_MAPPING = {v: k for k, v in STATE_MAPPING.items()}
|
REVERSE_STATE_MAPPING = {v: k for k, v in STATE_MAPPING.items()}
|
||||||
|
|
@ -111,14 +115,15 @@ SLEEP_STAGE_MAPPING = {
|
||||||
"deep_sleep": "Deep Sleep",
|
"deep_sleep": "Deep Sleep",
|
||||||
"light_sleep": "Light Sleep",
|
"light_sleep": "Light Sleep",
|
||||||
"awake": "Awake",
|
"awake": "Awake",
|
||||||
"not_awake": "Not Awake"
|
"not_awake": "Not Awake",
|
||||||
}
|
}
|
||||||
|
|
||||||
SOUND_MAPPING = {
|
SOUND_MAPPING = {
|
||||||
'sound_event_snore': "Snore Detected",
|
'sound_event_snore': "Snore Detected",
|
||||||
'sound_event_talk': "Talk Detected",
|
'sound_event_talk': "Talk Detected",
|
||||||
'sound_event_cough': "Cough Detected",
|
'sound_event_cough': "Cough Detected",
|
||||||
'sound_event_baby': "Baby Cry Detected",
|
'sound_event_baby': "Baby Cry Detected",
|
||||||
'sound_event_laugh': "Laugh Detected"
|
'sound_event_laugh': "Laugh Detected",
|
||||||
}
|
}
|
||||||
|
|
||||||
LULLABY_MAPPING = {
|
LULLABY_MAPPING = {
|
||||||
|
|
@ -129,7 +134,7 @@ LULLABY_MAPPING = {
|
||||||
|
|
||||||
DISTURBANCE_MAPPING = {
|
DISTURBANCE_MAPPING = {
|
||||||
'apnea_alarm': "Apnea Alarm",
|
'apnea_alarm': "Apnea Alarm",
|
||||||
'antisnoring': "Antisnoring"
|
'antisnoring': "Antisnoring",
|
||||||
}
|
}
|
||||||
|
|
||||||
ALARM_EVENT_MAPPING = {
|
ALARM_EVENT_MAPPING = {
|
||||||
|
|
@ -143,12 +148,12 @@ ALARM_EVENT_MAPPING = {
|
||||||
'show_skip_next_alarm': "Show Skip Next Alarm",
|
'show_skip_next_alarm': "Show Skip Next Alarm",
|
||||||
'smart_period': "Smart Period",
|
'smart_period': "Smart Period",
|
||||||
'before_smart_period': "Before Smart Period",
|
'before_smart_period': "Before Smart Period",
|
||||||
"alarm_rescheduled": "Alarm Rescheduled"
|
"alarm_rescheduled": "Alarm Rescheduled",
|
||||||
}
|
}
|
||||||
|
|
||||||
SLEEP_TRACKING_MAPPING = {
|
SLEEP_TRACKING_MAPPING = {
|
||||||
'sleep_tracking_started': "Sleep Tracking Started",
|
'sleep_tracking_started': "Sleep Tracking Started",
|
||||||
'sleep_tracking_stopped': "Sleep Tracking Stopped",
|
'sleep_tracking_stopped': "Sleep Tracking Stopped",
|
||||||
'sleep_tracking_paused': "Sleep Tracking Paused",
|
'sleep_tracking_paused': "Sleep Tracking Paused",
|
||||||
'sleep_tracking_resumed': "Sleep Tracking Resumed"
|
'sleep_tracking_resumed': "Sleep Tracking Resumed",
|
||||||
}
|
}
|
||||||
|
|
@ -6,5 +6,5 @@
|
||||||
"dependencies": ["mqtt", "mobile_app"],
|
"dependencies": ["mqtt", "mobile_app"],
|
||||||
"documentation": "https://www.github.com/sudoxnym/saas",
|
"documentation": "https://www.github.com/sudoxnym/saas",
|
||||||
"issue_tracker": "https://www.github.com/sudoxnym/saas/issues",
|
"issue_tracker": "https://www.github.com/sudoxnym/saas/issues",
|
||||||
"version": "0.0.6"
|
"version": "0.2.1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,9 @@ from homeassistant.util import dt as dt_util
|
||||||
from homeassistant.components import mqtt
|
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
|
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 = logging.getLogger(__name__)
|
||||||
_LOGGER.setLevel(logging.DEBUG)
|
_LOGGER.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
import inspect
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class SAASSensor(RestoreEntity):
|
class SAASSensor(RestoreEntity):
|
||||||
"""Representation of a SAAS - Sleep As Android Stats sensor."""
|
"""Representation of a SAAS - Sleep As Android Stats sensor."""
|
||||||
|
|
||||||
|
|
@ -105,8 +101,8 @@ class SAASAlarmEventSensor(RestoreEntity):
|
||||||
self._value2 = None
|
self._value2 = None
|
||||||
self._time = None
|
self._time = None
|
||||||
self.entry_id = entry_id
|
self.entry_id = entry_id
|
||||||
self._reset_timer = None
|
|
||||||
self._last_event = None
|
self._last_event = None
|
||||||
|
self._timeout_task = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
|
|
@ -136,13 +132,15 @@ class SAASAlarmEventSensor(RestoreEntity):
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self):
|
def extra_state_attributes(self):
|
||||||
"""Return the extra state attributes."""
|
"""Return the extra state attributes."""
|
||||||
return {
|
if self._state is not None:
|
||||||
"Last Event": self._last_event,
|
return {
|
||||||
"Message": self._value2 if self._value2 else "No message received",
|
"Last Event": self._last_event,
|
||||||
"Timestamp": self._value1 if self._value1 else "No timestamp received",
|
"Message": self._value2 if self._value2 else "No message received",
|
||||||
"Time": self._time.strftime('%H:%M') if self._time else "No time received",
|
"Timestamp": self._value1 if self._value1 else "No timestamp received",
|
||||||
"Date": self._time.strftime('%m/%d/%Y') if self._time else "No date 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):
|
async def async_added_to_hass(self):
|
||||||
"""Run when entity about to be added."""
|
"""Run when entity about to be added."""
|
||||||
|
|
@ -154,19 +152,14 @@ class SAASAlarmEventSensor(RestoreEntity):
|
||||||
self._state = state.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}")
|
_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(_):
|
# Start the timeout task as soon as the sensor is loaded
|
||||||
"""Reset the state to None after a delay."""
|
self._timeout_task = asyncio.create_task(self.timeout())
|
||||||
self._state = 'None'
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
async def message_received(msg):
|
async def message_received(msg):
|
||||||
"""Handle new MQTT messages."""
|
"""Handle new MQTT messages."""
|
||||||
# Cancel the previous state reset timer
|
# Cancel the previous timeout task if it exists
|
||||||
if self._reset_timer:
|
if self._timeout_task:
|
||||||
self._reset_timer.cancel()
|
self._timeout_task.cancel()
|
||||||
|
|
||||||
# Schedule a state reset after 15 seconds
|
|
||||||
self._reset_timer = async_call_later(self._hass, 15, reset_state)
|
|
||||||
|
|
||||||
# Parse the incoming message
|
# Parse the incoming message
|
||||||
msg_json = json.loads(msg.payload)
|
msg_json = json.loads(msg.payload)
|
||||||
|
|
@ -197,7 +190,230 @@ class SAASAlarmEventSensor(RestoreEntity):
|
||||||
_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}")
|
_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
|
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) # 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,
|
||||||
|
"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."""
|
||||||
|
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 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
|
# Use the mapping to convert the event to the corresponding state
|
||||||
new_state = self._mapping.get(event)
|
new_state = self._mapping.get(event)
|
||||||
|
|
@ -206,159 +422,28 @@ class SAASAlarmEventSensor(RestoreEntity):
|
||||||
self._state = new_state
|
self._state = new_state
|
||||||
self._last_event = new_state # Update the last event
|
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}")
|
_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
|
# Extract the 'value1' and 'value2' fields
|
||||||
await async_subscribe(self._hass, self._hass.data[DOMAIN][self.entry_id][CONF_TOPIC], message_received)
|
value1 = msg_json.get('value1')
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self):
|
# Parse 'value1' as a datetime
|
||||||
"""Run when entity will be removed from hass."""
|
if value1:
|
||||||
# Save the current state to the state machine
|
timestamp = int(value1) / 1000.0
|
||||||
self._hass.states.async_set(self.entity_id, self._state)
|
self._time = datetime.fromtimestamp(timestamp)
|
||||||
_LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Saved state: {self._state} for sensor {self.name}")
|
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}")
|
||||||
|
|
||||||
class SAASSoundSensor(RestoreEntity):
|
# Extract the 'value2' field
|
||||||
"""Representation of a SAAS - Sleep As Android Stats sensor for Sound Events."""
|
value2 = msg_json.get('value2')
|
||||||
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
|
# Store 'value2' as the message if it exists
|
||||||
def unique_id(self):
|
self._value2 = value2 if value2 else "None"
|
||||||
"""Return a unique ID."""
|
_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}")
|
||||||
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()
|
self.async_schedule_update_ha_state()
|
||||||
|
|
||||||
# Subscribe to the topic from the user input
|
# Create a new timeout task
|
||||||
await async_subscribe(self._hass, self._hass.data[DOMAIN][self.entry_id][CONF_TOPIC], message_received)
|
self._timeout_task = asyncio.create_task(self.timeout())
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
# Subscribe to the topic from the user input
|
# Subscribe to the topic from the user input
|
||||||
await async_subscribe(self._hass, self._hass.data[DOMAIN][self.entry_id][CONF_TOPIC], message_received)
|
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)
|
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}")
|
_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):
|
class SAASSleepTrackingSensor(RestoreEntity):
|
||||||
"""Representation of a SAAS - Sleep As Android Stats sensor for Sleep Tracking."""
|
"""Representation of a SAAS - Sleep As Android Stats sensor for Sleep Tracking."""
|
||||||
def __init__(self, hass, name, mapping, entry_id):
|
def __init__(self, hass, name, mapping, entry_id):
|
||||||
|
|
@ -446,13 +539,19 @@ class SAASSleepTrackingSensor(RestoreEntity):
|
||||||
|
|
||||||
class SAASDisturbanceSensor(RestoreEntity):
|
class SAASDisturbanceSensor(RestoreEntity):
|
||||||
"""Representation of a SAAS - Sleep As Android Stats sensor for Disturbance Events."""
|
"""Representation of a SAAS - Sleep As Android Stats sensor for Disturbance Events."""
|
||||||
|
|
||||||
def __init__(self, hass, name, mapping, entry_id):
|
def __init__(self, hass, name, mapping, entry_id):
|
||||||
"""Initialize the sensor."""
|
"""Initialize the sensor."""
|
||||||
self._state = None
|
self._state = None
|
||||||
self._name = name
|
self._name = name
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._mapping = mapping
|
self._mapping = mapping
|
||||||
|
self._value1 = None
|
||||||
|
self._value2 = None
|
||||||
|
self._time = None
|
||||||
self.entry_id = entry_id
|
self.entry_id = entry_id
|
||||||
|
self._last_event = None
|
||||||
|
self._timeout_task = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
|
|
@ -479,6 +578,16 @@ class SAASDisturbanceSensor(RestoreEntity):
|
||||||
"model": MODEL,
|
"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):
|
async def async_added_to_hass(self):
|
||||||
"""Run when entity about to be added."""
|
"""Run when entity about to be added."""
|
||||||
await super().async_added_to_hass()
|
await super().async_added_to_hass()
|
||||||
|
|
@ -489,8 +598,15 @@ class SAASDisturbanceSensor(RestoreEntity):
|
||||||
self._state = state.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}")
|
_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):
|
async def message_received(msg):
|
||||||
"""Handle new MQTT messages."""
|
"""Handle new MQTT messages."""
|
||||||
|
# Cancel the previous timeout task if it exists
|
||||||
|
if self._timeout_task:
|
||||||
|
self._timeout_task.cancel()
|
||||||
|
|
||||||
# Parse the incoming message
|
# Parse the incoming message
|
||||||
msg_json = json.loads(msg.payload)
|
msg_json = json.loads(msg.payload)
|
||||||
|
|
||||||
|
|
@ -504,10 +620,34 @@ class SAASDisturbanceSensor(RestoreEntity):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Use the mapping to convert the event to the corresponding state
|
# 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
|
new_state = self._mapping.get(event)
|
||||||
self._state = new_state
|
if new_state is not None:
|
||||||
_LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to {new_state} for sensor {self.name}")
|
_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.async_schedule_update_ha_state()
|
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
|
# Subscribe to the topic from the user input
|
||||||
await async_subscribe(self._hass, self._hass.data[DOMAIN][self.entry_id][CONF_TOPIC], message_received)
|
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)
|
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}")
|
_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):
|
class SAASLullabySensor(RestoreEntity):
|
||||||
"""Representation of a SAAS - Sleep As Android Stats sensor for Lullaby."""
|
"""Representation of a SAAS - Sleep As Android Stats sensor for Lullaby."""
|
||||||
def __init__(self, hass, name, mapping, entry_id):
|
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):
|
def __init__(self, hass, name, awake_states, sleep_states, awake_duration, sleep_duration, mqtt_topic, entry_id):
|
||||||
self._state = None
|
self._state = None
|
||||||
self._name = name
|
self._name = name
|
||||||
self.awake_bucket = []
|
|
||||||
self.sleep_bucket = []
|
|
||||||
self.awake_duration = timedelta(seconds=awake_duration)
|
self.awake_duration = timedelta(seconds=awake_duration)
|
||||||
self.sleep_duration = timedelta(seconds=sleep_duration)
|
self.sleep_duration = timedelta(seconds=sleep_duration)
|
||||||
self.awake_states = awake_states
|
self.awake_states = awake_states
|
||||||
|
|
@ -679,6 +825,8 @@ class SAASWakeStatusSensor(RestoreEntity):
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.mqtt_topic = mqtt_topic
|
self.mqtt_topic = mqtt_topic
|
||||||
self.entry_id = entry_id
|
self.entry_id = entry_id
|
||||||
|
self.awake_timer = None
|
||||||
|
self.sleep_timer = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
|
|
@ -722,61 +870,32 @@ class SAASWakeStatusSensor(RestoreEntity):
|
||||||
self._state = "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'.")
|
_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:
|
if mapped_value in self.awake_states:
|
||||||
self.awake_bucket.append((mapped_value, now))
|
if self.awake_timer:
|
||||||
self.sleep_bucket = []
|
self.awake_timer.cancel()
|
||||||
_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.")
|
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, add it to the sleep bucket and clear the awake bucket
|
# 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:
|
elif mapped_value in self.sleep_states:
|
||||||
self.sleep_bucket.append((mapped_value, now))
|
if self.sleep_timer:
|
||||||
self.awake_bucket = []
|
self.sleep_timer.cancel()
|
||||||
_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.")
|
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:
|
except Exception as e:
|
||||||
_LOGGER.error(f"Error processing message: {e}")
|
_LOGGER.error(f"Error processing message: {e}")
|
||||||
|
|
||||||
async def async_update(self, _=None):
|
def set_state(self, state):
|
||||||
"""Update the state."""
|
self._state = state
|
||||||
now = dt_util.utcnow()
|
self.async_schedule_update_ha_state()
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
"""Run when entity about to be added."""
|
"""Run when entity about to be added."""
|
||||||
|
|
@ -788,13 +907,6 @@ class SAASWakeStatusSensor(RestoreEntity):
|
||||||
self._state = state.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}")
|
_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
|
# Subscribe to the MQTT topic to receive messages
|
||||||
await mqtt.async_subscribe(
|
await mqtt.async_subscribe(
|
||||||
self.hass,
|
self.hass,
|
||||||
|
|
@ -822,6 +934,7 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
||||||
entities = [
|
entities = [
|
||||||
SAASSensor(hass, name, STATE_MAPPING, entry_id),
|
SAASSensor(hass, name, STATE_MAPPING, entry_id),
|
||||||
SAASAlarmEventSensor(hass, name, ALARM_EVENT_MAPPING, entry_id),
|
SAASAlarmEventSensor(hass, name, ALARM_EVENT_MAPPING, entry_id),
|
||||||
|
SAASNextAlarmSensor(hass, name, entry_id),
|
||||||
SAASSoundSensor(hass, name, SOUND_MAPPING, entry_id),
|
SAASSoundSensor(hass, name, SOUND_MAPPING, entry_id),
|
||||||
SAASSleepTrackingSensor(hass, name, SLEEP_TRACKING_MAPPING, entry_id),
|
SAASSleepTrackingSensor(hass, name, SLEEP_TRACKING_MAPPING, entry_id),
|
||||||
SAASDisturbanceSensor(hass, name, DISTURBANCE_MAPPING, entry_id),
|
SAASDisturbanceSensor(hass, name, DISTURBANCE_MAPPING, entry_id),
|
||||||
|
|
|
||||||
1
custom_components/saas/services.yaml
Normal file
1
custom_components/saas/services.yaml
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
#empty on purpose
|
||||||
Loading…
Reference in a new issue