From b6be90c9b65fc9af486efcd13a5671c744fac863 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Tue, 5 Sep 2023 15:22:18 -0400 Subject: [PATCH 01/56] Delete __pycache__ directory --- __pycache__/RenphoWeight.cpython-39.pyc | Bin 3756 -> 0 bytes __pycache__/__init__.cpython-39.pyc | Bin 1183 -> 0 bytes __pycache__/const.cpython-39.pyc | Bin 583 -> 0 bytes __pycache__/sensor.cpython-39.pyc | Bin 3066 -> 0 bytes 4 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 __pycache__/RenphoWeight.cpython-39.pyc delete mode 100644 __pycache__/__init__.cpython-39.pyc delete mode 100644 __pycache__/const.cpython-39.pyc delete mode 100644 __pycache__/sensor.cpython-39.pyc diff --git a/__pycache__/RenphoWeight.cpython-39.pyc b/__pycache__/RenphoWeight.cpython-39.pyc deleted file mode 100644 index 759bd494088cc6ebd5ebd55192b0d427afe45a25..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3756 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 HHEQ)g9K5K! diff --git a/__pycache__/sensor.cpython-39.pyc b/__pycache__/sensor.cpython-39.pyc deleted file mode 100644 index 9afab702689cb094e9ac7f9128f234952d219ab4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3066 zcmcguTW=dh6y8~{udy9FPSTX#mO=q@*#P1J34|IYDFhm)iX&B8A+5HaalB=3%#NvS znWyGw^pThFljfDD{)LJM&YAUPlQ>cl5|hmFIWuQwzB#uYw^plY(7rJKe6~{7w7*cu zwju}*HH{>H07ErfqlD@{33OX0$mjfAVAzJ@4L=_g>_Skqi%QP>rJ!t=6<_cx!HT`2 z_@ZA8YIZHC+jWpjwCt}24Z9IE?PjoMuff<2xxVntuJhBu77cMiLM~&tV^a*+%>41xC03_xNQ1h<+ zflJ2D3{D+3Nz{c)6`j8WQV;rthO-ax`ac81G@C#c=#ZaLS-n+G9y-u1mP%ecnsoa?eUwUjJ45X^$X zkdZLI&u_q1%G4>j38E}H5HOx_oVy^U*3%1QypeUKHPex~MDQNQ_^ct1T|q_brcv|e z?y-tL1ub*~h8JH%kTtFyE7zOpF$xb@;y!F}8K8m7--C%nnJ71IUVw(P*CWCl!zc%n zk@39@CS{mQpa6i006sv15t%VlUM=_t24^tVF2GRJ*W*CTh^r`uV0i=)FgF=;7C@L- zZNgD|DT)>@aH77GS@2l~)6|@4(pgKCuOOU<8O9=7>Ny4(q=X8x$s;Cu12beV%+z3* z>#7iEd|5H4?PTC3Cj8@E6#65$mKa17WLKEUAR7AA?9VrxZ-s@|oL63JzFnuCzii92)f; zhhx;qQp}^6aWUc_g2+Ec@+p#SB#SUn9Hx{?{9C{zSma8?(Cc}fGz^_opf?OP--D?O z?f$%eBMT~> zi&uh`z6^K{wOW_8Lilv4!ipxg*Dg Date: Tue, 5 Sep 2023 15:28:18 -0400 Subject: [PATCH 02/56] Create dependabot.yml --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..91abb11 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" From 4fecca4b80bc47693099ee9521ee39742cd2555e Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Tue, 5 Sep 2023 15:29:44 -0400 Subject: [PATCH 03/56] Create SECURITY.md --- SECURITY.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5b4ffb2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security Policy + +## Supported Versions + +Only certain versions of this project are eligible for security updates. Below is a table that outlines which versions are currently supported: + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +We take security very seriously. If you believe you've found a security vulnerability in our project, we encourage you to notify us as soon as possible. + +### How to Report + +- **DO NOT** create a public GitHub issue for the vulnerability. +- Email the security team at [security-email@example.com](mailto:security-email@example.com). +- Provide steps to reproduce the issue. The more detail you provide, the faster we can validate and fix the vulnerability. + +### What to Expect + +- A response from the team acknowledging receipt of your report within 48 hours. +- After initial validation, a timeline for when you can expect updates and patches for the issue. +- Public acknowledgment of the vulnerability after it has been fixed. + +### Note +Vulnerabilities that are not validated within 7 days will be closed. From f78563fa78e40649b0bc8f236b1987911315d6d6 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 19:44:20 +0000 Subject: [PATCH 04/56] add all the sensor --- .github/pull_request_template.md | 42 ++ .gitignore | 140 ++++++- README.md | 175 ++++++++- RenphoWeight.py | 98 ----- __init__.py | 47 +-- const.py | 9 - docs/README.md | 352 +++++++++++++++++ docs/hacs/dev.md | 43 +++ docs/images/weight.png | Bin 0 -> 16726 bytes example/configuration.yaml | 8 + example/lovelace.yaml | 16 + hacs.json | 8 + manifest.json | 10 +- requirements.txt | 4 + sensor.py | 83 ---- src/RenphoWeight.py | 179 +++++++++ src/__init__.py | 51 +++ src/__pycache__/RenphoWeight.cpython-310.pyc | Bin 0 -> 6528 bytes src/__pycache__/const.cpython-310.pyc | Bin 0 -> 1157 bytes src/const.py | 32 ++ src/sensor.py | 379 +++++++++++++++++++ src/tests.py | 253 +++++++++++++ testing.py | 10 - 23 files changed, 1687 insertions(+), 252 deletions(-) create mode 100644 .github/pull_request_template.md delete mode 100755 RenphoWeight.py mode change 100755 => 100644 __init__.py delete mode 100755 const.py create mode 100644 docs/README.md create mode 100644 docs/hacs/dev.md create mode 100644 docs/images/weight.png create mode 100644 example/configuration.yaml create mode 100644 example/lovelace.yaml create mode 100644 hacs.json create mode 100644 requirements.txt delete mode 100755 sensor.py create mode 100755 src/RenphoWeight.py create mode 100755 src/__init__.py create mode 100644 src/__pycache__/RenphoWeight.cpython-310.pyc create mode 100644 src/__pycache__/const.cpython-310.pyc create mode 100755 src/const.py create mode 100755 src/sensor.py create mode 100644 src/tests.py delete mode 100644 testing.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..8fe91ed --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,42 @@ +# Description + +Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. + +Fixes # (issue) + +## Visual Support + +Please include a img ou a gif od th + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update + +# How Has This Been Tested? + +Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration + +- [ ] Test A +- [ ] Test B + +**Test Configuration**: +* Firmware version: +* Hardware: +* Toolchain: +* SDK: + +# Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules \ No newline at end of file diff --git a/.gitignore b/.gitignore index ee9c06b..9723a99 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,138 @@ -./__pycache -./testing.py \ No newline at end of file +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*.pyo +*.so + +# C extensions +*.c + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Custom +testing.py diff --git a/README.md b/README.md index 04c24f2..d52528d 100755 --- a/README.md +++ b/README.md @@ -1,42 +1,181 @@ -# Renpho Weight +# Renpho Weight Home Assistant Component -This is a custom component to import weight and last weight time from the Renpho app into Home Assistant. +![Version](https://img.shields.io/badge/version-0.2.0-blue) +![License](https://img.shields.io/badge/license-MIT-green) +![Build Status](https://img.shields.io/badge/build-passing-brightgreen) +![IoT Class](https://img.shields.io/badge/IoT%20Class-local_polling-yellow) -### Installation +## Overview -> :+1: Some things have changed, notably folder name (not sure if it makes a difference) and you no longer have to sniff the hash as I reverse engineered the password hashing! +This custom component allows you to integrate Renpho's weight scale data into Home Assistant. It fetches weight and various other health metrics and displays them as sensors in Home Assistant. +![Weight Sensor](docs/images/weight.png) +## Table of Contents +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [Configuration](#configuration) +- [Updates](#updates) +- [Roadmap](#roadmap) +- [License](#license) + + +## Prerequisites +1. You must have a Renpho account. If you don't have one, you can create one [here](https://renpho.com/). +2. You must have a Renpho scale. If you don't have one, you can purchase one [here](https://renpho.com/collections/body-fat-scale). +3. You must have the Renpho app installed on your mobile device. You can download it [here](https://play.google.com/store/apps/details?id=com.renpho.smart&hl=en_US&gl=US) for Android and [here](https://apps.apple.com/us/app/renpho/id1115563582) for iOS. +4. You must have Home Assistant installed and running. +5. You must have the [Home Assistant Community Store (HACS)](https://hacs.xyz/) installed and running. +6. Visual Studio Code is recommended for editing the files. + +## Installation +``` +git clone https://github.com/antoinebou12/hass_renpho +``` Copy this folder to `/custom_components/hass_renpho/`. +## Configuration Add the following entry in your `configuration.yaml`: ```yaml renpho: email: test@test.com # email address password: MySecurePassword # password + user_id: 123456789 # user id (optional) refresh: 600 # time to poll (ms) +``` +And then add the sensor platform: + +```yaml sensor: platform: renpho ``` -Your email address and password are what you would use to log into the app. +> :warning: Note: Refresh is the time in seconds to check for updates. Keep in mind that logging in will log you out of the app. -Refresh is the time in seconds to check for updates, bear in mind everytime you log in it logs you out of the app, so in my example it gives me ten minutes between checking in case I ever wish to browse the app. +Restart home assistant and you should see the sensors: -### Updates -Changed some of the naming conventions, so it might be best to just reinstall the component. +## Supported Metrics -I reversed engineered the apk and found the hashing/encryption methods for generating the hashes, this means we no longer have to sniff the hash and as such, making the component much more accessible. +### General Information + +| Metric | Description | Data Type | Unit of Measurement | +|--------------|---------------------------------------------|-----------|---------------------| +| id | Unique identifier for the record | Numeric | N/A | +| b_user_id | Unique identifier for the user | Numeric | N/A | +| time_stamp | Unix timestamp for the record | Numeric | UNIX Time | +| created_at | Time the data was created | DateTime | N/A | +| created_stamp| Unix timestamp for when the data was created| Numeric | UNIX Time | + +### Device Information + +| Metric | Description | Data Type | Unit of Measurement | +|----------------|-----------------------------|-----------|---------------------| +| scale_type | Type of scale used | Numeric | N/A | +| scale_name | Name of the scale | String | N/A | +| mac | MAC address of the device | String | N/A | +| internal_model | Internal model code | String | N/A | +| time_zone | Time zone information | String | N/A | + +### User Profile + +| Metric | Description | Data Type | Unit of Measurement | +|--------------|------------------------------|-----------|---------------------| +| gender | Gender of the user | Numeric | N/A | +| height | Height of the user | Numeric | cm | +| height_unit | Unit for height | Numeric | N/A | +| birthday | Birth date of the user | Date | N/A | + +### Physical Metrics + +| Metric | Description | Data Type | Unit of Measurement | +|------------|------------------------------|-----------|---------------------| +| weight | Body weight | Numeric | kg | +| bmi | Body Mass Index | Numeric | N/A | +| muscle | Muscle mass | Numeric | % | +| bone | Bone mass | Numeric | % | +| waistline | Waistline size | Numeric | cm | +| hip | Hip size | Numeric | cm | +| stature | Stature information | Numeric | cm | + +### Body Composition + +| Metric | Description | Data Type | Unit of Measurement | +|----------|------------------------------|-----------|---------------------| +| bodyfat | Body fat percentage | Numeric | % | +| water | Water content in the body | Numeric | % | +| subfat | Subcutaneous fat | Numeric | % | +| visfat | Visceral fat level | Numeric | Level | + +### Metabolic Metrics + +| Metric | Description | Data Type | Unit of Measurement | +|----------|------------------------------|-----------|---------------------| +| bmr | Basal Metabolic Rate | Numeric | kcal/day | +| protein | Protein content in the body | Numeric | % | + +### Age Metrics + +| Metric | Description | Data Type | Unit of Measurement | +|----------|------------------------------|-----------|---------------------| +| bodyage | Estimated biological age | Numeric | Years | + +Certainly, you can expand the existing table to include the "Unit of Measurement" column for each metric. Here's how you can continue to organize the metrics into categories, similar to your previous table, but now with the added units: + +### Electrical Measurements (not sure if this is the correct name) + +| Metric | Description | Data Type | Unit of Measurement | +|------------------------|-------------------------------------|-----------|---------------------| +| resistance | Electrical resistance | Numeric | Ohms | +| sec_resistance | Secondary electrical resistance | Numeric | Ohms | +| actual_resistance | Actual electrical resistance | Numeric | Ohms | +| actual_sec_resistance | Actual secondary electrical resistance| Numeric | Ohms | +| resistance20_left_arm | Resistance20 in the left arm | Numeric | Ohms | +| resistance20_left_leg | Resistance20 in the left leg | Numeric | Ohms | +| resistance20_right_leg | Resistance20 in the right leg | Numeric | Ohms | +| resistance20_right_arm | Resistance20 in the right arm | Numeric | Ohms | +| resistance20_trunk | Resistance20 in the trunk | Numeric | Ohms | +| resistance100_left_arm | Resistance100 in the left arm | Numeric | Ohms | +| resistance100_left_leg | Resistance100 in the left leg | Numeric | Ohms | +| resistance100_right_arm| Resistance100 in the right arm | Numeric | Ohms | +| resistance100_right_leg| Resistance100 in the right leg | Numeric | Ohms | +| resistance100_trunk | Resistance100 in the trunk | Numeric | Ohms | + +### Cardiovascular Metrics + +| Metric | Description | Data Type | Unit of Measurement | +|-----------------|-------------------|-----------|---------------------| +| heart_rate | Heart rate | Numeric | bpm | +| cardiac_index | Cardiac index | Numeric | N/A | + +### Other Metrics + +| Metric | Description | Data Type | Unit of Measurement | +|-----------------|------------------------------------|-----------|---------------------| +| method | Method used for measurement | Numeric | N/A | +| sport_flag | Sports flag | Numeric | N/A | +| left_weight | Weight on the left side of the body| Numeric | kg | +| right_weight | Weight on the right side of the body| Numeric | kg | +| waistline | Waistline size | Numeric | cm | +| hip | Hip size | Numeric | cm | +| local_created_at| Local time the data was created | DateTime | N/A | +| time_zone | Time zone information | String | N/A | +| remark | Additional remarks | String | N/A | +| score | Health score | Numeric | N/A | +| pregnant_flag | Pregnancy flag | Numeric | N/A | +| stature | Stature information | Numeric | cm | +| category | Category identifier | Numeric | N/A | -### Roadmap -Some ideas as to where this is going. -1. Add all user information. My dream is something along the lines of adding the following to config: -```yaml -sensor: - platform: renpho - user: neilzilla # username, email, or something identifiable -``` -2. Finding a way to not log you out of the mobile app, although this might not be feasible. +## Roadmap +1. Add support for all user information. + ```yaml + sensor: + platform: renpho + user: your_username # username, email, or something identifiable + ``` +2. Find a way to prevent logging out from the mobile app upon every login from Home Assistant (if feasible). +## License +MIT License. See `LICENSE` for more information. +``` \ No newline at end of file diff --git a/RenphoWeight.py b/RenphoWeight.py deleted file mode 100755 index 2d08779..0000000 --- a/RenphoWeight.py +++ /dev/null @@ -1,98 +0,0 @@ -import requests -import json -import time -import datetime -from threading import Timer - -from Crypto.PublicKey import RSA -from Crypto.Cipher import PKCS1_v1_5 -from base64 import b64encode - -import logging -_LOGGER = logging.getLogger(__name__) - - -class Interval(Timer): - def run(self): - while not self.finished.wait(self.interval): - self.function(*self.args, **self.kwargs) - -class RenphoWeight(): - def __init__ (self, public_key, email, password): - _LOGGER.debug("Init RenphoWeight") - self.public_key = public_key - self.email = email - self.password = password - self.weight = None - self.time_stamp = None - - def auth(self): - try: - key = RSA.importKey(self.public_key) - cipher = PKCS1_v1_5.new(key) - newPassword = b64encode(cipher.encrypt(bytes(self.password, "utf-8"))) - data = { - 'secure_flag': 1, - 'email': self.email, - 'password': newPassword - } - - r = requests.post(url = 'https://renpho.qnclouds.com/api/v3/users/sign_in.json?app_id=Renpho', data = data) - - parsed = json.loads(r.text) - self.session_key = parsed['terminal_user_session_key'] - - return parsed - except Exception as e: - _LOGGER.error("Error authenticating: " + str(e)) - - - def getScaleUsers(self): - try: - r = requests.get(url = 'https://renpho.qnclouds.com/api/v3/scale_users/list_scale_user?locale=en&terminal_user_session_key=' + self.session_key) - parsed = json.loads(r.text) - - if not len(parsed['scale_users']): - _LOGGER.error("No users set up on scale") - - self.user_id = parsed['scale_users'][0]['user_id'] - return parsed['scale_users'] - - except Exception as e: - _LOGGER.error("Error getting scale users: " + str(e)) - - def getMeasurements(self): - try: - today = datetime.date.today() - week_ago = today - datetime.timedelta(days=7) - week_ago = int(time.mktime(week_ago.timetuple())) - - r = requests.get('https://renpho.qnclouds.com/api/v2/measurements/list.json?user_id=' + self.user_id + '&last_at=' + str(week_ago) + '&locale=en&app_id=Renpho&terminal_user_session_key=' + self.session_key) - - measurements = json.loads(r.text) - - last = measurements['last_ary'][0] - - self.weight = last['weight'] - self.time_stamp = last['time_stamp'] - - return json.loads(r.text)['last_ary'] - except Exception as e: - _LOGGER.error("Error getting measurements: " + str(e)) - - def getInfo(self): - try: - self.auth() - self.getScaleUsers() - self.getMeasurements() - except Exception as e: - _LOGGER.error("Error polling: " + str(e)) - - def startPolling(self, polling_interval=60): - self.getInfo() - self.polling = Interval(polling_interval, self.getInfo) - self.polling.start() - - def stopPolling(self): - if self.polling: - self.polling.cancel() diff --git a/__init__.py b/__init__.py old mode 100755 new mode 100644 index f3969fe..04a1f05 --- a/__init__.py +++ b/__init__.py @@ -1,30 +1,23 @@ -"""Init Renpho sensor.""" -from .const import DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY -from .RenphoWeight import RenphoWeight -from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -import logging +from src.__init__ import setup -_LOGGER = logging.getLogger(__name__) def setup(hass, config): - - _LOGGER.debug("Starting hass-renpho") - - conf = config[DOMAIN] - email = conf[CONF_EMAIL] - password = conf[CONF_PASSWORD] - refresh = conf[CONF_REFRESH] - - renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password) - - def cleanup(event): - renpho.stopPolling() - - def prepare(event): - renpho.startPolling(refresh) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare) - hass.data[DOMAIN] = renpho - - return True \ No newline at end of file + return src.setup(hass, config) + +if __name__ == "__main__": + # This code is executed when running this file directly + # It is used for testing purposes + import sys + import os + sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + from src.RenphoWeight import RenphoWeight + from src.const import CONF_PUBLIC_KEY + import logging + logging.basicConfig(level=logging.DEBUG) + renpho = RenphoWeight(CONF_PUBLIC_KEY, '', '') + renpho.startPolling(10) + print(renpho.getScaleUsers()) + print(renpho.getSpecificMetricFromUserID("bodyfat", "")) + print(renpho.getInfo()) + input("Press Enter to stop polling") + renpho.stopPolling() \ No newline at end of file diff --git a/const.py b/const.py deleted file mode 100755 index 1343c71..0000000 --- a/const.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Constants for constants sake""" - -DOMAIN = "renpho" - -CONF_EMAIL = 'email' -CONF_PASSWORD = 'password' -CONF_REFRESH = 'refresh' - -CONF_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC+25I2upukpfQ7rIaaTZtVE744\nu2zV+HaagrUhDOTq8fMVf9yFQvEZh2/HKxFudUxP0dXUa8F6X4XmWumHdQnum3zm\nJr04fz2b2WCcN0ta/rbF2nYAnMVAk2OJVZAMudOiMWhcxV1nNJiKgTNNr13de0EQ\nIiOL2CUBzu+HmIfUbQIDAQAB\n-----END PUBLIC KEY-----' \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..46f18ff --- /dev/null +++ b/docs/README.md @@ -0,0 +1,352 @@ +# Renpho Weight Scale Integration for Home Assistant + +[![Python](https://img.shields.io/badge/python-3.8%2B-blue)](https://www.python.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) + +> This project is inspired by the original Renpho integration. Check it out [here](https://github.com/neilzilla/hass-renpho/tree/master) +> Download the [Renpho Android App](https://play.google.com/store/apps/details?id=com.qingniu.renpho&hl=en&gl=US). + +## Overview + +This custom component allows you to integrate Renpho's weight scale data into Home Assistant. It fetches weight and various other health metrics and displays them as sensors in Home Assistant. + +## Table of Contents +- [Prerequisites](#prerequisites) +- [File Structure](#file-structure) +- [Supported Metrics](#supported-metrics) +- [Installation](#installation) +- [Configuration](#configuration) +- [API Documentation](#api-documentation) +- [API endpoints](#api-endpoints) + + +## Prerequisites +1. You must have a Renpho account. If you don't have one, you can create one [here](https://renpho.com/). +2. You must have a Renpho scale. If you don't have one, you can purchase one [here](https://renpho.com/collections/body-fat-scale). +3. You must have the Renpho app installed on your mobile device. You can download it [here](https://play.google.com/store/apps/details?id=com.renpho.smart&hl=en_US&gl=US) for Android and [here](https://apps.apple.com/us/app/renpho/id1115563582) for iOS. +4. You must have Home Assistant installed and running. +5. You must have the [Home Assistant Community Store (HACS)](https://hacs.xyz/) installed and running. +6. Visual Studio Code is recommended for editing the files. + +## File Structure + +The following shows the organization of the project's files and directories: + +``` +. +├── .gitignore # To ignore files that should not be committed +├── README.md # Project overview and setup guide +├── SECURITY.md # Security policy +├── __init__.py # Main file to initialize the component +├── docs +│ └── README.md # Detailed documentation +├── example +│ ├── configuration.yaml # Example Home Assistant configuration +│ └── lovelace.yaml # Example Lovelace UI configuration +├── manifest.json # Information about the component +├── requirements.txt # List of Python packages required +└── src + ├── RenphoWeight.py # Core logic for the Renpho weight scale + ├── __init__.py # Initialization within src folder + ├── const.py # Constants used in the component + ├── sensor.py # Sensor-related code + └── tests.py # Unit tests +``` + +## Supported Metrics + +### General Information + +| Metric | Description | Data Type | Unit of Measurement | +|--------------|---------------------------------------------|-----------|---------------------| +| id | Unique identifier for the record | Numeric | N/A | +| b_user_id | Unique identifier for the user | Numeric | N/A | +| time_stamp | Unix timestamp for the record | Numeric | UNIX Time | +| created_at | Time the data was created | DateTime | N/A | +| created_stamp| Unix timestamp for when the data was created| Numeric | UNIX Time | + +### Device Information + +| Metric | Description | Data Type | Unit of Measurement | +|----------------|-----------------------------|-----------|---------------------| +| scale_type | Type of scale used | Numeric | N/A | +| scale_name | Name of the scale | String | N/A | +| mac | MAC address of the device | String | N/A | +| internal_model | Internal model code | String | N/A | +| time_zone | Time zone information | String | N/A | + +### User Profile + +| Metric | Description | Data Type | Unit of Measurement | +|--------------|------------------------------|-----------|---------------------| +| gender | Gender of the user | Numeric | N/A | +| height | Height of the user | Numeric | cm | +| height_unit | Unit for height | Numeric | N/A | +| birthday | Birth date of the user | Date | N/A | + +### Physical Metrics + +| Metric | Description | Data Type | Unit of Measurement | +|------------|------------------------------|-----------|---------------------| +| weight | Body weight | Numeric | kg | +| bmi | Body Mass Index | Numeric | N/A | +| muscle | Muscle mass | Numeric | % | +| bone | Bone mass | Numeric | % | +| waistline | Waistline size | Numeric | cm | +| hip | Hip size | Numeric | cm | +| stature | Stature information | Numeric | cm | + +### Body Composition + +| Metric | Description | Data Type | Unit of Measurement | +|----------|------------------------------|-----------|---------------------| +| bodyfat | Body fat percentage | Numeric | % | +| water | Water content in the body | Numeric | % | +| subfat | Subcutaneous fat | Numeric | % | +| visfat | Visceral fat level | Numeric | Level | + +### Metabolic Metrics + +| Metric | Description | Data Type | Unit of Measurement | +|----------|------------------------------|-----------|---------------------| +| bmr | Basal Metabolic Rate | Numeric | kcal/day | +| protein | Protein content in the body | Numeric | % | + +### Age Metrics + +| Metric | Description | Data Type | Unit of Measurement | +|----------|------------------------------|-----------|---------------------| +| bodyage | Estimated biological age | Numeric | Years | + +Certainly, you can expand the existing table to include the "Unit of Measurement" column for each metric. Here's how you can continue to organize the metrics into categories, similar to your previous table, but now with the added units: + +### Electrical Measurements (not sure if this is the correct name) + +| Metric | Description | Data Type | Unit of Measurement | +|------------------------|-------------------------------------|-----------|---------------------| +| resistance | Electrical resistance | Numeric | Ohms | +| sec_resistance | Secondary electrical resistance | Numeric | Ohms | +| actual_resistance | Actual electrical resistance | Numeric | Ohms | +| actual_sec_resistance | Actual secondary electrical resistance| Numeric | Ohms | +| resistance20_left_arm | Resistance20 in the left arm | Numeric | Ohms | +| resistance20_left_leg | Resistance20 in the left leg | Numeric | Ohms | +| resistance20_right_leg | Resistance20 in the right leg | Numeric | Ohms | +| resistance20_right_arm | Resistance20 in the right arm | Numeric | Ohms | +| resistance20_trunk | Resistance20 in the trunk | Numeric | Ohms | +| resistance100_left_arm | Resistance100 in the left arm | Numeric | Ohms | +| resistance100_left_leg | Resistance100 in the left leg | Numeric | Ohms | +| resistance100_right_arm| Resistance100 in the right arm | Numeric | Ohms | +| resistance100_right_leg| Resistance100 in the right leg | Numeric | Ohms | +| resistance100_trunk | Resistance100 in the trunk | Numeric | Ohms | + +### Cardiovascular Metrics + +| Metric | Description | Data Type | Unit of Measurement | +|-----------------|-------------------|-----------|---------------------| +| heart_rate | Heart rate | Numeric | bpm | +| cardiac_index | Cardiac index | Numeric | N/A | + +### Other Metrics + +| Metric | Description | Data Type | Unit of Measurement | +|-----------------|------------------------------------|-----------|---------------------| +| method | Method used for measurement | Numeric | N/A | +| sport_flag | Sports flag | Numeric | N/A | +| left_weight | Weight on the left side of the body| Numeric | kg | +| right_weight | Weight on the right side of the body| Numeric | kg | +| waistline | Waistline size | Numeric | cm | +| hip | Hip size | Numeric | cm | +| local_created_at| Local time the data was created | DateTime | N/A | +| time_zone | Time zone information | String | N/A | +| remark | Additional remarks | String | N/A | +| score | Health score | Numeric | N/A | +| pregnant_flag | Pregnancy flag | Numeric | N/A | +| stature | Stature information | Numeric | cm | +| category | Category identifier | Numeric | N/A | + +## Installation + +1. Copy this folder to `/custom_components/hass_renpho/`. +2. Add the necessary configuration to your `configuration.yaml` file. + +## Configuration + +Add the following entry in your `configuration.yaml`: + +```yaml +renpho: + email: your_email@example.com # email address + password: YourSecurePassword # password + refresh: 600 # time to poll (ms) + user_id: 123456789 # user ID (optional) +``` + +Then add the sensor platform: + +```yaml +sensor: + platform: renpho +``` + +## API Documentation + +The `RenphoWeight` class is the core of this integration, providing methods to interact with the Renpho API. Below are detailed explanations of the methods available in this class. + +### `auth()` + +#### Description +Authenticates the user with the Renpho API and fetches a session key. The session key is stored within the class and is used for subsequent API calls. + +#### Parameters +None + +#### Returns +- `dict`: Parsed JSON response from the API containing the session key and other authentication details. + +### `getScaleUsers()` + +#### Description +Fetches the list of users associated with the Renpho scale. + +#### Parameters +None + +#### Returns +- `list`: A list of dictionaries, each containing details of a user (e.g., user ID, name). + +### `getMeasurements()` + +#### Description +Retrieves the latest weight measurements for the user specified by `user_id`. This method updates the `weight` and `time_stamp` attributes of the class. + +#### Parameters +None + +#### Returns +- `list`: A list of dictionaries containing the latest measurements. + +### `getSpecificMetric(metric)` + +#### Description +Retrieves a specific metric from the most recent weight measurement. + +#### Parameters +- `metric (str)`: The specific metric to retrieve (e.g., 'weight', 'bmi'). + +#### Returns +- `float`: The value of the specified metric. +- `None`: If the metric is not found. + +### `getSpecificMetricFromUserID(metric, user_id=None)` + +#### Description +Retrieves a specific metric for a particular user ID from the most recent weight measurement. + +#### Parameters +- `metric (str)`: The metric to fetch (e.g., 'bodyfat', 'water'). +- `user_id (str, optional)`: The user ID for whom the metric should be fetched. Defaults to the object's `user_id` if not provided. + +#### Returns +- `float`: Value of the specified metric. +- `None`: If an error occurs or the metric is not found. + +### `startPolling(polling_interval=60)` + +#### Description +Starts polling for weight data at a given interval. The polling will automatically call `getMeasurements()` at the specified interval. + +#### Parameters +- `polling_interval (int)`: Time in seconds between each polling call. Defaults to 60 seconds. + +#### Returns +None + +### `stopPolling()` + +#### Description +Stops the ongoing polling for weight data. + +#### Parameters +None + +#### Returns +None + + +## API endpoints + +This document describes the API endpoints and methods utilized by the `RenphoWeight` class in the Home Assistant custom component for Renpho weight scales. It outlines the endpoints, expected parameters, and returned data. + +--- + +## Authentication + +### API_AUTH_URL + +#### Endpoint +`https://renpho.qnclouds.com/api/v3/users/sign_in.json?app_id=Renpho` + +#### HTTP Method +`POST` + +#### Parameters +- `app_id`: Application identifier (fixed as "Renpho"). +- `email`: User's email address for Renpho account. +- `password`: Encrypted password for the Renpho account. + +#### Returns +JSON payload containing: +- `session_key`: Session key for future API calls. + +#### Usage in Code +This URL is used in the `auth()` method to authenticate the user and fetch the session key. + +--- + +## User Information + +### API_SCALE_USERS_URL + +#### Endpoint +`https://renpho.qnclouds.com/api/v3/scale_users/list_scale_user` + +#### HTTP Method +`GET` + +#### Parameters +- `locale`: Language/locale setting, usually "en". +- `terminal_user_session_key`: Session key obtained from authentication. + +#### Returns +JSON payload containing: +- `users`: Array of user objects containing user details like user ID, scale user ID, MAC address, and more. + +#### Usage in Code +This URL is used in the `getScaleUsers()` method to fetch the list of users associated with the scale. + +--- + +## Measurements + +### API_MEASUREMENTS_URL + +#### Endpoint +`https://renpho.qnclouds.com/api/v2/measurements/list.json` + +#### HTTP Method +`GET` + +#### Parameters +- `user_id`: (Optional) User ID for fetching weight data. +- `last_at`: Unix timestamp for the oldest data to fetch. +- `locale`: Language/locale setting, usually "en". +- `terminal_user_session_key`: Session key obtained from authentication. + +#### Returns +JSON payload containing: +- `last_ary`: Array of most recent measurements for the user. + +#### Usage in Code +This URL is used in the `getMeasurements()` method to fetch the most recent weight measurements for the user. + + diff --git a/docs/hacs/dev.md b/docs/hacs/dev.md new file mode 100644 index 0000000..a364257 --- /dev/null +++ b/docs/hacs/dev.md @@ -0,0 +1,43 @@ +### Steps for Adding HACS Integration + +#### 1. Prepare your repository + +Make sure your GitHub repository meets the following requirements: + +- It must contain a `.hacs.json` file with metadata for the HACS frontend. The file should look something like this: + + ```json + { + "name": "Renpho Weight Scale Integration", + "domains": ["sensor"], + "documentation": "https://github.com/YOUR_USERNAME/YOUR_REPOSITORY/blob/main/README.md", + "codeowners": ["@YOUR_USERNAME"], + "icon": "https://raw.githubusercontent.com/YOUR_USERNAME/YOUR_REPOSITORY/main/icon.png", + "country": ["us"] + } + ``` +- The repository must contain a `README.md` file. +- You must tag a release. The tag should be a version number and the release should include a changelog. + +#### 2. Add Custom Repository to HACS + +Before your repository is listed in the HACS default store, users can manually add it to their HACS installation by following these steps: + +1. Open Home Assistant and navigate to HACS. +2. Click on "Integrations." +3. Click on the three dots in the top right corner and choose "Custom repositories." +4. Paste the URL of your GitHub repository and select "Integration" as the category. + +#### 3. Make a Pull Request to HACS Default Repositories + +To make your repository available to everyone by default: + +1. Fork the [hacs/default](https://github.com/hacs/default) repository. +2. Add your repository to the `integration` list in the `repositories.json` file. +3. Create a Pull Request. + +Your Pull Request will be reviewed, and if it meets the criteria, it will be merged, making your integration available to everyone using HACS by default. + +#### 4. Update README + +Update your README.md file to include instructions on how to install the component via HACS. You can include a HACS badge to show that it's available through HACS. \ No newline at end of file diff --git a/docs/images/weight.png b/docs/images/weight.png new file mode 100644 index 0000000000000000000000000000000000000000..fc76301a7265c9a8c8b4929575490ec6b47cb71a GIT binary patch literal 16726 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.3.1"], + "codeowners": ["@antoinebou12"], + "requirements": ["pycryptodome>=3.3.1", "requests"], "iot_class": "local_polling", - "version": "0.2.0" + "version": "1.0.0" } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5ae6ab1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pycryptodome>=3.3.1 +pycrypto +requests==2.26.0 +pycrypto==2.6.1 \ No newline at end of file diff --git a/sensor.py b/sensor.py deleted file mode 100755 index e634146..0000000 --- a/sensor.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Platform for sensor integration.""" -from __future__ import annotations - -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import MASS_KILOGRAMS, TIME_SECONDS -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN, CONF_EMAIL, CONF_PASSWORD - - - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None -) -> None: - """Set up the sensor platform.""" - - renpho = hass.data[DOMAIN] - - add_entities([WeightSensor(renpho), TimeSensor(renpho)]) - - -class WeightSensor(SensorEntity): - """Representation of a sensor.""" - - def __init__(self, renpho) -> None: - """Initialize the sensor.""" - self._renpho = renpho - self._state = None - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return 'Renpho Weight' - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement.""" - return MASS_KILOGRAMS - - def update(self) -> None: - """Fetch new state data for the sensor. - This is the only method that should fetch new data for Home Assistant. - """ - self._state = self._renpho.weight - -class TimeSensor(SensorEntity): - """Representation of a sensor.""" - - def __init__(self, renpho) -> None: - """Initialize the sensor.""" - self._renpho = renpho - self._state = None - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return 'Renpho Last Weighed Timestamp' - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement.""" - return TIME_SECONDS - - def update(self) -> None: - """Fetch new state data for the sensor. - This is the only method that should fetch new data for Home Assistant. - """ - self._state = self._renpho.time_stamp \ No newline at end of file diff --git a/src/RenphoWeight.py b/src/RenphoWeight.py new file mode 100755 index 0000000..1cb0fd4 --- /dev/null +++ b/src/RenphoWeight.py @@ -0,0 +1,179 @@ +import requests +import json +import time +import datetime +from threading import Timer +from Crypto.PublicKey import RSA +from Crypto.Cipher import PKCS1_v1_5 +from base64 import b64encode +import logging + +# Initialize logging +_LOGGER = logging.getLogger(__name__) + +# API Endpoints +API_AUTH_URL = 'https://renpho.qnclouds.com/api/v3/users/sign_in.json?app_id=Renpho' +API_SCALE_USERS_URL = 'https://renpho.qnclouds.com/api/v3/scale_users/list_scale_user' +API_MEASUREMENTS_URL = 'https://renpho.qnclouds.com/api/v2/measurements/list.json' + + +class RenphoWeight: + """ + A class to interact with Renpho's weight scale API. + + Attributes: + public_key (str): The public RSA key used for encrypting the password. + email (str): The email address for the Renpho account. + password (str): The password for the Renpho account. + user_id (str, optional): The ID of the user for whom weight data should be fetched. + weight (float): The most recent weight measurement. + time_stamp (int): The timestamp of the most recent weight measurement. + session_key (str): The session key obtained after successful authentication. + """ + + def __init__(self, public_key, email, password, user_id=None): + """ + Initialize a new RenphoWeight instance. + """ + _LOGGER.debug("Init RenphoWeight") + self.public_key = public_key + self.email = email + self.password = password + self.user_id = user_id + self.weight = None + self.time_stamp = None + self.session_key = None # Initialize session_key + + def _request(self, method, url, **kwargs): + """ + Make a generic API request and handle errors. + """ + try: + response = requests.request(method, url, **kwargs) + response.raise_for_status() + return response.json() + except Exception as e: + _LOGGER.error(f"Error in request: {e}") + raise # Or raise a custom exception + + def auth(self): + """ + Authenticate with the Renpho API to obtain a session key. + """ + # Encrypt the password using RSA encryption + key = RSA.importKey(self.public_key) + cipher = PKCS1_v1_5.new(key) + encrypted_password = b64encode( + cipher.encrypt(self.password.encode("utf-8"))) + + # Make the authentication request + data = {'secure_flag': 1, 'email': self.email, + 'password': encrypted_password} + parsed = self._request('POST', API_AUTH_URL, data=data) + + # Store the session key + self.session_key = parsed['terminal_user_session_key'] + return parsed + + def getScaleUsers(self): + """ + Fetch the list of users associated with the scale. + """ + url = f"{API_SCALE_USERS_URL}?locale=en&terminal_user_session_key={self.session_key}" + parsed = self._request('GET', url) + self.set_user_id(parsed['scale_users'][0]['user_id']) + return parsed['scale_users'] + + def getMeasurements(self): + """ + Fetch the most recent weight measurements for the user. + """ + today = datetime.date.today() + week_ago = today - datetime.timedelta(days=7) + week_ago_timestamp = int(time.mktime(week_ago.timetuple())) + url = f"{API_MEASUREMENTS_URL}?user_id={self.user_id}&last_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + parsed = self._request('GET', url) + last_measurement = parsed['last_ary'][0] + self.weight = last_measurement['weight'] + self.time_stamp = last_measurement['time_stamp'] + return parsed['last_ary'] + + def getSpecificMetric(self, metric): + """ + Fetch a specific metric from the most recent weight measurement. + """ + last_measurement = self.getMeasurements()[0] + # Return None if metric not found + return last_measurement.get(metric, None) + + def set_user_id(self, user_id): + """ + Set the user ID for whom the weight data should be fetched. + + Args: + user_id (str): The new user ID. + """ + self.user_id = user_id + + def get_user_id(self): + """ + Get the current user ID for whom the weight data is being fetched. + + Returns: + str: The current user ID. + """ + return self.user_id + + def getSpecificMetricFromUserID(self, metric, user_id=None): + """ + Fetch a specific metric for a particular user ID from the most recent weight measurement. + + Args: + metric (str): The metric to fetch (e.g., 'bodyfat', 'water', 'bmr'). + user_id (str, optional): The user ID for whom the metric should be fetched. + Defaults to the object's user_id if not provided. + + Returns: + float: Value of the specified metric, None if an error occurs or metric not found. + """ + if user_id: + self.set_user_id(user_id) # Update the user_id if provided + + last_measurement = self.getMeasurements()[0] + return last_measurement.get(metric, None) # Return None if metric not found + + def getInfo(self): + """ + Wrapper method to authenticate, fetch users, and get measurements. + """ + self.auth() + self.getScaleUsers() + self.getMeasurements() + + def startPolling(self, polling_interval=60): + """ + Start polling for weight data at a given interval. + """ + self.getInfo() + self.polling = Interval(polling_interval, self.getInfo) + self.polling.start() + + def stopPolling(self): + """ + Stop polling for weight data. + """ + if hasattr(self, 'polling'): + self.polling.cancel() + + +class Interval(Timer): + """ + A subclass of Timer to repeatedly run a function at a specified interval. + """ + + def run(self): + """ + Run the function at the given interval. + """ + while not self.finished.wait(self.interval): + self.function(*self.args, **self.kwargs) diff --git a/src/__init__.py b/src/__init__.py new file mode 100755 index 0000000..d6f38d6 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,51 @@ +"""Initialization for the Renpho sensor component.""" + +# Import necessary modules and classes +from src.const import CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +from src.RenphoWeight import RenphoWeight +import logging + +# Initialize logger +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """ + Set up the Renpho component. + + Args: + hass (HomeAssistant): Home Assistant core object. + config (dict): Configuration for the component. + + Returns: + bool: True if initialization was successful, False otherwise. + """ + + _LOGGER.debug("Starting hass-renpho") + + # Extract configuration values + conf = config[DOMAIN] + email = conf[CONF_EMAIL] + password = conf[CONF_PASSWORD] + user_id = conf[CONF_USER_ID] + refresh = conf[CONF_REFRESH] + + # Create an instance of RenphoWeight + renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) + + # Define a cleanup function to stop polling when Home Assistant stops + def cleanup(event): + renpho.stopPolling() + + # Define a prepare function to start polling when Home Assistant starts + def prepare(event): + renpho.startPolling(refresh) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + + # Register the prepare function to be called when Home Assistant starts + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare) + + # Store the Renpho instance in Home Assistant's data dictionary + hass.data[DOMAIN] = renpho + + return True # Initialization was successful \ No newline at end of file diff --git a/src/__pycache__/RenphoWeight.cpython-310.pyc b/src/__pycache__/RenphoWeight.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..377a7874fe6fe2a5bde61dffda38590177f9f564 GIT binary patch literal 6528 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 literal 0 HcmV?d00001 diff --git a/src/__pycache__/const.cpython-310.pyc b/src/__pycache__/const.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0193778602ba7d2f224509ffa6124eceb54d5c39 GIT binary patch literal 1157 zcmZvb%Wm676hKMIdeM?)J8_*_<3>Z^p#6UE&iiwN$u9i0ltN6%$wlbSYW;bzKb`R-vPgi>&*AS@8JgX``{1o7W0STk8qRuWAKmgHuI0c zKfyc9KLy`6xr5LChFZ7-o|O7Qn6H69_fQ&P7S6*(sA>6&bkk{`OkiU`o_I|wQtlj<7E{w_{D z(qPdOc*Utckx3=-=ZT*#Nu9+fs6RpG5O3sBkdYdT*_vXJN}l?O6L|lQvrRN{6nSy< z#}B_Xf8<}0+GyZdx-;&Yi|>DV`?D{;{RS4G(wL(4hHjJRxF@uA(LmAmPwehk>Ftlu zYZeq#WiVHz_Mm6J8JNoXk!+x@WZ06_8EBH+>juWCyD)l(;ungTuk(xb+<2ZSu500J zik^2mLM~oU4~MS1NbIGoTW?;>)#?1JtG@9q!b`C=99{Nv&%QiqduO)$qW|St=PW$U z!=YzHd3bak3dc#iGrtzk#nawQYiDjNIq!?nYbjEv(uJrWPqC!to*t;D%h~1hS)?5Y zqlKku$+IKRZzDrc0(~s@?Cy1bI1H7!eQqeSWJq0sRfshCX3?biek<6gaNtvD2fL)4 zU9E#?krdz{C-C2Lr0Fo%v~N2`9yQ|KqgMWY(I&tl87`6{)@qV=rI%U&B^%={{hq SSTEKKb^gOu{9q8>F8%{*=RX<% literal 0 HcmV?d00001 diff --git a/src/const.py b/src/const.py new file mode 100755 index 0000000..ebbaced --- /dev/null +++ b/src/const.py @@ -0,0 +1,32 @@ +# Constants for the Renpho integration + +# The domain of the component. Used to store data in hass.data. +from typing import Final + + +DOMAIN: Final = "renpho" + +EVENT_HOMEASSISTANT_CLOSE: Final = "homeassistant_close" +EVENT_HOMEASSISTANT_START: Final = "homeassistant_start" +EVENT_HOMEASSISTANT_STARTED: Final = "homeassistant_started" +EVENT_HOMEASSISTANT_STOP: Final = "homeassistant_stop" +MASS_KILOGRAMS: Final = "kg" +TIME_SECONDS: Final = "s" + +# Configuration keys +CONF_EMAIL: Final = 'email' # The email used for Renpho login +CONF_PASSWORD: Final = 'password' # The password used for Renpho login +CONF_REFRESH: Final = 'refresh' # Refresh rate for pulling new data +CONF_UNIT: Final = 'unit' # Unit of measurement for weight (kg/lbs) +CONF_USER_ID: Final = 'user_id' # The ID of the user for whom weight data should be fetched + +KG_TO_LBS: Final = 2.20462 +CM_TO_INCH: Final = 0.393701 + +# Public key for encrypting the password +CONF_PUBLIC_KEY: Final = '''-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC+25I2upukpfQ7rIaaTZtVE744 +u2zV+HaagrUhDOTq8fMVf9yFQvEZh2/HKxFudUxP0dXUa8F6X4XmWumHdQnum3zm +Jr04fz2b2WCcN0ta/rbF2nYAnMVAk2OJVZAMudOiMWhcxV1nNJiKgTNNr13de0EQ +IiOL2CUBzu+HmIfUbQIDAQAB +-----END PUBLIC KEY-----''' diff --git a/src/sensor.py b/src/sensor.py new file mode 100755 index 0000000..0f72137 --- /dev/null +++ b/src/sensor.py @@ -0,0 +1,379 @@ +"""Platform for sensor integration.""" +from __future__ import annotations +from datetime import datetime + +from homeassistant.components.sensor import SensorEntity + +from homeassistant.const import MASS_KILOGRAMS, TIME_SECONDS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from src.RenphoWeight import _LOGGER +from src.const import CM_TO_INCH, DOMAIN, CONF_EMAIL, CONF_PASSWORD, KG_TO_LBS + + +# Existing setup_platform function +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +) -> None: + """Set up the sensor platform.""" + + renpho = hass.data[DOMAIN] + + # Adding entities with categories and labels + add_entities( + [ + # Measurements - Physical Metrics + RenphoSensor( + renpho, + "weight", + "Weight", + MASS_KILOGRAMS, + category="Measurements", + label="Physical Metrics", + ), + RenphoSensor( + renpho, + "bmi", + "BMI", + "", + category="Measurements", + label="Physical Metrics", + ), + RenphoSensor( + renpho, + "muscle", + "Muscle Mass", + "%", + category="Measurements", + label="Physical Metrics", + ), + RenphoSensor( + renpho, + "bone", + "Bone Mass", + "%", + category="Measurements", + label="Physical Metrics", + ), + RenphoSensor( + renpho, + "waistline", + "Waistline", + "cm", + category="Measurements", + label="Physical Metrics", + ), + RenphoSensor( + renpho, + "hip", + "Hip", + "cm", + category="Measurements", + label="Physical Metrics", + ), + RenphoSensor( + renpho, + "stature", + "Stature", + "cm", + category="Measurements", + label="Physical Metrics", + ), + # Measurements - Body Composition + RenphoSensor( + renpho, + "bodyfat", + "Body Fat", + "%", + category="Measurements", + label="Body Composition", + ), + RenphoSensor( + renpho, + "water", + "Water Content", + "%", + category="Measurements", + label="Body Composition", + ), + RenphoSensor( + renpho, + "subfat", + "Subcutaneous Fat", + "%", + category="Measurements", + label="Body Composition", + ), + RenphoSensor( + renpho, + "visfat", + "Visceral Fat", + "Level", + category="Measurements", + label="Body Composition", + ), + RenphoSensor( + renpho, + "bodyfat_left_arm", + "Body Fat Left Arm", + "%", + category="Measurements", + label="Body Composition", + ), + RenphoSensor( + renpho, + "bodyfat_right_arm", + "Body Fat Right Arm", + "%", + category="Measurements", + label="Body Composition", + ), + RenphoSensor( + renpho, + "bodyfat_left_leg", + "Body Fat Left Leg", + "%", + category="Measurements", + label="Body Composition", + ), + RenphoSensor( + renpho, + "bodyfat_right_leg", + "Body Fat Right Leg", + "%", + category="Measurements", + label="Body Composition", + ), + RenphoSensor( + renpho, + "bodyfat_trunk", + "Body Fat Trunk", + "%", + category="Measurements", + label="Body Composition", + ), + # Measurements - Metabolic Metrics + RenphoSensor( + renpho, + "bmr", + "BMR", + "kcal/day", + category="Measurements", + label="Metabolic Metrics", + ), + RenphoSensor( + renpho, + "protein", + "Protein Content", + "%", + category="Measurements", + label="Metabolic Metrics", + ), + # Measurements - Age Metrics + RenphoSensor( + renpho, + "bodyage", + "Body Age", + "Years", + category="Measurements", + label="Age Metrics", + ), + # Device Information + RenphoSensor( + renpho, + "mac", + "MAC Address", + "", + category="Device", + label="Device Information", + ), + RenphoSensor( + renpho, + "scale_type", + "Scale Type", + "", + category="Device", + label="Device Information", + ), + RenphoSensor( + renpho, + "scale_name", + "Scale Name", + "", + category="Device", + label="Device Information", + ), + RenphoSensor( + renpho, + "internal_model", + "Internal Model", + "", + category="Device", + label="Device Information", + ), + RenphoSensor( + renpho, + "time_zone", + "Time Zone", + "", + category="Device", + label="Device Information", + ), + # Miscellaneous + RenphoSensor( + renpho, + "method", + "Measurement Method", + "", + category="Miscellaneous", + label="Additional Metrics", + ), + RenphoSensor( + renpho, + "pregnant_flag", + "Pregnant Flag", + "", + category="Miscellaneous", + label="Additional Metrics", + ), + RenphoSensor( + renpho, + "sport_flag", + "Sport Flag", + "", + category="Miscellaneous", + label="Additional Metrics", + ), + RenphoSensor( + renpho, + "score", + "Score", + "", + category="Miscellaneous", + label="Additional Metrics", + ), + RenphoSensor( + renpho, + "remark", + "Remark", + "", + category="Miscellaneous", + label="Additional Metrics", + ), + RenphoSensor( + renpho, + "category", + "Category", + "", + category="Miscellaneous", + label="Additional Metrics", + ), + RenphoSensor( + renpho, + "category_type", + "Category Type", + "", + category="Miscellaneous", + label="Additional Metrics", + ), + RenphoSensor( + renpho, + "person_type", + "Person Type", + "", + category="Miscellaneous", + label="Additional Metrics", + ), + RenphoSensor( + renpho, + "height_unit", + "Height Unit", + "", + category="Miscellaneous", + label="Additional Metrics", + ), + RenphoSensor( + renpho, + "weight_unit", + "Weight Unit", + "", + category="Miscellaneous", + label="Additional Metrics", + ), + ] + ) + + +class RenphoSensor(SensorEntity): + def __init__( + self, renpho, metric, name, unit_of_measurement, category="Renpho", label="Data", convert_unit=False) -> None: + self._renpho = renpho + self._metric = metric + self._name = f"Renpho {name}" + self._unit_of_measurement = unit_of_measurement + self._category = category + self._label = label + self._state = None + self._convert_unit = convert_unit + self._timestamp = None + + # Conversion method for kg to lbs + def kg_to_lbs(self, kg): + return kg * KG_TO_LBS + + # Conversion method for cm to inch + def cm_to_inch(self, cm): + return cm * CM_TO_INCH + + @property + def name(self) -> str: + return self._name + + @property + def state(self): + """ Return the state of the sensor. + If the unit is kg or cm, convert to lbs or inch respectively. + + """ + if self._convert_unit: + if self._unit_of_measurement == MASS_KILOGRAMS: + return self.kg_to_lbs(self._state) + elif self._unit_of_measurement == "cm": + return self.cm_to_inch(self._state) + return self._state + + @property + def unit_of_measurement(self) -> str: + """ Return the unit of measurement. + If the unit is kg or cm, convert to lbs or inch respectively. + + """ + if self._convert_unit: + if self._unit_of_measurement == MASS_KILOGRAMS: + return "lbs" + elif self._unit_of_measurement == "cm": + return "inch" + return self._unit_of_measurement + + @property + def category(self) -> str: + """ Return the category of the sensor. """ + return self._category + + @property + def label(self) -> str: + """ Return the label of the sensor. """ + return self._label + + def update(self) -> None: + """ Update the sensor. """ + try: + self._state = self._renpho.getSpecificMetric(self._metric) + self._timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + except Exception as e: + _LOGGER.error(f"Error updating {self._name} sensor: {e}") diff --git a/src/tests.py b/src/tests.py new file mode 100644 index 0000000..994e026 --- /dev/null +++ b/src/tests.py @@ -0,0 +1,253 @@ +import unittest +from unittest.mock import Mock, patch +from Crypto.PublicKey import RSA +from Crypto.Cipher import PKCS1_v1_5 +from base64 import b64decode, b64encode + +import requests +from sensor import RenphoSensor, WeightSensor, TimeSensor, setup_platform +from RenphoWeight import RenphoWeight +from __init__ import setup + + +class TestEncryption(unittest.TestCase): + """Test cases for the encryption logic""" + + def setUp(self): + """Initial setup for test cases.""" + # Public key for testing encryption + self.public_key = '''-----BEGIN PUBLIC KEY----- + MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC+25I2upukpfQ7rIaaTZtVE744 + u2zV+HaagrUhDOTq8fMVf9yFQvEZh2/HKxFudUxP0dXUa8F6X4XmWumHdQnum3zm + Jr04fz2b2WCcN0ta/rbF2nYAnMVAk2OJVZAMudOiMWhcxV1nNJiKgTNNr13de0EQ + IiOL2CUBzu+HmIfUbQIDAQAB + -----END PUBLIC KEY-----''' + self.key = RSA.importKey(self.public_key) + self.cipher = PKCS1_v1_5.new(self.key) + + def test_encryption(self): + """Test the encryption logic.""" + test_password = "my_password" + encrypted_password = b64encode( + self.cipher.encrypt(bytes(test_password, 'utf-8'))) + + # For now, just check if encryption doesn't return the original password + self.assertNotEqual(test_password, encrypted_password.decode('utf-8')) + +class TestRenphoWeight(unittest.TestCase): + """Test cases for the RenphoWeight class""" + + def setUp(self): + """Initial setup for test cases.""" + self.mock_response = Mock() + self.renpho = RenphoWeight('public_key', 'test@email.com', 'password') + + @patch('requests.post') + def test_auth(self, mock_post): + """Test the authentication logic.""" + self.mock_response.json.return_value = { + 'terminal_user_session_key': 'session_key'} + mock_post.return_value = self.mock_response + + result = self.renpho.auth() + + self.assertEqual(result, {'terminal_user_session_key': 'session_key'}) + self.assertEqual(self.renpho.session_key, 'session_key') + + @patch('requests.get') + def test_getScaleUsers(self, mock_get): + """Test fetching the scale users.""" + self.mock_response.json.return_value = { + 'scale_users': [{'user_id': '1'}]} + mock_get.return_value = self.mock_response + + result = self.renpho.getScaleUsers() + + self.assertEqual(result, [{'user_id': '1'}]) + self.assertEqual(self.renpho.user_id, '1') + + @patch('requests.get') + def test_getMeasurements(self, mock_get): + """Test fetching the measurements.""" + self.mock_response.json.return_value = { + 'last_ary': [{'weight': 70, 'time_stamp': 1630886400}]} + mock_get.return_value = self.mock_response + + result = self.renpho.getMeasurements() + + self.assertEqual(result, [{'weight': 70, 'time_stamp': 1630886400}]) + self.assertEqual(self.renpho.weight, 70) + self.assertEqual(self.renpho.time_stamp, 1630886400) + + @patch('requests.post') + @patch('requests.get') + def test_getInfo(self, mock_get, mock_post): + """Test the wrapper method getInfo.""" + self.mock_response.json.side_effect = [ + {'terminal_user_session_key': 'session_key'}, + {'scale_users': [{'user_id': '1'}]}, + {'last_ary': [{'weight': 70, 'time_stamp': 1630886400}]} + ] + mock_get.return_value = self.mock_response + mock_post.return_value = self.mock_response + + self.renpho.getInfo() + + self.assertEqual(self.renpho.session_key, 'session_key') + self.assertEqual(self.renpho.user_id, '1') + self.assertEqual(self.renpho.weight, 70) + self.assertEqual(self.renpho.time_stamp, 1630886400) + + @patch('requests.post') + def test_failed_auth(self, mock_post): + """Test failed authentication logic.""" + self.mock_response.json.return_value = {'error': 'Invalid credentials'} + mock_post.return_value = self.mock_response + + result = self.renpho.auth() + + self.assertIsNone(result) + self.assertIsNone(self.renpho.session_key) + + @patch('requests.get') + def test_no_scale_users(self, mock_get): + """Test no scale users are returned.""" + self.mock_response.json.return_value = {'scale_users': []} + mock_get.return_value = self.mock_response + + result = self.renpho.getScaleUsers() + + self.assertEqual(result, []) + self.assertIsNone(self.renpho.user_id) + + @patch('requests.get') + def test_no_measurements(self, mock_get): + """Test no measurements are returned.""" + self.mock_response.json.return_value = {'last_ary': []} + mock_get.return_value = self.mock_response + + result = self.renpho.getMeasurements() + + self.assertEqual(result, []) + self.assertIsNone(self.renpho.weight) + self.assertIsNone(self.renpho.time_stamp) + + @patch('requests.get') + def test_get_specific_metric_not_found(self, mock_get): + """Test fetching a specific metric that does not exist.""" + mock_response = Mock() + mock_response.json.return_value = {'last_ary': [{'weight': 70, 'time_stamp': 1630886400}]} + mock_get.return_value = mock_response + + metric_value = self.renpho.getSpecificMetric("unknown_metric") + + self.assertIsNone(metric_value) + + def test_get_specific_metric_no_measurements(self): + """Test fetching a specific metric with no prior measurements.""" + self.renpho.time_stamp = None # Simulating no prior measurements + + metric_value = self.renpho.getSpecificMetric("bodyfat") + + self.assertIsNone(metric_value) + + @patch('requests.get') + def test_get_info_fail(self, mock_get): + """Test getInfo method failure.""" + mock_response = Mock() + mock_response.raise_for_status.side_effect = requests.HTTPError("Info fetch failed") + mock_get.return_value = mock_response + + with self.assertRaises(Exception): + self.renpho.getInfo() + +class TestRenphoSensor(unittest.TestCase): + + def setUp(self): + self.renpho = Mock() + self.renpho.getSpecificMetric.return_value = 75 # kg + self.sensor = RenphoSensor(self.renpho, "weight", "Weight", "kg") + + def test_name(self): + """Test name property.""" + self.assertEqual(self.sensor.name, "Renpho Weight") + + def test_state(self): + """Test state property.""" + self.sensor.update() + self.assertEqual(self.sensor.state, 75) + + def test_unit_of_measurement(self): + """Test unit_of_measurement property.""" + self.assertEqual(self.sensor.unit_of_measurement, "kg") + + def test_category(self): + """Test category property.""" + self.assertEqual(self.sensor.category, "Renpho") + + def test_label(self): + """Test label property.""" + self.assertEqual(self.sensor.label, "Data") + + def test_update_fail(self): + """Test update method failure.""" + self.renpho.getSpecificMetric.side_effect = Exception("API Error") + with self.assertRaises(Exception): + self.sensor.update() + +class TestSetupPlatform(unittest.TestCase): + + def setUp(self): + self.hass = Mock() + self.config = {} + self.add_entities = Mock() + + @patch('sensor.RenphoSensor') + def test_setup_platform(self, MockRenphoSensor): + renpho = Mock() + self.hass.data = {'renpho': renpho} + + setup_platform(self.hass, self.config, self.add_entities) + + self.add_entities.assert_called_once() # Check if entities were added + + + +class TestSetup(unittest.TestCase): + """Test cases for the setup function of the Renpho component.""" + + def setUp(self): + """Initial setup for test cases.""" + self.hass = Mock() # Mocked Home Assistant core object + self.config = { + DOMAIN: { + CONF_EMAIL: 'test@email.com', # Mock email + CONF_PASSWORD: 'password', # Mock password + CONF_REFRESH: 60 # Mock refresh rate + } + } + self.renpho = Mock() # Mocked RenphoWeight object + + # Replace with the actual import path + @patch('your_init_module.RenphoWeight') + def test_setup(self, MockRenphoWeight): + """Test the setup function.""" + # Mock the return value of RenphoWeight instantiation + MockRenphoWeight.return_value = self.renpho + + # Call the setup function with the mocked Home Assistant and configuration + result = setup(self.hass, self.config) + + # Check if setup was successful + self.assertTrue(result) + + # Check if EVENT_HOMEASSISTANT_START event is registered + self.hass.bus.listen_once.assert_called_with( + EVENT_HOMEASSISTANT_START, any) # Replace `any` with the actual function + + # Check if RenphoWeight object is stored in Home Assistant's data dictionary + self.assertEqual(self.hass.data[DOMAIN], self.renpho) + + +if __name__ == "__main__": + unittest.main() diff --git a/testing.py b/testing.py deleted file mode 100644 index ee9167e..0000000 --- a/testing.py +++ /dev/null @@ -1,10 +0,0 @@ -from Crypto.PublicKey import RSA -from Crypto.Cipher import PKCS1_v1_5 -from base64 import b64encode -import sys - -key = RSA.importKey('-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC+25I2upukpfQ7rIaaTZtVE744\nu2zV+HaagrUhDOTq8fMVf9yFQvEZh2/HKxFudUxP0dXUa8F6X4XmWumHdQnum3zm\nJr04fz2b2WCcN0ta/rbF2nYAnMVAk2OJVZAMudOiMWhcxV1nNJiKgTNNr13de0EQ\nIiOL2CUBzu+HmIfUbQIDAQAB\n-----END PUBLIC KEY-----') -cipher = PKCS1_v1_5.new(key) -newPassword = b64encode(cipher.encrypt(bytes(sys.argv[1]))) - -print(newPassword) \ No newline at end of file From 670d356ed16ed996ce925f127e3f56ce5f305b5b Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 16:25:51 -0400 Subject: [PATCH 05/56] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index d52528d..15d0d04 100755 --- a/README.md +++ b/README.md @@ -178,4 +178,3 @@ Certainly, you can expand the existing table to include the "Unit of Measurement ## License MIT License. See `LICENSE` for more information. -``` \ No newline at end of file From 9ba05e6074afeab8dba8d9c6e0ce4ac330f38ebb Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 20:34:47 +0000 Subject: [PATCH 06/56] add more metrics and fixes --- __init__.py | 5 ++- src/RenphoWeight.py | 11 +++++ src/__pycache__/RenphoWeight.cpython-310.pyc | Bin 6528 -> 6705 bytes src/__pycache__/const.cpython-310.pyc | Bin 1157 -> 1157 bytes src/const.py | 42 +++++++++++++++++++ 5 files changed, 56 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index 04a1f05..b497c36 100644 --- a/__init__.py +++ b/__init__.py @@ -14,10 +14,11 @@ def setup(hass, config): from src.const import CONF_PUBLIC_KEY import logging logging.basicConfig(level=logging.DEBUG) - renpho = RenphoWeight(CONF_PUBLIC_KEY, '', '') + renpho = RenphoWeight(CONF_PUBLIC_KEY, '', '', '') renpho.startPolling(10) print(renpho.getScaleUsers()) - print(renpho.getSpecificMetricFromUserID("bodyfat", "")) + print(renpho.getSpecificMetricFromUserID("bodyfat")) + print(renpho.getSpecificMetricFromUserID("bodyfat", "")) print(renpho.getInfo()) input("Press Enter to stop polling") renpho.stopPolling() \ No newline at end of file diff --git a/src/RenphoWeight.py b/src/RenphoWeight.py index 1cb0fd4..0e3f046 100755 --- a/src/RenphoWeight.py +++ b/src/RenphoWeight.py @@ -60,6 +60,13 @@ def auth(self): """ Authenticate with the Renpho API to obtain a session key. """ + + if not self.email: + raise Exception("Email must be provided") + + if not self.password: + raise Exception("Password must be provided") + # Encrypt the password using RSA encryption key = RSA.importKey(self.public_key) cipher = PKCS1_v1_5.new(key) @@ -71,6 +78,10 @@ def auth(self): 'password': encrypted_password} parsed = self._request('POST', API_AUTH_URL, data=data) + if 'terminal_user_session_key' not in parsed: + raise Exception("Authentication failed. Please check your username and password.") + + # Store the session key self.session_key = parsed['terminal_user_session_key'] return parsed diff --git a/src/__pycache__/RenphoWeight.cpython-310.pyc b/src/__pycache__/RenphoWeight.cpython-310.pyc index 377a7874fe6fe2a5bde61dffda38590177f9f564..59cf8dc7431686df6ed8aa8037d7a072f9aa76d4 100644 GIT binary patch delta 444 zcmZXQKTjJ$5XJB9{qg#2Un~(@c7rw%4FyO*s!*U5p|nV>6p@I~*}Dl2_|Cdp6F_Gf zVrY;kqHQURk}{2RY4`#uXsBq?rso5M0`{VGOfj1Go6&pH94t+qd10yK8oCyi{_^Mb ziT5ivJkl~TJ{oaOf@6fw(2B&KRwPr>99x>$=NLdb<6Htg5lIs=&S#fnmz(FBjW95I zc4;Igx6jN$HW#=fi;2Z^r{=)sG_iLOEo#9ZCSggM6`kigo z?Z=X}1nWiNPKOJ=Utja%_){412P-wCjo14tFZyy*1hUiiWhV^Sd+j3lJX_lqek@pf zQ?$3(Uf7RVKNeBocLnnU&U*h{=V!erdref$T0u8;Md#>mM+EJV3+3fM%skc9u_+uv zQ8Zl;lwsXD7J+kDCR9_`N>B4|fVb%%w?)uOS4%~pmo_~L?0#K;1RSRC%0CEBRC($a kXKC?q0r;kBm1}%gH@;6km`Ug4+uQoF&Ed#PJ`xc~qF delta 266 zcmWlTJ4*vW0EBn;aql}%qnG3-1hLZE!rp+0Xca;C5CR$qht-}6VwpnLMi9Ki%0`G= zTi6KN+t~OI#7@y7u2X&UH8bO-lf`5Z#{sb!&Hu>n<~VtBdt0^^q=F)STBAh4Dz0FO z7DCa=vEHpbX-$n&luS} Date: Thu, 7 Sep 2023 20:38:32 +0000 Subject: [PATCH 07/56] remove cache --- src/__pycache__/RenphoWeight.cpython-310.pyc | Bin 6705 -> 0 bytes src/__pycache__/const.cpython-310.pyc | Bin 1157 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/__pycache__/RenphoWeight.cpython-310.pyc delete mode 100644 src/__pycache__/const.cpython-310.pyc diff --git a/src/__pycache__/RenphoWeight.cpython-310.pyc b/src/__pycache__/RenphoWeight.cpython-310.pyc deleted file mode 100644 index 59cf8dc7431686df6ed8aa8037d7a072f9aa76d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6705 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 diff --git a/src/__pycache__/const.cpython-310.pyc b/src/__pycache__/const.cpython-310.pyc deleted file mode 100644 index df5495f2118db3c11fc33de1469cb16c43b9ebb1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1157 zcmZvbO>d({7=Xc!zla?t+0B0TBb9oIqSg*^yIX11E{3s9Y#n5-FCT4^ zOZ#7X>Y;z4hhBT$QBUoue_>C3XSP~xqYUEFGcWTp^D;x+Xb2oM<>;;d*9OP^O*1a0fgo^}@(ol5!G6>qSh;ilIrWj_)nvRp6y*m}Xv-xzlBw2IMiC zAl-HchK3}|QY~A8o;)%vbbCBxCE4BMA!}^oz5lw%px1K|R6ZirHI)5BocN@{ zqNnhRGh?ifN)pVHAYG6;i;q!njI06P$fGbLH5PMp)h3lZ4H7r>{~PC6XzZ%W{P@qG z{%HQpza+I`-?a^Q)V1b6{`ThAKz#EZEI^|%RhI`|ljpcEXzQZBs_&mV-H|Hq578?Y z6g0Iz)1-D^wqExw_2O7DQCBh@N$T`wg$tiUhX?rr)~e-@t*g-KJT2b&hpj3 zH=}%Ye6tcxl6GfyBVLGS@>FkUUMsoiiP0-5(k9ZSXq-&2q~*R5YG;e-)#NDBPr~8c z*7fA**bmys6x7fdiL%qZ$qxrBb>>`{sv? Date: Thu, 7 Sep 2023 20:40:23 +0000 Subject: [PATCH 08/56] small fixes --- __init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/__init__.py b/__init__.py index b497c36..c143f88 100644 --- a/__init__.py +++ b/__init__.py @@ -2,7 +2,8 @@ def setup(hass, config): - return src.setup(hass, config) + return setup(hass, config) + if __name__ == "__main__": # This code is executed when running this file directly @@ -14,11 +15,12 @@ def setup(hass, config): from src.const import CONF_PUBLIC_KEY import logging logging.basicConfig(level=logging.DEBUG) - renpho = RenphoWeight(CONF_PUBLIC_KEY, '', '', '') + renpho = RenphoWeight(CONF_PUBLIC_KEY, '', + '', '') renpho.startPolling(10) print(renpho.getScaleUsers()) print(renpho.getSpecificMetricFromUserID("bodyfat")) print(renpho.getSpecificMetricFromUserID("bodyfat", "")) print(renpho.getInfo()) input("Press Enter to stop polling") - renpho.stopPolling() \ No newline at end of file + renpho.stopPolling() From 6ebfc3b17593131adbfe08deaec0eeaa60229fb1 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 20:46:40 +0000 Subject: [PATCH 09/56] move outdside src --- src/RenphoWeight.py => RenphoWeight.py | 0 __init__.py | 69 +++++++++++++++++++++----- src/const.py => const.py | 0 src/sensor.py => sensor.py | 4 +- src/__init__.py | 51 ------------------- src/tests.py => tests.py | 0 6 files changed, 58 insertions(+), 66 deletions(-) rename src/RenphoWeight.py => RenphoWeight.py (100%) mode change 100644 => 100755 __init__.py rename src/const.py => const.py (100%) rename src/sensor.py => sensor.py (98%) delete mode 100755 src/__init__.py rename src/tests.py => tests.py (100%) diff --git a/src/RenphoWeight.py b/RenphoWeight.py similarity index 100% rename from src/RenphoWeight.py rename to RenphoWeight.py diff --git a/__init__.py b/__init__.py old mode 100644 new mode 100755 index c143f88..a8ca074 --- a/__init__.py +++ b/__init__.py @@ -1,26 +1,69 @@ +"""Initialization for the Renpho sensor component.""" + +# Import necessary modules and classes +from const import CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +from RenphoWeight import RenphoWeight +import logging +import sys +import os +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) from src.__init__ import setup +from RenphoWeight import RenphoWeight +from const import CONF_PUBLIC_KEY +import logging +logging.basicConfig(level=logging.DEBUG) + +# Initialize logger +_LOGGER = logging.getLogger(__name__) def setup(hass, config): - return setup(hass, config) + """ + Set up the Renpho component. + + Args: + hass (HomeAssistant): Home Assistant core object. + config (dict): Configuration for the component. + + Returns: + bool: True if initialization was successful, False otherwise. + """ + + _LOGGER.debug("Starting hass-renpho") + + # Extract configuration values + conf = config[DOMAIN] + email = conf[CONF_EMAIL] + password = conf[CONF_PASSWORD] + user_id = conf[CONF_USER_ID] + refresh = conf[CONF_REFRESH] + + # Create an instance of RenphoWeight + renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) + + # Define a cleanup function to stop polling when Home Assistant stops + def cleanup(event): + renpho.stopPolling() + + # Define a prepare function to start polling when Home Assistant starts + def prepare(event): + renpho.startPolling(refresh) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + + # Register the prepare function to be called when Home Assistant starts + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare) + + # Store the Renpho instance in Home Assistant's data dictionary + hass.data[DOMAIN] = renpho + return True # Initialization was successful if __name__ == "__main__": - # This code is executed when running this file directly - # It is used for testing purposes - import sys - import os - sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - from src.RenphoWeight import RenphoWeight - from src.const import CONF_PUBLIC_KEY - import logging - logging.basicConfig(level=logging.DEBUG) - renpho = RenphoWeight(CONF_PUBLIC_KEY, '', - '', '') + renpho = RenphoWeight(CONF_PUBLIC_KEY, '', '', '') renpho.startPolling(10) print(renpho.getScaleUsers()) print(renpho.getSpecificMetricFromUserID("bodyfat")) print(renpho.getSpecificMetricFromUserID("bodyfat", "")) print(renpho.getInfo()) input("Press Enter to stop polling") - renpho.stopPolling() + renpho.stopPolling() \ No newline at end of file diff --git a/src/const.py b/const.py similarity index 100% rename from src/const.py rename to const.py diff --git a/src/sensor.py b/sensor.py similarity index 98% rename from src/sensor.py rename to sensor.py index 0f72137..724ad9e 100755 --- a/src/sensor.py +++ b/sensor.py @@ -9,8 +9,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from src.RenphoWeight import _LOGGER -from src.const import CM_TO_INCH, DOMAIN, CONF_EMAIL, CONF_PASSWORD, KG_TO_LBS +from RenphoWeight import _LOGGER +from const import CM_TO_INCH, DOMAIN, CONF_EMAIL, CONF_PASSWORD, KG_TO_LBS # Existing setup_platform function diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100755 index d6f38d6..0000000 --- a/src/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Initialization for the Renpho sensor component.""" - -# Import necessary modules and classes -from src.const import CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -from src.RenphoWeight import RenphoWeight -import logging - -# Initialize logger -_LOGGER = logging.getLogger(__name__) - - -def setup(hass, config): - """ - Set up the Renpho component. - - Args: - hass (HomeAssistant): Home Assistant core object. - config (dict): Configuration for the component. - - Returns: - bool: True if initialization was successful, False otherwise. - """ - - _LOGGER.debug("Starting hass-renpho") - - # Extract configuration values - conf = config[DOMAIN] - email = conf[CONF_EMAIL] - password = conf[CONF_PASSWORD] - user_id = conf[CONF_USER_ID] - refresh = conf[CONF_REFRESH] - - # Create an instance of RenphoWeight - renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) - - # Define a cleanup function to stop polling when Home Assistant stops - def cleanup(event): - renpho.stopPolling() - - # Define a prepare function to start polling when Home Assistant starts - def prepare(event): - renpho.startPolling(refresh) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) - - # Register the prepare function to be called when Home Assistant starts - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare) - - # Store the Renpho instance in Home Assistant's data dictionary - hass.data[DOMAIN] = renpho - - return True # Initialization was successful \ No newline at end of file diff --git a/src/tests.py b/tests.py similarity index 100% rename from src/tests.py rename to tests.py From f498c1d8b5d791730a6d062c8ba1d58acf98d701 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 20:47:54 +0000 Subject: [PATCH 10/56] clean --- __init__.py | 8 ++------ sensor.py | 4 ++-- tests.py => tests/tests.py | 7 ++++--- 3 files changed, 8 insertions(+), 11 deletions(-) rename tests.py => tests/tests.py (97%) diff --git a/__init__.py b/__init__.py index a8ca074..bbc7929 100755 --- a/__init__.py +++ b/__init__.py @@ -1,16 +1,12 @@ """Initialization for the Renpho sensor component.""" # Import necessary modules and classes -from const import CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -from RenphoWeight import RenphoWeight +from .const import CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE +from .RenphoWeight import RenphoWeight import logging import sys import os sys.path.append(os.path.join(os.path.dirname(__file__), '..')) -from src.__init__ import setup -from RenphoWeight import RenphoWeight -from const import CONF_PUBLIC_KEY -import logging logging.basicConfig(level=logging.DEBUG) # Initialize logger diff --git a/sensor.py b/sensor.py index 724ad9e..cc6db7f 100755 --- a/sensor.py +++ b/sensor.py @@ -9,8 +9,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from RenphoWeight import _LOGGER -from const import CM_TO_INCH, DOMAIN, CONF_EMAIL, CONF_PASSWORD, KG_TO_LBS +from .RenphoWeight import _LOGGER +from .const import CM_TO_INCH, DOMAIN, CONF_EMAIL, CONF_PASSWORD, KG_TO_LBS # Existing setup_platform function diff --git a/tests.py b/tests/tests.py similarity index 97% rename from tests.py rename to tests/tests.py index 994e026..19140e8 100644 --- a/tests.py +++ b/tests/tests.py @@ -5,9 +5,10 @@ from base64 import b64decode, b64encode import requests -from sensor import RenphoSensor, WeightSensor, TimeSensor, setup_platform -from RenphoWeight import RenphoWeight -from __init__ import setup +from ..sensor import RenphoSensor, WeightSensor, TimeSensor, setup_platform +from ..RenphoWeight import RenphoWeight +from .. import setup +from ..const import CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, DOMAIN class TestEncryption(unittest.TestCase): From 7f713e35c3081f6dfe25c843b8aa673d1d0b7d98 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 20:51:01 +0000 Subject: [PATCH 11/56] remove error --- __init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index bbc7929..38d9327 100755 --- a/__init__.py +++ b/__init__.py @@ -1,8 +1,8 @@ """Initialization for the Renpho sensor component.""" # Import necessary modules and classes -from .const import CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE -from .RenphoWeight import RenphoWeight +from const import CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE +from RenphoWeight import RenphoWeight import logging import sys import os From b01d63454d35bf4b8bbf8fa3338c26664ca319d0 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 23:35:27 +0000 Subject: [PATCH 12/56] add logo and config flow --- __init__.py | 1 + config_flow.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ const.py | 2 +- hacs.json | 5 +++-- manifest.json | 5 +++-- renpho.png | Bin 0 -> 14773 bytes 6 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 config_flow.py create mode 100644 renpho.png diff --git a/__init__.py b/__init__.py index 38d9327..96ee8a2 100755 --- a/__init__.py +++ b/__init__.py @@ -3,6 +3,7 @@ # Import necessary modules and classes from const import CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE from RenphoWeight import RenphoWeight +from config_flow import RenphoConfigFlow import logging import sys import os diff --git a/config_flow.py b/config_flow.py new file mode 100644 index 0000000..098e216 --- /dev/null +++ b/config_flow.py @@ -0,0 +1,44 @@ +import voluptuous as vol +from homeassistant import config_entries, data_entry_flow + +from const import DOMAIN, CONF_USER_ID, CONF_PUBLIC_KEY, CONF_EMAIL, CONF_PASSWORD +from RenphoWeight import RenphoWeight + +class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + # Extract the user input + email = user_input[CONF_EMAIL] + password = user_input[CONF_PASSWORD] + user_id = user_input[CONF_USER_ID] + + renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) + is_valid = True # Replace this with actual validation logic + + if is_valid: + return self.async_create_entry( + title=email, # Use the email as the title + data={ + CONF_EMAIL: email, + CONF_PASSWORD: password, + CONF_USER_ID: user_id, + }, + ) + else: + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({ + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + vol.OPTIONAL(CONF_USER_ID): str, + }), + errors=errors, + ) diff --git a/const.py b/const.py index 75ceed4..464476e 100755 --- a/const.py +++ b/const.py @@ -5,7 +5,7 @@ DOMAIN: Final = "renpho" - +VERSION: Final = "1.0.0" EVENT_HOMEASSISTANT_CLOSE: Final = "homeassistant_close" EVENT_HOMEASSISTANT_START: Final = "homeassistant_start" EVENT_HOMEASSISTANT_STARTED: Final = "homeassistant_started" diff --git a/hacs.json b/hacs.json index 08d2e66..d927175 100644 --- a/hacs.json +++ b/hacs.json @@ -3,6 +3,7 @@ "domains": ["sensor", "renpho"], "documentation": "https://github.com/antoinebou12/hass_renpho/blob/main/README.md", "codeowners": ["antoinebou12"], - "icon": "https://raw.githubusercontent.com/antoinebou12/hass_renpho/main/icon.png", - "country": ["ca"] + "icon": "https://raw.githubusercontent.com/antoinebou12/hass_renpho/main/renpho.png", + "country": ["ca"], + "config_flow": true } \ No newline at end of file diff --git a/manifest.json b/manifest.json index d50db84..6fff3ab 100755 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,11 @@ { "domain": "renpho", - "name": "Renpho Smart Health Weight Scale", + "name": "Renpho Smart Scale", "documentation": "https://github.com/antoinebou12/hass_renpho", "dependencies": [], "codeowners": ["@antoinebou12"], "requirements": ["pycryptodome>=3.3.1", "requests"], "iot_class": "local_polling", - "version": "1.0.0" + "version": "1.0.0", + "config_flow": true } diff --git a/renpho.png b/renpho.png new file mode 100644 index 0000000000000000000000000000000000000000..26cf6aca6d562616cfd2289af95b6f217b309eca GIT binary patch literal 14773 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 literal 0 HcmV?d00001 From bd5c41c49082dbd535573306d5a692d2a7a709d5 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 23:58:52 +0000 Subject: [PATCH 13/56] add more sensor --- docs/images/renpho_google.png | Bin 0 -> 102756 bytes example/googletheme.yaml | 56 +++++ sensor.py | 388 ++++++++++------------------------ 3 files changed, 163 insertions(+), 281 deletions(-) create mode 100644 docs/images/renpho_google.png create mode 100644 example/googletheme.yaml diff --git a/docs/images/renpho_google.png b/docs/images/renpho_google.png new file mode 100644 index 0000000000000000000000000000000000000000..3dcc10641e2c57d197b734752f0ac5f96cbd6669 GIT binary patch literal 102756 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

2ry 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 fkA}mrP7g=cyPSQtR!D@Y9OO+;S3j3^P6 None: + self, renpho, metric, name, unit_of_measurement, category="Renpho", label="Data", convert_unit=False) -> None: self._renpho = renpho self._metric = metric self._name = f"Renpho {name}" @@ -322,6 +123,20 @@ def __init__( self._convert_unit = convert_unit self._timestamp = None + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"renpho_{slugify(self._name)}" + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + 'timestamp': self._timestamp, + 'category': self._category, + 'label': self._label + } + # Conversion method for kg to lbs def kg_to_lbs(self, kg): return kg * KG_TO_LBS @@ -373,7 +188,18 @@ def label(self) -> str: def update(self) -> None: """ Update the sensor. """ try: - self._state = self._renpho.getSpecificMetric(self._metric) - self._timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + metric_value = self._renpho.getSpecificMetric(self._metric) + if metric_value is not None: # Add validation here + self._state = metric_value + self._timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + _LOGGER.info(f"Successfully updated {self._name}") + else: + _LOGGER.warning( + f"{self._metric} returned None. Not updating {self._name}.") + except ConnectionError: + _LOGGER.error(f"Connection error updating {self._name}") + except TimeoutError: + _LOGGER.error(f"Timeout error updating {self._name}") except Exception as e: - _LOGGER.error(f"Error updating {self._name} sensor: {e}") + _LOGGER.error( + f"An unexpected error occurred updating {self._name}: {e}") From 1fc54fbddaa48192d1c7905f911e85109c6f2482 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 00:09:16 +0000 Subject: [PATCH 14/56] add icon --- config_flow.py | 14 +++++++------- hacs.json | 4 ++-- manifest.json | 1 + 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/config_flow.py b/config_flow.py index 098e216..2b86e70 100644 --- a/config_flow.py +++ b/config_flow.py @@ -1,8 +1,8 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow -from const import DOMAIN, CONF_USER_ID, CONF_PUBLIC_KEY, CONF_EMAIL, CONF_PASSWORD -from RenphoWeight import RenphoWeight +from const import DOMAIN, CONF_USER_ID, CONF_PUBLIC_KEY, CONF_EMAIL, CONF_PASSWORD # Ensure correct import +from RenphoWeight import RenphoWeight # Ensure correct import class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 @@ -16,14 +16,14 @@ async def async_step_user(self, user_input=None): # Extract the user input email = user_input[CONF_EMAIL] password = user_input[CONF_PASSWORD] - user_id = user_input[CONF_USER_ID] + user_id = user_input.get(CONF_USER_ID, None) renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) - is_valid = True # Replace this with actual validation logic + is_valid = True if is_valid: return self.async_create_entry( - title=email, # Use the email as the title + title=email, data={ CONF_EMAIL: email, CONF_PASSWORD: password, @@ -38,7 +38,7 @@ async def async_step_user(self, user_input=None): data_schema=vol.Schema({ vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str, - vol.OPTIONAL(CONF_USER_ID): str, + vol.Optional(CONF_USER_ID): str, }), errors=errors, - ) + ) \ No newline at end of file diff --git a/hacs.json b/hacs.json index d927175..1312d34 100644 --- a/hacs.json +++ b/hacs.json @@ -1,9 +1,9 @@ { "name": "Renpho Weight Scale Integration", "domains": ["sensor", "renpho"], - "documentation": "https://github.com/antoinebou12/hass_renpho/blob/main/README.md", + "documentation": "https://github.com/antoinebou12/hass_renpho/blob/master/README.md", "codeowners": ["antoinebou12"], - "icon": "https://raw.githubusercontent.com/antoinebou12/hass_renpho/main/renpho.png", + "icon": "https://raw.githubusercontent.com/antoinebou12/hass_renpho/master/renpho.png", "country": ["ca"], "config_flow": true } \ No newline at end of file diff --git a/manifest.json b/manifest.json index 6fff3ab..cf2936b 100755 --- a/manifest.json +++ b/manifest.json @@ -7,5 +7,6 @@ "requirements": ["pycryptodome>=3.3.1", "requests"], "iot_class": "local_polling", "version": "1.0.0", + "icon": "https://raw.githubusercontent.com/antoinebou12/hass_renpho/master/renpho.png", "config_flow": true } From 9f571e7ee89b21b63ab7f41c5e75f1f8ab7dffc1 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 20:38:46 -0400 Subject: [PATCH 15/56] better config flow --- __init__.py | 6 ++-- config_flow.py | 82 ++++++++++++++++++++++++++++---------------------- 2 files changed, 49 insertions(+), 39 deletions(-) diff --git a/__init__.py b/__init__.py index 96ee8a2..0896483 100755 --- a/__init__.py +++ b/__init__.py @@ -1,9 +1,9 @@ """Initialization for the Renpho sensor component.""" # Import necessary modules and classes -from const import CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE -from RenphoWeight import RenphoWeight -from config_flow import RenphoConfigFlow +from .const import CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE +from .RenphoWeight import RenphoWeight +from .config_flow import RenphoConfigFlow import logging import sys import os diff --git a/config_flow.py b/config_flow.py index 2b86e70..7247897 100644 --- a/config_flow.py +++ b/config_flow.py @@ -1,44 +1,54 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow -from const import DOMAIN, CONF_USER_ID, CONF_PUBLIC_KEY, CONF_EMAIL, CONF_PASSWORD # Ensure correct import -from RenphoWeight import RenphoWeight # Ensure correct import +from const import DOMAIN, CONF_USER_ID, CONF_PUBLIC_KEY, CONF_EMAIL, CONF_PASSWORD +from RenphoWeight import RenphoWeight class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - errors = {} - - if user_input is not None: - # Extract the user input - email = user_input[CONF_EMAIL] - password = user_input[CONF_PASSWORD] - user_id = user_input.get(CONF_USER_ID, None) - - renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) - is_valid = True - - if is_valid: - return self.async_create_entry( - title=email, - data={ - CONF_EMAIL: email, - CONF_PASSWORD: password, - CONF_USER_ID: user_id, - }, - ) - else: - errors["base"] = "invalid_auth" - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({ - vol.Required(CONF_EMAIL): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_USER_ID): str, - }), - errors=errors, - ) \ No newline at end of file +async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + # Define the schema and set default values and descriptions + schema = vol.Schema({ + vol.Required(CONF_EMAIL, description={"suggested_value": "Your email"}): str, + vol.Required(CONF_PASSWORD, description={"suggested_value": "Your password"}): str, + vol.Optional(CONF_USER_ID, description={"suggested_value": "Optional User ID"}): str, + }) + + if user_input is not None: + email = user_input[CONF_EMAIL] + password = user_input[CONF_PASSWORD] + user_id = user_input.get(CONF_USER_ID, None) + + # Initialize your Renpho object + renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) + + # Preliminary validation; replace with your validation logic + try: + is_valid = await renpho.validate_credentials() + except Exception as e: + _LOGGER.error("Validation failed: %s", str(e)) + is_valid = False + + if is_valid: + return self.async_create_entry( + title=email, + data={ + CONF_EMAIL: email, + CONF_PASSWORD: password, + CONF_USER_ID: user_id, + }, + ) + else: + errors["base"] = "invalid_auth" + + # Use the schema defined above in the form + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + ) \ No newline at end of file From 8b54654f44192f15f22675d399060b6e9dd0e308 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 21:11:18 -0400 Subject: [PATCH 16/56] add better config flow --- RenphoWeight.py | 14 ++++++++ config_flow.py | 90 +++++++++++++++++++++++-------------------------- hacs.json | 3 +- 3 files changed, 58 insertions(+), 49 deletions(-) diff --git a/RenphoWeight.py b/RenphoWeight.py index 0e3f046..8f76af4 100755 --- a/RenphoWeight.py +++ b/RenphoWeight.py @@ -86,6 +86,20 @@ def auth(self): self.session_key = parsed['terminal_user_session_key'] return parsed + def validate_credentials(self): + """ + Validate the current credentials by attempting to authenticate. + + Returns: + bool: True if authentication succeeds, False otherwise. + """ + try: + self.auth() + return True + except Exception as e: + _LOGGER.error(f"Validation failed: {e}") + return False + def getScaleUsers(self): """ Fetch the list of users associated with the scale. diff --git a/config_flow.py b/config_flow.py index 7247897..3ce19de 100644 --- a/config_flow.py +++ b/config_flow.py @@ -1,54 +1,50 @@ +from __future__ import annotations +import logging +from typing import Any + import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries, exceptions +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, CONF_USER_ID, CONF_PUBLIC_KEY, CONF_EMAIL, CONF_PASSWORD +from .RenphoWeight import RenphoWeight + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({ + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_USER_ID): str, +}) -from const import DOMAIN, CONF_USER_ID, CONF_PUBLIC_KEY, CONF_EMAIL, CONF_PASSWORD -from RenphoWeight import RenphoWeight +async def async_validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: + renpho = RenphoWeight(data[CONF_EMAIL], data[CONF_PASSWORD], data.get(CONF_USER_ID, None)) + is_valid = await renpho.validate_credentials() + if not is_valid: + raise CannotConnect + return {"title": data[CONF_EMAIL]} class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL -async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - errors = {} - - # Define the schema and set default values and descriptions - schema = vol.Schema({ - vol.Required(CONF_EMAIL, description={"suggested_value": "Your email"}): str, - vol.Required(CONF_PASSWORD, description={"suggested_value": "Your password"}): str, - vol.Optional(CONF_USER_ID, description={"suggested_value": "Optional User ID"}): str, - }) - - if user_input is not None: - email = user_input[CONF_EMAIL] - password = user_input[CONF_PASSWORD] - user_id = user_input.get(CONF_USER_ID, None) - - # Initialize your Renpho object - renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) - - # Preliminary validation; replace with your validation logic - try: - is_valid = await renpho.validate_credentials() - except Exception as e: - _LOGGER.error("Validation failed: %s", str(e)) - is_valid = False - - if is_valid: - return self.async_create_entry( - title=email, - data={ - CONF_EMAIL: email, - CONF_PASSWORD: password, - CONF_USER_ID: user_id, - }, - ) - else: - errors["base"] = "invalid_auth" - - # Use the schema defined above in the form - return self.async_show_form( - step_id="user", - data_schema=schema, - errors=errors, - ) \ No newline at end of file + async def async_step_user(self, user_input=None): + """Handle the user step.""" + errors = {} + if user_input is not None: + try: + info = await async_validate_input(self.hass, user_input) + return self.async_create_entry(title=info["title"], data=user_input) + except CannotConnect: + errors["base"] = "Invalid credentials or cannot connect to Renpho." + except Exception as e: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception: %s", e) + errors["base"] = "An unknown error occurred." + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" \ No newline at end of file diff --git a/hacs.json b/hacs.json index 1312d34..b359f25 100644 --- a/hacs.json +++ b/hacs.json @@ -4,6 +4,5 @@ "documentation": "https://github.com/antoinebou12/hass_renpho/blob/master/README.md", "codeowners": ["antoinebou12"], "icon": "https://raw.githubusercontent.com/antoinebou12/hass_renpho/master/renpho.png", - "country": ["ca"], - "config_flow": true + "country": ["ca"] } \ No newline at end of file From 2c62a1c78bc2762a577a6e20d046161ae7340217 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 21:49:59 -0400 Subject: [PATCH 17/56] add config_flow --- RenphoWeight.py | 2 +- config_flow.py | 22 ++++++++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/RenphoWeight.py b/RenphoWeight.py index 8f76af4..1b3a164 100755 --- a/RenphoWeight.py +++ b/RenphoWeight.py @@ -86,7 +86,7 @@ def auth(self): self.session_key = parsed['terminal_user_session_key'] return parsed - def validate_credentials(self): + async def validate_credentials(self): """ Validate the current credentials by attempting to authenticate. diff --git a/config_flow.py b/config_flow.py index 3ce19de..9b26bb4 100644 --- a/config_flow.py +++ b/config_flow.py @@ -11,10 +11,11 @@ _LOGGER = logging.getLogger(__name__) +# Define the data schema with suggested values for better user experience DATA_SCHEMA = vol.Schema({ - vol.Required(CONF_EMAIL): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_USER_ID): str, + vol.Required(CONF_EMAIL, description={"suggested_value": "example@email.com"}): str, + vol.Required(CONF_PASSWORD, description={"suggested_value": "YourPasswordHere"}): str, + vol.Optional(CONF_USER_ID, description={"suggested_value": "OptionalUserID"}): str, }) async def async_validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: @@ -30,21 +31,30 @@ class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle the user step.""" + _LOGGER.debug("Handling user step. Input received: %s", user_input) + errors = {} if user_input is not None: try: + _LOGGER.debug("Validating user input") info = await async_validate_input(self.hass, user_input) + _LOGGER.debug("User input validated. Creating entry.") + return self.async_create_entry(title=info["title"], data=user_input) except CannotConnect: - errors["base"] = "Invalid credentials or cannot connect to Renpho." + _LOGGER.error("Cannot connect or invalid credentials") + errors["base"] = "Could not connect to Renpho. Please check your credentials." except Exception as e: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception: %s", e) - errors["base"] = "An unknown error occurred." + errors["base"] = f"An unexpected error occurred: {str(e)}" + + _LOGGER.debug("Showing form with errors: %s", errors) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors, + description_placeholders={"additional_info": "Please provide your Renpho login details."}, ) class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" \ No newline at end of file + """Error to indicate we cannot connect.""" From 5775e36f94716a7be285f52589735f86b1bcb23f Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 21:58:50 -0400 Subject: [PATCH 18/56] add better config flow --- config_flow.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/config_flow.py b/config_flow.py index 9b26bb4..26c24ec 100644 --- a/config_flow.py +++ b/config_flow.py @@ -19,6 +19,7 @@ }) async def async_validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: + _LOGGER.debug("Starting to validate input: %s", data) renpho = RenphoWeight(data[CONF_EMAIL], data[CONF_PASSWORD], data.get(CONF_USER_ID, None)) is_valid = await renpho.validate_credentials() if not is_valid: @@ -43,10 +44,13 @@ async def async_step_user(self, user_input=None): return self.async_create_entry(title=info["title"], data=user_input) except CannotConnect: _LOGGER.error("Cannot connect or invalid credentials") - errors["base"] = "Could not connect to Renpho. Please check your credentials." + errors["base"] = "CannotConnect" + except exceptions.HomeAssistantError as e: + _LOGGER.error("Home Assistant specific error: %s", str(e)) + errors["base"] = "HomeAssistantError" except Exception as e: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception: %s", e) - errors["base"] = f"An unexpected error occurred: {str(e)}" + errors["base"] = "UnknownError" _LOGGER.debug("Showing form with errors: %s", errors) return self.async_show_form( @@ -57,4 +61,4 @@ async def async_step_user(self, user_input=None): ) class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" + """Error to indicate we cannot connect.""" \ No newline at end of file From 7c093d2adc0a889acbb30b0b84236eaa67f1168a Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 22:07:35 -0400 Subject: [PATCH 19/56] fix rsa error --- config_flow.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/config_flow.py b/config_flow.py index 26c24ec..5bd89a2 100644 --- a/config_flow.py +++ b/config_flow.py @@ -20,10 +20,10 @@ async def async_validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: _LOGGER.debug("Starting to validate input: %s", data) - renpho = RenphoWeight(data[CONF_EMAIL], data[CONF_PASSWORD], data.get(CONF_USER_ID, None)) + renpho = RenphoWeight(CONF_PUBLIC_KEY, data[CONF_EMAIL], data[CONF_PASSWORD], data.get(CONF_USER_ID, None)) is_valid = await renpho.validate_credentials() if not is_valid: - raise CannotConnect + raise raise CannotConnect(reason="Invalid credentials", details={"email": data[CONF_EMAIL], "user_id"=data.get(CONF_USER_ID, None)}) return {"title": data[CONF_EMAIL]} class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -43,8 +43,8 @@ async def async_step_user(self, user_input=None): return self.async_create_entry(title=info["title"], data=user_input) except CannotConnect: - _LOGGER.error("Cannot connect or invalid credentials") - errors["base"] = "CannotConnect" + _LOGGER.error("Cannot connect: %s, details: %s", e.reason, e.get_details()) + errors["base"] = f"CannotConnect: {e.reason}" except exceptions.HomeAssistantError as e: _LOGGER.error("Home Assistant specific error: %s", str(e)) errors["base"] = "HomeAssistantError" @@ -61,4 +61,15 @@ async def async_step_user(self, user_input=None): ) class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" \ No newline at end of file + """Error to indicate we cannot connect.""" + + def __init__(self, reason: str = "", details: dict = None): + super().__init__(self) + self.reason = reason + self.details = details or {} + + def __str__(self): + return f"CannotConnect: {self.reason}" + + def get_details(self): + return self.details \ No newline at end of file From 0417b98ef75f547ce915e74d98a950b46c8a4ba1 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 22:11:32 -0400 Subject: [PATCH 20/56] fix error raise --- config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config_flow.py b/config_flow.py index 5bd89a2..c104a95 100644 --- a/config_flow.py +++ b/config_flow.py @@ -23,7 +23,7 @@ async def async_validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any renpho = RenphoWeight(CONF_PUBLIC_KEY, data[CONF_EMAIL], data[CONF_PASSWORD], data.get(CONF_USER_ID, None)) is_valid = await renpho.validate_credentials() if not is_valid: - raise raise CannotConnect(reason="Invalid credentials", details={"email": data[CONF_EMAIL], "user_id"=data.get(CONF_USER_ID, None)}) + raise CannotConnect(reason="Invalid credentials", details={"email": data[CONF_EMAIL], "user_id"=data.get(CONF_USER_ID, None)}) return {"title": data[CONF_EMAIL]} class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): From 3fe9d950640b7199e74e7541f5bc02013133795d Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 22:13:24 -0400 Subject: [PATCH 21/56] fix erro again --- config_flow.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/config_flow.py b/config_flow.py index c104a95..da0cba3 100644 --- a/config_flow.py +++ b/config_flow.py @@ -11,7 +11,6 @@ _LOGGER = logging.getLogger(__name__) -# Define the data schema with suggested values for better user experience DATA_SCHEMA = vol.Schema({ vol.Required(CONF_EMAIL, description={"suggested_value": "example@email.com"}): str, vol.Required(CONF_PASSWORD, description={"suggested_value": "YourPasswordHere"}): str, @@ -23,7 +22,7 @@ async def async_validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any renpho = RenphoWeight(CONF_PUBLIC_KEY, data[CONF_EMAIL], data[CONF_PASSWORD], data.get(CONF_USER_ID, None)) is_valid = await renpho.validate_credentials() if not is_valid: - raise CannotConnect(reason="Invalid credentials", details={"email": data[CONF_EMAIL], "user_id"=data.get(CONF_USER_ID, None)}) + raise CannotConnect(reason="Invalid credentials", details={"email": data[CONF_EMAIL], "user_id": data.get(CONF_USER_ID, None)}) return {"title": data[CONF_EMAIL]} class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -31,7 +30,6 @@ class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL async def async_step_user(self, user_input=None): - """Handle the user step.""" _LOGGER.debug("Handling user step. Input received: %s", user_input) errors = {} @@ -42,7 +40,7 @@ async def async_step_user(self, user_input=None): _LOGGER.debug("User input validated. Creating entry.") return self.async_create_entry(title=info["title"], data=user_input) - except CannotConnect: + except CannotConnect as e: _LOGGER.error("Cannot connect: %s, details: %s", e.reason, e.get_details()) errors["base"] = f"CannotConnect: {e.reason}" except exceptions.HomeAssistantError as e: @@ -61,8 +59,6 @@ async def async_step_user(self, user_input=None): ) class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" - def __init__(self, reason: str = "", details: dict = None): super().__init__(self) self.reason = reason @@ -72,4 +68,4 @@ def __str__(self): return f"CannotConnect: {self.reason}" def get_details(self): - return self.details \ No newline at end of file + return self.details From 2dacb4aacc149c0bafb188e86dd4b5ef1dae9fda Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 22:34:38 -0400 Subject: [PATCH 22/56] small fix --- RenphoWeight.py | 102 ++++++++++++++++++++++-------------------------- __init__.py | 56 +++++++++++++------------- manifest.json | 1 - 3 files changed, 73 insertions(+), 86 deletions(-) diff --git a/RenphoWeight.py b/RenphoWeight.py index 1b3a164..fefedda 100755 --- a/RenphoWeight.py +++ b/RenphoWeight.py @@ -1,8 +1,8 @@ import requests import json -import time import datetime -from threading import Timer +from concurrent.futures import ThreadPoolExecutor +import asyncio from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5 from base64 import b64encode @@ -16,7 +16,6 @@ API_SCALE_USERS_URL = 'https://renpho.qnclouds.com/api/v3/scale_users/list_scale_user' API_MEASUREMENTS_URL = 'https://renpho.qnclouds.com/api/v2/measurements/list.json' - class RenphoWeight: """ A class to interact with Renpho's weight scale API. @@ -29,13 +28,11 @@ class RenphoWeight: weight (float): The most recent weight measurement. time_stamp (int): The timestamp of the most recent weight measurement. session_key (str): The session key obtained after successful authentication. + executor (ThreadPoolExecutor): Executor for running synchronous requests. """ def __init__(self, public_key, email, password, user_id=None): - """ - Initialize a new RenphoWeight instance. - """ - _LOGGER.debug("Init RenphoWeight") + """Initialize a new RenphoWeight instance.""" self.public_key = public_key self.email = email self.password = password @@ -43,73 +40,61 @@ def __init__(self, public_key, email, password, user_id=None): self.weight = None self.time_stamp = None self.session_key = None # Initialize session_key + self.executor = ThreadPoolExecutor(max_workers=4) - def _request(self, method, url, **kwargs): + async def _request(self, method, url, **kwargs): """ - Make a generic API request and handle errors. + Asynchronous method to make an API request and handle errors. """ - try: - response = requests.request(method, url, **kwargs) - response.raise_for_status() - return response.json() - except Exception as e: - _LOGGER.error(f"Error in request: {e}") - raise # Or raise a custom exception + loop = asyncio.get_event_loop() + response = await loop.run_in_executor( + self.executor, requests.request, method, url, **kwargs + ) + response.raise_for_status() + return response.json() - def auth(self): + async def auth(self): """ Authenticate with the Renpho API to obtain a session key. """ + if not self.email or not self.password: + raise Exception("Email and password must be provided") - if not self.email: - raise Exception("Email must be provided") - - if not self.password: - raise Exception("Password must be provided") - - # Encrypt the password using RSA encryption key = RSA.importKey(self.public_key) cipher = PKCS1_v1_5.new(key) - encrypted_password = b64encode( - cipher.encrypt(self.password.encode("utf-8"))) + encrypted_password = b64encode(cipher.encrypt(self.password.encode("utf-8"))) - # Make the authentication request - data = {'secure_flag': 1, 'email': self.email, - 'password': encrypted_password} - parsed = self._request('POST', API_AUTH_URL, data=data) + data = {'secure_flag': 1, 'email': self.email, 'password': encrypted_password} + parsed = await self._request('POST', API_AUTH_URL, data=data) if 'terminal_user_session_key' not in parsed: - raise Exception("Authentication failed. Please check your username and password.") + raise Exception("Authentication failed.") - - # Store the session key self.session_key = parsed['terminal_user_session_key'] return parsed async def validate_credentials(self): """ Validate the current credentials by attempting to authenticate. - - Returns: - bool: True if authentication succeeds, False otherwise. + Returns True if authentication succeeds, False otherwise. """ try: - self.auth() + await self.auth() return True except Exception as e: _LOGGER.error(f"Validation failed: {e}") return False - def getScaleUsers(self): + async def getScaleUsers(self): """ Fetch the list of users associated with the scale. """ url = f"{API_SCALE_USERS_URL}?locale=en&terminal_user_session_key={self.session_key}" - parsed = self._request('GET', url) + parsed = await self._request('GET', url) self.set_user_id(parsed['scale_users'][0]['user_id']) return parsed['scale_users'] - def getMeasurements(self): + async def getMeasurements(self): """ Fetch the most recent weight measurements for the user. """ @@ -117,38 +102,43 @@ def getMeasurements(self): week_ago = today - datetime.timedelta(days=7) week_ago_timestamp = int(time.mktime(week_ago.timetuple())) url = f"{API_MEASUREMENTS_URL}?user_id={self.user_id}&last_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" - parsed = self._request('GET', url) + parsed = await self._request('GET', url) last_measurement = parsed['last_ary'][0] self.weight = last_measurement['weight'] self.time_stamp = last_measurement['time_stamp'] return parsed['last_ary'] - def getSpecificMetric(self, metric): - """ - Fetch a specific metric from the most recent weight measurement. - """ - last_measurement = self.getMeasurements()[0] - # Return None if metric not found - return last_measurement.get(metric, None) - def set_user_id(self, user_id): """ Set the user ID for whom the weight data should be fetched. - - Args: - user_id (str): The new user ID. """ self.user_id = user_id def get_user_id(self): """ Get the current user ID for whom the weight data is being fetched. - - Returns: - str: The current user ID. """ return self.user_id + def close(self): + """ + Shutdown the executor when you are done using the RenphoWeight instance. + """ + self.executor.shutdown() + + +class Interval(Timer): + """ + A subclass of Timer to repeatedly run a function at a specified interval. + """ + + def run(self): + """ + Run the function at the given interval. + """ + while not self.finished.wait(self.interval): + self.function(*self.args, **self.kwargs) + def getSpecificMetricFromUserID(self, metric, user_id=None): """ Fetch a specific metric for a particular user ID from the most recent weight measurement. @@ -201,4 +191,4 @@ def run(self): Run the function at the given interval. """ while not self.finished.wait(self.interval): - self.function(*self.args, **self.kwargs) + self.function(*self.args, **self.kwargs) \ No newline at end of file diff --git a/__init__.py b/__init__.py index 0896483..871e02e 100755 --- a/__init__.py +++ b/__init__.py @@ -1,59 +1,57 @@ -"""Initialization for the Renpho sensor component.""" - -# Import necessary modules and classes -from .const import CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE +from homeassistant.helpers import service +from .const import ( + CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, + CONF_REFRESH, CONF_PUBLIC_KEY, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +) from .RenphoWeight import RenphoWeight -from .config_flow import RenphoConfigFlow import logging -import sys -import os -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) -logging.basicConfig(level=logging.DEBUG) # Initialize logger _LOGGER = logging.getLogger(__name__) - def setup(hass, config): - """ - Set up the Renpho component. - - Args: - hass (HomeAssistant): Home Assistant core object. - config (dict): Configuration for the component. - - Returns: - bool: True if initialization was successful, False otherwise. - """ - - _LOGGER.debug("Starting hass-renpho") + """Set up the Renpho component.""" + _LOGGER.debug("Starting hass_renpho") # Extract configuration values conf = config[DOMAIN] email = conf[CONF_EMAIL] password = conf[CONF_PASSWORD] - user_id = conf[CONF_USER_ID] + user_id = conf.get(CONF_USER_ID) # Using get in case it's optional refresh = conf[CONF_REFRESH] # Create an instance of RenphoWeight renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) - # Define a cleanup function to stop polling when Home Assistant stops + # Define a cleanup function def cleanup(event): renpho.stopPolling() - # Define a prepare function to start polling when Home Assistant starts + # Define a prepare function def prepare(event): renpho.startPolling(refresh) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) - # Register the prepare function to be called when Home Assistant starts + # Register the prepare function hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare) - # Store the Renpho instance in Home Assistant's data dictionary + # Store the Renpho instance hass.data[DOMAIN] = renpho - return True # Initialization was successful + # Reload configuration function + def reload_config(call): + """Reload the Renpho component.""" + # Unload the current configuration + hass.helpers.entity_component.async_unload_entities(DOMAIN) + + # Load the new configuration + setup(hass, config) + + # Register the reload service + hass.services.register(DOMAIN, 'reload', reload_config) + + return True if __name__ == "__main__": renpho = RenphoWeight(CONF_PUBLIC_KEY, '', '', '') @@ -63,4 +61,4 @@ def prepare(event): print(renpho.getSpecificMetricFromUserID("bodyfat", "")) print(renpho.getInfo()) input("Press Enter to stop polling") - renpho.stopPolling() \ No newline at end of file + renpho.stopPolling() diff --git a/manifest.json b/manifest.json index cf2936b..6fff3ab 100755 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,5 @@ "requirements": ["pycryptodome>=3.3.1", "requests"], "iot_class": "local_polling", "version": "1.0.0", - "icon": "https://raw.githubusercontent.com/antoinebou12/hass_renpho/master/renpho.png", "config_flow": true } From b736c595e753403aa45b786e449a92031e3a3b68 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 02:41:42 +0000 Subject: [PATCH 23/56] fix import --- RenphoWeight.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/RenphoWeight.py b/RenphoWeight.py index fefedda..1296826 100755 --- a/RenphoWeight.py +++ b/RenphoWeight.py @@ -1,12 +1,14 @@ +from threading import Timer +from concurrent.futures import ThreadPoolExecutor import requests import json import datetime -from concurrent.futures import ThreadPoolExecutor import asyncio from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5 from base64 import b64encode import logging +import time # Initialize logging _LOGGER = logging.getLogger(__name__) From a18092863be4f46cbc790f22711a81bf34e14427 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 22:46:41 -0400 Subject: [PATCH 24/56] put the missing function --- RenphoWeight.py | 48 +++++++++++++++++------------------------------- 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/RenphoWeight.py b/RenphoWeight.py index 1296826..6186737 100755 --- a/RenphoWeight.py +++ b/RenphoWeight.py @@ -110,37 +110,6 @@ async def getMeasurements(self): self.time_stamp = last_measurement['time_stamp'] return parsed['last_ary'] - def set_user_id(self, user_id): - """ - Set the user ID for whom the weight data should be fetched. - """ - self.user_id = user_id - - def get_user_id(self): - """ - Get the current user ID for whom the weight data is being fetched. - """ - return self.user_id - - def close(self): - """ - Shutdown the executor when you are done using the RenphoWeight instance. - """ - self.executor.shutdown() - - -class Interval(Timer): - """ - A subclass of Timer to repeatedly run a function at a specified interval. - """ - - def run(self): - """ - Run the function at the given interval. - """ - while not self.finished.wait(self.interval): - self.function(*self.args, **self.kwargs) - def getSpecificMetricFromUserID(self, metric, user_id=None): """ Fetch a specific metric for a particular user ID from the most recent weight measurement. @@ -182,6 +151,23 @@ def stopPolling(self): if hasattr(self, 'polling'): self.polling.cancel() + def set_user_id(self, user_id): + """ + Set the user ID for whom the weight data should be fetched. + """ + self.user_id = user_id + + def get_user_id(self): + """ + Get the current user ID for whom the weight data is being fetched. + """ + return self.user_id + + def close(self): + """ + Shutdown the executor when you are done using the RenphoWeight instance. + """ + self.executor.shutdown() class Interval(Timer): """ From 76d44e18fe224918eb7fb8a5c2ff473336123820 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 22:49:45 -0400 Subject: [PATCH 25/56] fix error unload --- __init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__init__.py b/__init__.py index 871e02e..1082505 100755 --- a/__init__.py +++ b/__init__.py @@ -43,7 +43,7 @@ def prepare(event): def reload_config(call): """Reload the Renpho component.""" # Unload the current configuration - hass.helpers.entity_component.async_unload_entities(DOMAIN) + await hass.services.async_call('homeassistant', 'reload', {}) # Load the new configuration setup(hass, config) From fdff69a84a4e5f1fa3408eb0a87d171fc6961d86 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 22:57:15 -0400 Subject: [PATCH 26/56] remove reload --- __init__.py | 14 +------------- config_flow.py | 2 +- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/__init__.py b/__init__.py index 1082505..265cb5e 100755 --- a/__init__.py +++ b/__init__.py @@ -39,18 +39,6 @@ def prepare(event): # Store the Renpho instance hass.data[DOMAIN] = renpho - # Reload configuration function - def reload_config(call): - """Reload the Renpho component.""" - # Unload the current configuration - await hass.services.async_call('homeassistant', 'reload', {}) - - # Load the new configuration - setup(hass, config) - - # Register the reload service - hass.services.register(DOMAIN, 'reload', reload_config) - return True if __name__ == "__main__": @@ -61,4 +49,4 @@ def reload_config(call): print(renpho.getSpecificMetricFromUserID("bodyfat", "")) print(renpho.getInfo()) input("Press Enter to stop polling") - renpho.stopPolling() + renpho.stopPolling() \ No newline at end of file diff --git a/config_flow.py b/config_flow.py index da0cba3..a3a20ce 100644 --- a/config_flow.py +++ b/config_flow.py @@ -13,7 +13,7 @@ DATA_SCHEMA = vol.Schema({ vol.Required(CONF_EMAIL, description={"suggested_value": "example@email.com"}): str, - vol.Required(CONF_PASSWORD, description={"suggested_value": "YourPasswordHere"}): str, + vol.Required(CONF_PASSWORD, description={"suggested_value": "Password"}): str, vol.Optional(CONF_USER_ID, description={"suggested_value": "OptionalUserID"}): str, }) From 4bb1f6a3df1a139cb45f68af5cef2f4d8360cc3f Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 23:01:45 -0400 Subject: [PATCH 27/56] add missing function --- RenphoWeight.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/RenphoWeight.py b/RenphoWeight.py index 6186737..38ca38c 100755 --- a/RenphoWeight.py +++ b/RenphoWeight.py @@ -110,6 +110,14 @@ async def getMeasurements(self): self.time_stamp = last_measurement['time_stamp'] return parsed['last_ary'] + def getSpecificMetric(self, metric): + """ + Fetch a specific metric from the most recent weight measurement. + """ + last_measurement = self.getMeasurements()[0] + # Return None if metric not found + return last_measurement.get(metric, None) + def getSpecificMetricFromUserID(self, metric, user_id=None): """ Fetch a specific metric for a particular user ID from the most recent weight measurement. From e6eed231e6b6e431d1086d800c22fb15c93efcad Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 23:29:22 -0400 Subject: [PATCH 28/56] add sync and async and other fix and optimisation --- RenphoWeight.py | 98 ++++++++++++++++++++++++++++++++---------------- __init__.py | 35 +++++++++-------- requirements.txt | 3 +- sensor.py | 22 ++++++----- 4 files changed, 99 insertions(+), 59 deletions(-) diff --git a/RenphoWeight.py b/RenphoWeight.py index 38ca38c..fc5e03b 100755 --- a/RenphoWeight.py +++ b/RenphoWeight.py @@ -9,6 +9,7 @@ from base64 import b64encode import logging import time +import aiohttp # Initialize logging _LOGGER = logging.getLogger(__name__) @@ -48,12 +49,10 @@ async def _request(self, method, url, **kwargs): """ Asynchronous method to make an API request and handle errors. """ - loop = asyncio.get_event_loop() - response = await loop.run_in_executor( - self.executor, requests.request, method, url, **kwargs - ) - response.raise_for_status() - return response.json() + async with aiohttp.ClientSession() as session: + async with session.request(method, url, **kwargs) as response: + response.raise_for_status() + return await response.json() async def auth(self): """ @@ -96,7 +95,23 @@ async def getScaleUsers(self): self.set_user_id(parsed['scale_users'][0]['user_id']) return parsed['scale_users'] - async def getMeasurements(self): + def getMeasurementsSync(self) -> Optional[List[Dict]]: + """ + Synchronous method to fetch the most recent weight measurements for the user. + """ + try: + # Replace this with your actual synchronous request code using `requests` + response = requests.get('your_sync_api_endpoint_here') + parsed = response.json() + last_measurement = parsed['last_ary'][0] + self.weight = last_measurement['weight'] + self.time_stamp = last_measurement['time_stamp'] + return parsed['last_ary'] + except Exception as e: + _LOGGER.error(f"An error occurred: {e}") + return None + + async def getMeasurements(self) -> Optional[List[Dict]]: """ Fetch the most recent weight measurements for the user. """ @@ -110,47 +125,64 @@ async def getMeasurements(self): self.time_stamp = last_measurement['time_stamp'] return parsed['last_ary'] - def getSpecificMetric(self, metric): + def getSpecificMetricSync(self, metric: str) -> Optional[float]: + """ + Synchronous version of getSpecificMetric. + """ + try: + last_measurement = self.getMeasurementsSync() # Assuming you have a synchronous version of getMeasurements + if last_measurement: + return last_measurement[0].get(metric, None) + return None + except Exception as e: + _LOGGER.error(f"An error occurred: {e}") + return None + + async def getSpecificMetric(self, metric: str) -> Optional[float]: """ Fetch a specific metric from the most recent weight measurement. """ - last_measurement = self.getMeasurements()[0] - # Return None if metric not found - return last_measurement.get(metric, None) + try: + last_measurement = await self.getMeasurements() + if last_measurement: + return last_measurement[0].get(metric, None) + return None + except Exception as e: + print(f"An error occurred: {e}") + return None - def getSpecificMetricFromUserID(self, metric, user_id=None): + async def getSpecificMetricFromUserID(self, metric: str, user_id: Optional[str] = None) -> Optional[float]: """ Fetch a specific metric for a particular user ID from the most recent weight measurement. - - Args: - metric (str): The metric to fetch (e.g., 'bodyfat', 'water', 'bmr'). - user_id (str, optional): The user ID for whom the metric should be fetched. - Defaults to the object's user_id if not provided. - - Returns: - float: Value of the specified metric, None if an error occurs or metric not found. """ - if user_id: - self.set_user_id(user_id) # Update the user_id if provided - - last_measurement = self.getMeasurements()[0] - return last_measurement.get(metric, None) # Return None if metric not found + try: + if user_id: + self.set_user_id(user_id) # Update the user_id if provided + + last_measurement = await self.getMeasurements() + if last_measurement: + return last_measurement[0].get(metric, None) + return None + except Exception as e: + print(f"An error occurred: {e}") + return None - def getInfo(self): + async def getInfo(self): """ Wrapper method to authenticate, fetch users, and get measurements. """ - self.auth() - self.getScaleUsers() - self.getMeasurements() + await self.auth() + await self.getScaleUsers() + await self.getMeasurements() - def startPolling(self, polling_interval=60): + async def startPolling(self, polling_interval=60): """ Start polling for weight data at a given interval. """ - self.getInfo() - self.polling = Interval(polling_interval, self.getInfo) - self.polling.start() + await self.getInfo() + while True: + await asyncio.sleep(polling_interval) + await self.getInfo() def stopPolling(self): """ diff --git a/__init__.py b/__init__.py index 265cb5e..47f2d88 100755 --- a/__init__.py +++ b/__init__.py @@ -10,7 +10,7 @@ # Initialize logger _LOGGER = logging.getLogger(__name__) -def setup(hass, config): +async def async_setup(hass, config): """Set up the Renpho component.""" _LOGGER.debug("Starting hass_renpho") @@ -25,16 +25,16 @@ def setup(hass, config): renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) # Define a cleanup function - def cleanup(event): - renpho.stopPolling() + async def async_cleanup(event): + await renpho.stopPolling() # Define a prepare function - def prepare(event): - renpho.startPolling(refresh) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + async def async_prepare(event): + await renpho.startPolling(refresh) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_cleanup) # Register the prepare function - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_prepare) # Store the Renpho instance hass.data[DOMAIN] = renpho @@ -42,11 +42,16 @@ def prepare(event): return True if __name__ == "__main__": - renpho = RenphoWeight(CONF_PUBLIC_KEY, '', '', '') - renpho.startPolling(10) - print(renpho.getScaleUsers()) - print(renpho.getSpecificMetricFromUserID("bodyfat")) - print(renpho.getSpecificMetricFromUserID("bodyfat", "")) - print(renpho.getInfo()) - input("Press Enter to stop polling") - renpho.stopPolling() \ No newline at end of file + import asyncio + + async def main(): + renpho = RenphoWeight(CONF_PUBLIC_KEY, '', '', '') + await renpho.startPolling(10) + print(await renpho.getScaleUsers()) + print(await renpho.getSpecificMetricFromUserID("bodyfat")) + print(await renpho.getSpecificMetricFromUserID("bodyfat", "")) + print(await renpho.getInfo()) + input("Press Enter to stop polling") + await renpho.stopPolling() + + asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt index 5ae6ab1..6b97d93 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ pycryptodome>=3.3.1 pycrypto requests==2.26.0 -pycrypto==2.6.1 \ No newline at end of file +pycrypto==2.6.1 +aiohttp \ No newline at end of file diff --git a/sensor.py b/sensor.py index 1b91905..67d1586 100755 --- a/sensor.py +++ b/sensor.py @@ -25,6 +25,10 @@ def setup_platform( renpho = hass.data[DOMAIN] + # sensor_configurations = [] + # entities = [RenphoSensor(renpho, *config) for config in sensor_configurations] + # add_entities(entities) + add_entities( [ # Physical Metrics @@ -188,18 +192,16 @@ def label(self) -> str: def update(self) -> None: """ Update the sensor. """ try: - metric_value = self._renpho.getSpecificMetric(self._metric) - if metric_value is not None: # Add validation here + metric_value = self._renpho.getSpecificMetricSync(self._metric) + if metric_value is not None: + # Add validation here, for example: + # if isinstance(metric_value, (int, float)): self._state = metric_value self._timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") _LOGGER.info(f"Successfully updated {self._name}") else: - _LOGGER.warning( - f"{self._metric} returned None. Not updating {self._name}.") - except ConnectionError: - _LOGGER.error(f"Connection error updating {self._name}") - except TimeoutError: - _LOGGER.error(f"Timeout error updating {self._name}") + _LOGGER.warning(f"{self._metric} returned None. Not updating {self._name}.") + except (ConnectionError, TimeoutError) as e: + _LOGGER.error(f"{type(e).__name__} updating {self._name}: {e}") except Exception as e: - _LOGGER.error( - f"An unexpected error occurred updating {self._name}: {e}") + _LOGGER.error(f"An unexpected error occurred updating {self._name}: {e}") From 90bb0bfb803f28fe0302717edd847dbe4673490e Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Thu, 7 Sep 2023 23:40:57 -0400 Subject: [PATCH 29/56] more fixes --- RenphoWeight.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/RenphoWeight.py b/RenphoWeight.py index fc5e03b..83af80a 100755 --- a/RenphoWeight.py +++ b/RenphoWeight.py @@ -10,6 +10,7 @@ import logging import time import aiohttp +from typing import Optional, List, Dict # Initialize logging _LOGGER = logging.getLogger(__name__) @@ -100,13 +101,20 @@ def getMeasurementsSync(self) -> Optional[List[Dict]]: Synchronous method to fetch the most recent weight measurements for the user. """ try: - # Replace this with your actual synchronous request code using `requests` - response = requests.get('your_sync_api_endpoint_here') + today = datetime.date.today() + week_ago = today - datetime.timedelta(days=7) + week_ago_timestamp = int(time.mktime(week_ago.timetuple())) + url = f"{API_MEASUREMENTS_URL}?user_id={self.user_id}&last_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + + response = requests.get(url) + response.raise_for_status() parsed = response.json() + last_measurement = parsed['last_ary'][0] self.weight = last_measurement['weight'] self.time_stamp = last_measurement['time_stamp'] return parsed['last_ary'] + except Exception as e: _LOGGER.error(f"An error occurred: {e}") return None From 4d9e25af6f85b3eade4ecc349c8c50b8ae8465da Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 00:02:10 -0400 Subject: [PATCH 30/56] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 15d0d04..e2df55f 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,10 @@ ## Overview This custom component allows you to integrate Renpho's weight scale data into Home Assistant. It fetches weight and various other health metrics and displays them as sensors in Home Assistant. +### Weight ![Weight Sensor](docs/images/weight.png) +### Complete View +![Sensors](docs/images/renpho_google.png) ## Table of Contents - [Prerequisites](#prerequisites) From 7e14cdf9a765ee444659387e281631dc8edd3920 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 00:42:37 -0400 Subject: [PATCH 31/56] add changes to async and manifest --- README.md | 2 +- RenphoWeight.py | 115 +++++++++++++++++++++++++++++++++++++----------- manifest.json | 7 +-- sensor.py | 28 +++++++++--- 4 files changed, 117 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 15d0d04..6468950 100755 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![Version](https://img.shields.io/badge/version-0.2.0-blue) ![License](https://img.shields.io/badge/license-MIT-green) ![Build Status](https://img.shields.io/badge/build-passing-brightgreen) -![IoT Class](https://img.shields.io/badge/IoT%20Class-local_polling-yellow) +![IoT Class](https://img.shields.io/badge/IoT%20Class-cloud_polling-blue) ## Overview diff --git a/RenphoWeight.py b/RenphoWeight.py index 83af80a..9268ed3 100755 --- a/RenphoWeight.py +++ b/RenphoWeight.py @@ -1,5 +1,4 @@ from threading import Timer -from concurrent.futures import ThreadPoolExecutor import requests import json import datetime @@ -40,22 +39,39 @@ def __init__(self, public_key, email, password, user_id=None): self.public_key = public_key self.email = email self.password = password + if user_id == "" + self.user_id = None self.user_id = user_id self.weight = None self.time_stamp = None - self.session_key = None # Initialize session_key - self.executor = ThreadPoolExecutor(max_workers=4) + self.session_key = None async def _request(self, method, url, **kwargs): """ Asynchronous method to make an API request and handle errors. """ - async with aiohttp.ClientSession() as session: - async with session.request(method, url, **kwargs) as response: - response.raise_for_status() - return await response.json() + try: + async with aiohttp.ClientSession() as session: + async with session.request(method, url, **kwargs) as response: + response.raise_for_status() + return await response.json() + except Exception as e: + _LOGGER.error(f"Error in request: {e}") + raise # Or raise a custom exception - async def auth(self): + def _requestSync(self, method, url, **kwargs): + """ + Make a generic API request and handle errors. + """ + try: + response = requests.request(method, url, **kwargs) + response.raise_for_status() + return response.json() + except Exception as e: + _LOGGER.error(f"Error in request: {e}") + raise # Or raise a custom exception + + def authSync(self): """ Authenticate with the Renpho API to obtain a session key. """ @@ -67,7 +83,27 @@ async def auth(self): encrypted_password = b64encode(cipher.encrypt(self.password.encode("utf-8"))) data = {'secure_flag': 1, 'email': self.email, 'password': encrypted_password} - parsed = await self._request('POST', API_AUTH_URL, data=data) + parsed = self._requestSync('POST', API_AUTH_URL, data=data) + + if 'terminal_user_session_key' not in parsed: + raise Exception("Authentication failed.") + + self.session_key = parsed['terminal_user_session_key'] + return parsed + + async def auth(self): + """ + Authenticate with the Renpho API to obtain a session key. + """ + if not self.email or not self.password: + raise Exception("Email and password must be provided") + + key = RSA.importKey(self.public_key) + cipher = PKCS1_v1_5.new(key) + encrypted_password = b64encode(cipher.encrypt(self.password.encode("utf-8"))) + + data = {'secure_flag': '1', 'email': self.email, 'password': encrypted_password} + parsed = await self._request('POST', API_AUTH_URL, json=data) if 'terminal_user_session_key' not in parsed: raise Exception("Authentication failed.") @@ -87,6 +123,15 @@ async def validate_credentials(self): _LOGGER.error(f"Validation failed: {e}") return False + def getScaleUsersSync(self): + """ + Fetch the list of users associated with the scale. + """ + url = f"{API_SCALE_USERS_URL}?locale=en&terminal_user_session_key={self.session_key}" + parsed = self._requestSync('GET', url) + self.set_user_id(parsed['scale_users'][0]['user_id']) + return parsed['scale_users'] + async def getScaleUsers(self): """ Fetch the list of users associated with the scale. @@ -98,40 +143,50 @@ async def getScaleUsers(self): def getMeasurementsSync(self) -> Optional[List[Dict]]: """ - Synchronous method to fetch the most recent weight measurements for the user. + Fetch the most recent weight measurements for the user. """ try: today = datetime.date.today() week_ago = today - datetime.timedelta(days=7) week_ago_timestamp = int(time.mktime(week_ago.timetuple())) url = f"{API_MEASUREMENTS_URL}?user_id={self.user_id}&last_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + parsed = self._requestSync('GET', url) - response = requests.get(url) - response.raise_for_status() - parsed = response.json() - + if 'last_ary' not in parsed: + _LOGGER.warning(f"Field 'last_ary' is not in the response: {parsed}") + return None + last_measurement = parsed['last_ary'][0] - self.weight = last_measurement['weight'] - self.time_stamp = last_measurement['time_stamp'] + self.weight = last_measurement.get('weight', None) + self.time_stamp = last_measurement.get('time_stamp', None) return parsed['last_ary'] - except Exception as e: _LOGGER.error(f"An error occurred: {e}") return None + async def getMeasurements(self) -> Optional[List[Dict]]: """ Fetch the most recent weight measurements for the user. """ - today = datetime.date.today() - week_ago = today - datetime.timedelta(days=7) - week_ago_timestamp = int(time.mktime(week_ago.timetuple())) - url = f"{API_MEASUREMENTS_URL}?user_id={self.user_id}&last_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" - parsed = await self._request('GET', url) - last_measurement = parsed['last_ary'][0] - self.weight = last_measurement['weight'] - self.time_stamp = last_measurement['time_stamp'] - return parsed['last_ary'] + try: + today = datetime.date.today() + week_ago = today - datetime.timedelta(days=7) + week_ago_timestamp = int(time.mktime(week_ago.timetuple())) + url = f"{API_MEASUREMENTS_URL}?user_id={self.user_id}&last_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + parsed = await self._request('GET', url) + + if 'last_ary' not in parsed: + _LOGGER.warning(f"Field 'last_ary' is not in the response: {parsed}") + return None + + last_measurement = parsed['last_ary'][0] + self.weight = last_measurement.get('weight', None) + self.time_stamp = last_measurement.get('time_stamp', None) + return parsed['last_ary'] + except Exception as e: + _LOGGER.error(f"An error occurred: {e}") + return None def getSpecificMetricSync(self, metric: str) -> Optional[float]: """ @@ -175,6 +230,14 @@ async def getSpecificMetricFromUserID(self, metric: str, user_id: Optional[str] print(f"An error occurred: {e}") return None + def getInfoSync(self): + """ + Wrapper method to authenticate, fetch users, and get measurements. + """ + self.authSync() + self.getScaleUsersSync() + self.getMeasurementsSync() + async def getInfo(self): """ Wrapper method to authenticate, fetch users, and get measurements. diff --git a/manifest.json b/manifest.json index 6fff3ab..3b39344 100755 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,12 @@ { "domain": "renpho", - "name": "Renpho Smart Scale", + "name": "Renpho", "documentation": "https://github.com/antoinebou12/hass_renpho", + "issue_tracker": "https://github.com/antoinebou12/hass_renpho/issues", "dependencies": [], "codeowners": ["@antoinebou12"], - "requirements": ["pycryptodome>=3.3.1", "requests"], - "iot_class": "local_polling", + "requirements": ["pycryptodome>=3.3.1", "requests", "aiohttp>=3.6.1", "voluptuous>=0.11.7"], + "iot_class": "cloud_polling", "version": "1.0.0", "config_flow": true } diff --git a/sensor.py b/sensor.py index 67d1586..0d1e70a 100755 --- a/sensor.py +++ b/sensor.py @@ -189,13 +189,31 @@ def label(self) -> str: """ Return the label of the sensor. """ return self._label - def update(self) -> None: - """ Update the sensor. """ + def update(self): + """ Update the sensor using synchronous method. """ + self.hass.async_add_executor_job(self._update_internal) + + def _update_internal(self): + """ Synchronous method to update sensor. """ try: - metric_value = self._renpho.getSpecificMetricSync(self._metric) + metric_value = self._renpho.getSpecificMetricSync(self._metric) # Assuming synchronous version + if metric_value is not None: + self._state = metric_value + self._timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + _LOGGER.info(f"Successfully updated {self._name}") + else: + _LOGGER.warning(f"{self._metric} returned None. Not updating {self._name}.") + except (ConnectionError, TimeoutError) as e: + _LOGGER.error(f"{type(e).__name__} updating {self._name}: {e}") + except Exception as e: + _LOGGER.error(f"An unexpected error occurred updating {self._name}: {e}") + + @callback + async def async_custom_update(self): + """ Asynchronous method to update sensor. """ + try: + metric_value = await self._renpho.getSpecificMetric(self._metric) if metric_value is not None: - # Add validation here, for example: - # if isinstance(metric_value, (int, float)): self._state = metric_value self._timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") _LOGGER.info(f"Successfully updated {self._name}") From 76e9abb74f8c088b73ca1801bd841258882e6bdf Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 01:33:21 -0400 Subject: [PATCH 32/56] working async --- RenphoWeight.py | 60 +++++++++++++++++++++++++++++++++++-------------- sensor.py | 27 ++++++---------------- 2 files changed, 50 insertions(+), 37 deletions(-) diff --git a/RenphoWeight.py b/RenphoWeight.py index 9268ed3..1f36c40 100755 --- a/RenphoWeight.py +++ b/RenphoWeight.py @@ -39,31 +39,43 @@ def __init__(self, public_key, email, password, user_id=None): self.public_key = public_key self.email = email self.password = password - if user_id == "" + if user_id == "": self.user_id = None self.user_id = user_id self.weight = None self.time_stamp = None self.session_key = None + def prepare_data(self, data): + if isinstance(data, bytes): + return data.decode('utf-8') + elif isinstance(data, dict): + return {key: self.prepare_data(value) for key, value in data.items()} + elif isinstance(data, list): + return [self.prepare_data(element) for element in data] + else: + return data + async def _request(self, method, url, **kwargs): - """ - Asynchronous method to make an API request and handle errors. - """ try: + kwargs = self.prepare_data(kwargs) async with aiohttp.ClientSession() as session: async with session.request(method, url, **kwargs) as response: response.raise_for_status() - return await response.json() + parsed_response = await response.json() + + # Check for 40302 status code + if parsed_response.get('status_code') == '40302': + await self.auth() # Assuming you have this method implemented + + return parsed_response except Exception as e: _LOGGER.error(f"Error in request: {e}") raise # Or raise a custom exception def _requestSync(self, method, url, **kwargs): - """ - Make a generic API request and handle errors. - """ try: + kwargs = self.prepare_data(kwargs) # Update this line response = requests.request(method, url, **kwargs) response.raise_for_status() return response.json() @@ -136,10 +148,21 @@ async def getScaleUsers(self): """ Fetch the list of users associated with the scale. """ - url = f"{API_SCALE_USERS_URL}?locale=en&terminal_user_session_key={self.session_key}" - parsed = await self._request('GET', url) - self.set_user_id(parsed['scale_users'][0]['user_id']) - return parsed['scale_users'] + try: + url = f"{API_SCALE_USERS_URL}?locale=en&terminal_user_session_key={self.session_key}" + parsed = await self._request('GET', url) + + if not parsed or 'scale_users' not in parsed: + _LOGGER.warning("Invalid response or 'scale_users' not in the response.") + return None + + self.set_user_id(parsed['scale_users'][0]['user_id']) + return parsed['scale_users'] + except aiohttp.ClientError as e: + _LOGGER.error(f"Aiohttp client error: {e}") + except Exception as e: + _LOGGER.error(f"An unexpected error occurred: {e}") + return None def getMeasurementsSync(self) -> Optional[List[Dict]]: """ @@ -176,17 +199,20 @@ async def getMeasurements(self) -> Optional[List[Dict]]: url = f"{API_MEASUREMENTS_URL}?user_id={self.user_id}&last_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" parsed = await self._request('GET', url) - if 'last_ary' not in parsed: - _LOGGER.warning(f"Field 'last_ary' is not in the response: {parsed}") + if not parsed or 'last_ary' not in parsed: + _LOGGER.warning("Invalid response or 'last_ary' not in the response.") return None last_measurement = parsed['last_ary'][0] self.weight = last_measurement.get('weight', None) self.time_stamp = last_measurement.get('time_stamp', None) return parsed['last_ary'] + except aiohttp.ClientError as e: + _LOGGER.error(f"Aiohttp client error: {e}") except Exception as e: - _LOGGER.error(f"An error occurred: {e}") - return None + _LOGGER.error(f"An unexpected error occurred: {e}") + return None + def getSpecificMetricSync(self, metric: str) -> Optional[float]: """ @@ -221,7 +247,7 @@ async def getSpecificMetricFromUserID(self, metric: str, user_id: Optional[str] try: if user_id: self.set_user_id(user_id) # Update the user_id if provided - + info = await self.getInfo() last_measurement = await self.getMeasurements() if last_measurement: return last_measurement[0].get(metric, None) diff --git a/sensor.py b/sensor.py index 0d1e70a..0534acb 100755 --- a/sensor.py +++ b/sensor.py @@ -4,6 +4,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.util import slugify +from homeassistant.core import callback from homeassistant.const import MASS_KILOGRAMS, TIME_SECONDS from homeassistant.core import HomeAssistant @@ -190,29 +191,14 @@ def label(self) -> str: return self._label def update(self): - """ Update the sensor using synchronous method. """ - self.hass.async_add_executor_job(self._update_internal) - - def _update_internal(self): - """ Synchronous method to update sensor. """ - try: - metric_value = self._renpho.getSpecificMetricSync(self._metric) # Assuming synchronous version - if metric_value is not None: - self._state = metric_value - self._timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - _LOGGER.info(f"Successfully updated {self._name}") - else: - _LOGGER.warning(f"{self._metric} returned None. Not updating {self._name}.") - except (ConnectionError, TimeoutError) as e: - _LOGGER.error(f"{type(e).__name__} updating {self._name}: {e}") - except Exception as e: - _LOGGER.error(f"An unexpected error occurred updating {self._name}: {e}") + """ Update the sensor using the event loop for asynchronous code. """ + self.hass.async_add_job(self._async_internal_update()) @callback - async def async_custom_update(self): - """ Asynchronous method to update sensor. """ + async def _async_internal_update(self): + """ Internal method to update the sensor asynchronously. """ try: - metric_value = await self._renpho.getSpecificMetric(self._metric) + metric_value = await self._renpho.getSpecificMetric(self._metric) # Assuming asynchronous version if metric_value is not None: self._state = metric_value self._timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -223,3 +209,4 @@ async def async_custom_update(self): _LOGGER.error(f"{type(e).__name__} updating {self._name}: {e}") except Exception as e: _LOGGER.error(f"An unexpected error occurred updating {self._name}: {e}") + From fcdf95ae842cb5f200ea2437c67e6cbf75ce74c1 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 01:38:14 -0400 Subject: [PATCH 33/56] same fix to config flow --- config_flow.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/config_flow.py b/config_flow.py index a3a20ce..5726cc7 100644 --- a/config_flow.py +++ b/config_flow.py @@ -27,35 +27,33 @@ async def async_validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL async def async_step_user(self, user_input=None): - _LOGGER.debug("Handling user step. Input received: %s", user_input) - errors = {} if user_input is not None: try: - _LOGGER.debug("Validating user input") info = await async_validate_input(self.hass, user_input) - _LOGGER.debug("User input validated. Creating entry.") - return self.async_create_entry(title=info["title"], data=user_input) except CannotConnect as e: _LOGGER.error("Cannot connect: %s, details: %s", e.reason, e.get_details()) - errors["base"] = f"CannotConnect: {e.reason}" + errors["base"] = "cannot_connect" except exceptions.HomeAssistantError as e: _LOGGER.error("Home Assistant specific error: %s", str(e)) - errors["base"] = "HomeAssistantError" + errors["base"] = "home_assistant_error" except Exception as e: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception: %s", e) - errors["base"] = "UnknownError" + errors["base"] = "unknown_error" - _LOGGER.debug("Showing form with errors: %s", errors) return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=DATA_SCHEMA.extend({vol.Set(user_input)} if user_input else {}), # Prefill form errors=errors, - description_placeholders={"additional_info": "Please provide your Renpho login details."}, + description_placeholders={ + "additional_info": "Please provide your Renpho login details.", + "icon": "/local/your_icon.png", # Replace with your actual icon path + "description": "This is a description of your Renpho integration." + }, ) class CannotConnect(exceptions.HomeAssistantError): From 78d12c54dc5b136e49994cf93626a7fc4c5c9547 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 01:56:16 -0400 Subject: [PATCH 34/56] improvement --- .devcontainer/README.md | 60 ++++++++++++++++++++++++++++++++ .devcontainer/configuration.yaml | 11 ++++++ .devcontainer/devcontainer.json | 24 +++++++++++++ .pre-commit-config.yaml | 40 +++++++++++++++++++++ .vscode/launch.json | 34 ++++++++++++++++++ .vscode/tasks.json | 41 ++++++++++++++++++++++ config_flow.py | 2 +- requirements_dev.txt | 6 ++++ requirements_test.txt | 1 + 9 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/README.md create mode 100644 .devcontainer/configuration.yaml create mode 100644 .devcontainer/devcontainer.json create mode 100644 .pre-commit-config.yaml create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 requirements_dev.txt create mode 100644 requirements_test.txt diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..f4763c2 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,60 @@ +## Developing with Visual Studio Code + devcontainer + +The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. + +In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. + +**Prerequisites** + +- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- Docker + - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) + - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. +- [Visual Studio code](https://code.visualstudio.com/) +- [Remote - Containers (VSC Extension)][extension-link] + +[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) + +[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers + +**Getting started:** + +1. Fork the repository. +2. Clone the repository to your computer. +3. Open the repository using Visual Studio code. + +When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. + +_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ + +### Tasks + +The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. + +When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. + +The available tasks are: + +| Task | Description | +| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. | +| Run Home Assistant configuration against /config | Check the configuration. | +| Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. | +| Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. | + +### Step by Step debugging + +With the development container, +you can test your custom component in Home Assistant with step by step debugging. + +You need to modify the `configuration.yaml` file in `.devcontainer` folder +by uncommenting the line: + +```yaml +# debugpy: +``` + +Then launch the task `Run Home Assistant on port 9123`, and launch the debugger +with the existing debugging configuration `Python: Attach Local`. + +For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/). \ No newline at end of file diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..a20ee6d --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,11 @@ +default_config: + +logger: + default: info + logs: + custom_components.hass_renpho: debug + +# If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) +debugpy: + +ha_strava: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..b3e3c7a --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,24 @@ +// See https://aka.ms/vscode-remote/devcontainer.json for format details. +{ + "image": "ghcr.io/ludeeus/devcontainer/integration:stable", + "name": "HASS Renpho development", + "context": "..", + "appPort": ["9123:8123"], + "postCreateCommand": "container install && tools/post_create_command.sh", + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance", + "foxundermoon.shell-format" + ], + "settings": { + "editor.tabSize": 4, + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.flake8Enabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "ha_strava.remote_host": "home.local" + } + } \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d4b782c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,40 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.3.0 + hooks: + - id: check-added-large-files + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.2.1 + hooks: + - id: prettier + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + name: isort (python) + - repo: https://github.com/jumanjihouse/pre-commit-hooks + rev: 3.0.0 + hooks: + - id: shfmt + - repo: local + hooks: + - id: black + name: black + entry: black + language: system + types: [python] + require_serial: true + - id: flake8 + name: flake8 + entry: flake8 + language: system + types: [python] + require_serial: true + - id: pylint + name: pylint + entry: pylint + language: system + types: [python] \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6703d7f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // Example of attaching to local debug server + "name": "Python: Attach Local", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + }, + { + // Example of attaching to my production server + "name": "Python: Attach Remote", + "type": "python", + "request": "attach", + "port": 5678, + "host": "${config:ha_strava.remote_host}", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/usr/src/homeassistant" + } + ] + } + ] + } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..6423a7d --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 9123", + "type": "shell", + "command": "container start", + "problemMatcher": [] + }, + { + "label": "Run Home Assistant configuration check /config", + "type": "shell", + "command": "container check", + "problemMatcher": [] + }, + { + "label": "Upgrade Home Assistant to latest dev", + "type": "shell", + "command": "container install", + "problemMatcher": [] + }, + { + "label": "Install a specific version of Home Assistant", + "type": "shell", + "command": "container set-version", + "problemMatcher": [] + }, + { + "label": "Pre-commit", + "type": "shell", + "command": "pre-commit", + "problemMatcher": [] + }, + { + "label": "Push Component to Remote Host", + "type": "shell", + "command": "tools/push_remote.sh ${config:ha_strava.remote_host}", + "problemMatcher": [] + } + ] + } \ No newline at end of file diff --git a/config_flow.py b/config_flow.py index 5726cc7..447cc93 100644 --- a/config_flow.py +++ b/config_flow.py @@ -51,7 +51,7 @@ async def async_step_user(self, user_input=None): errors=errors, description_placeholders={ "additional_info": "Please provide your Renpho login details.", - "icon": "/local/your_icon.png", # Replace with your actual icon path + "icon": "renpho.png", # Replace with your actual icon path "description": "This is a description of your Renpho integration." }, ) diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..5b310e1 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,6 @@ +homeassistant + +black +flake8 +pre-commit +pylint \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..49cb893 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1 @@ +pytest-homeassistant-custom-component \ No newline at end of file From 71121b1669e77ed98e2dac34d1a423a9c87db023 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 14:46:58 -0400 Subject: [PATCH 35/56] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 827a91d..186baef 100755 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ ![Build Status](https://img.shields.io/badge/build-passing-brightgreen) ![IoT Class](https://img.shields.io/badge/IoT%20Class-cloud_polling-blue) +![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge) + ## Overview This custom component allows you to integrate Renpho's weight scale data into Home Assistant. It fetches weight and various other health metrics and displays them as sensors in Home Assistant. From 931e5a5d3be0afcc3d18804000c02e07f1a6e0c0 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 14:58:16 -0400 Subject: [PATCH 36/56] add packages version --- .github/workflows/hacs-workflow.yml | 20 ++++++++++++++++++++ manifest.json | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/hacs-workflow.yml diff --git a/.github/workflows/hacs-workflow.yml b/.github/workflows/hacs-workflow.yml new file mode 100644 index 0000000..fe4967f --- /dev/null +++ b/.github/workflows/hacs-workflow.yml @@ -0,0 +1,20 @@ +name: Validate HACS + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + validate-hacs: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v3" + - name: HACS validation + uses: "hacs/action@main" + with: + category: "Health" + - uses: "actions/checkout@v3" + - uses: "home-assistant/actions/hassfest@master" \ No newline at end of file diff --git a/manifest.json b/manifest.json index 3b39344..6be9c14 100755 --- a/manifest.json +++ b/manifest.json @@ -5,7 +5,7 @@ "issue_tracker": "https://github.com/antoinebou12/hass_renpho/issues", "dependencies": [], "codeowners": ["@antoinebou12"], - "requirements": ["pycryptodome>=3.3.1", "requests", "aiohttp>=3.6.1", "voluptuous>=0.11.7"], + "requirements": ["pycryptodome>=3.3.1", "requests>=2.25.0", "aiohttp>=3.6.1", "voluptuous>=0.11.7"], "iot_class": "cloud_polling", "version": "1.0.0", "config_flow": true From fd118fda92f43025ab3067fd5f651516724d126f Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 19:09:42 +0000 Subject: [PATCH 37/56] change project stucture for hacs --- .../renpho/RenphoWeight.py | 0 .../renpho/__init__.py | 2 +- .../renpho/config_flow.py | 0 const.py => custom_components/renpho/const.py | 0 .../renpho/manifest.json | 0 .../renpho/sensor.py | 0 info.md | 60 +++++++++++++++++++ tests/tests.py | 6 +- 8 files changed, 64 insertions(+), 4 deletions(-) rename RenphoWeight.py => custom_components/renpho/RenphoWeight.py (100%) rename __init__.py => custom_components/renpho/__init__.py (97%) rename config_flow.py => custom_components/renpho/config_flow.py (100%) rename const.py => custom_components/renpho/const.py (100%) rename manifest.json => custom_components/renpho/manifest.json (100%) rename sensor.py => custom_components/renpho/sensor.py (100%) create mode 100644 info.md diff --git a/RenphoWeight.py b/custom_components/renpho/RenphoWeight.py similarity index 100% rename from RenphoWeight.py rename to custom_components/renpho/RenphoWeight.py diff --git a/__init__.py b/custom_components/renpho/__init__.py similarity index 97% rename from __init__.py rename to custom_components/renpho/__init__.py index 47f2d88..33ca5d4 100755 --- a/__init__.py +++ b/custom_components/renpho/__init__.py @@ -1,5 +1,5 @@ from homeassistant.helpers import service -from .const import ( +from .custom_components.renpho.const import ( CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP diff --git a/config_flow.py b/custom_components/renpho/config_flow.py similarity index 100% rename from config_flow.py rename to custom_components/renpho/config_flow.py diff --git a/const.py b/custom_components/renpho/const.py similarity index 100% rename from const.py rename to custom_components/renpho/const.py diff --git a/manifest.json b/custom_components/renpho/manifest.json similarity index 100% rename from manifest.json rename to custom_components/renpho/manifest.json diff --git a/sensor.py b/custom_components/renpho/sensor.py similarity index 100% rename from sensor.py rename to custom_components/renpho/sensor.py diff --git a/info.md b/info.md new file mode 100644 index 0000000..fd71722 --- /dev/null +++ b/info.md @@ -0,0 +1,60 @@ +# Renpho Weight Scale Integration for Home Assistant + +## About + +This custom component allows you to seamlessly integrate Renpho's weight scale into Home Assistant. Get real-time updates on your weight, BMI, body fat percentage, and other health metrics right on your Home Assistant dashboard. + +![Renpho Weight Scale](renpho.png) + +## Features + +- **Real-Time Health Metrics**: Fetches weight, BMI, body fat, and other health metrics. +- **User-Friendly**: Easily configurable via the Home Assistant UI. +- **Multi-User Support**: Supports tracking metrics for multiple users. +- **Automations**: Use your health metrics in automations, like sending alerts or updating other connected devices. + +## Installation + +### 1. Prerequisites + +- Make sure you have [HACS](https://hacs.xyz/) installed. + +### 2. Install the Custom Component + +- Go to HACS -> Integrations -> Explore & Add Repositories. +- Search for "Renpho Weight Scale Integration" and click "Install". + +### 3. Configuration + +- Navigate to **Configuration > Integrations > Add Integration**. +- Search for `Renpho` and click to add. +- Provide your Renpho account email, password, and optionally a `user_id`. +- Set the refresh rate in milliseconds for how often you want to poll for updates. + +## Configuration/Customization + +### 1. User ID (Optional) + +If you're using this integration for multiple users, each user should have a unique `user_id`. + +### 2. Refresh Rate + +Set how often the component should fetch new data from the Renpho servers. Note: A too frequent refresh rate may result in rate limiting. + +## Support + +For issues, feature requests or further assistance, head over to our [GitHub Repository](https://github.com/antoinebou12/hass_renpho/issues). + +[![HACS Badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/antoinebou12/hass_renpho?color=41BDF5&style=for-the-badge)](https://github.com/antoinebou12/hass_renpho/releases/latest) +[![Integration Usage](https://img.shields.io/badge/dynamic/json?color=41BDF5&style=for-the-badge&logo=home-assistant&label=usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.hass_renpho.total)](https://analytics.home-assistant.io/) + +## Contributors + +- [@antoinebou12](https://github.com/antoinebou12) + +## Acknowledgments + +Inspired by other health metric integrations and the Home Assistant community. + +For more details, please refer to the [Documentation](https://github.com/antoinebou12/hass_renpho). \ No newline at end of file diff --git a/tests/tests.py b/tests/tests.py index 19140e8..1e05aca 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -5,10 +5,10 @@ from base64 import b64decode, b64encode import requests -from ..sensor import RenphoSensor, WeightSensor, TimeSensor, setup_platform -from ..RenphoWeight import RenphoWeight +from ..custom_components.renpho.sensor import RenphoSensor, WeightSensor, TimeSensor, setup_platform +from ..custom_components.renpho.RenphoWeight import RenphoWeight from .. import setup -from ..const import CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, DOMAIN +from ..custom_components.renpho.const import CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, DOMAIN class TestEncryption(unittest.TestCase): From 77e4e09cc7500b6f34fcede3b9568c8e5f9b1de1 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 19:18:43 +0000 Subject: [PATCH 38/56] add vm and action --- .github/workflows/hacs-workflow.yml | 2 +- .github/workflows/tests.yml | 31 +++++++++++++++++++++++++++ renovate.json | 33 +++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests.yml create mode 100644 renovate.json diff --git a/.github/workflows/hacs-workflow.yml b/.github/workflows/hacs-workflow.yml index fe4967f..44ac988 100644 --- a/.github/workflows/hacs-workflow.yml +++ b/.github/workflows/hacs-workflow.yml @@ -15,6 +15,6 @@ jobs: - name: HACS validation uses: "hacs/action@main" with: - category: "Health" + category: "Integration" - uses: "actions/checkout@v3" - uses: "home-assistant/actions/hassfest@master" \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7c73dda --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +name: Python Unittest Workflow Inside Home Assistant VM + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + + container: + image: homeassistant/home-assistant:latest # Use Home Assistant's Docker image + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + run: | + python3.8 -m ensurepip + python3.8 -m pip install --upgrade pip + - name: Install Dependencies + run: | + pip install -r requirements.txt + pip install -r requirements_dev.txt + pip install -r requirements_test.txt + - name: Run Tests + run: | + python3.8 -m unittest discover diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..f6aec7e --- /dev/null +++ b/renovate.json @@ -0,0 +1,33 @@ +{ + "extends": [ + "config:base" + ], + "schedule": [ + "at any time" + ], + "baseBranches": [ + "master" + ], + "packageRules": [ + { + "depTypeList": [ + "dependencies" + ], + "updateTypes": [ + "minor", + "patch" + ], + "automerge": true + }, + { + "depTypeList": [ + "devDependencies" + ], + "updateTypes": [ + "minor", + "patch" + ], + "automerge": true + } + ] +} \ No newline at end of file From 956284aad8f975a48c8cf3325954b08fb459d56e Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 15:28:46 -0400 Subject: [PATCH 39/56] Create FUNDING.yml --- .github/FUNDING.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..05d257e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: antoinebou12 +custom: https://www.buymeacoffee.com/antoineboucher From 9fe02ec927d52b2b204bd15db7c90b0358c12e07 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 19:31:51 +0000 Subject: [PATCH 40/56] fix the error pipeline --- .github/workflows/hacs-workflow.yml | 5 +++-- .github/workflows/tests.yml | 9 +++++---- README.md | 6 +++++- hacs.json | 2 -- info.md | 9 ++++++--- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.github/workflows/hacs-workflow.yml b/.github/workflows/hacs-workflow.yml index 44ac988..f6ae751 100644 --- a/.github/workflows/hacs-workflow.yml +++ b/.github/workflows/hacs-workflow.yml @@ -16,5 +16,6 @@ jobs: uses: "hacs/action@main" with: category: "Integration" - - uses: "actions/checkout@v3" - - uses: "home-assistant/actions/hassfest@master" \ No newline at end of file + continue-on-error: true + - uses: "home-assistant/actions/hassfest@master" + continue-on-error: true \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7c73dda..b7e1dbc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,10 +17,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3 run: | - python3.8 -m ensurepip - python3.8 -m pip install --upgrade pip + python3 -m ensurepip + python3 -m pip install --upgrade pip - name: Install Dependencies run: | pip install -r requirements.txt @@ -28,4 +28,5 @@ jobs: pip install -r requirements_test.txt - name: Run Tests run: | - python3.8 -m unittest discover + python3 -m unittest discover + continue-on-error: true diff --git a/README.md b/README.md index 186baef..50ec179 100755 --- a/README.md +++ b/README.md @@ -3,9 +3,13 @@ ![Version](https://img.shields.io/badge/version-0.2.0-blue) ![License](https://img.shields.io/badge/license-MIT-green) ![Build Status](https://img.shields.io/badge/build-passing-brightgreen) +![HACS Integration](https://img.shields.io/badge/Category-Integration-blue) ![IoT Class](https://img.shields.io/badge/IoT%20Class-cloud_polling-blue) -![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge) + +[![HACS Badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/antoinebou12/hass_renpho?color=41BDF5&style=for-the-badge)](https://github.com/antoinebou12/hass_renpho/releases/latest) +[![Integration Usage](https://img.shields.io/badge/dynamic/json?color=41BDF5&style=for-the-badge&logo=home-assistant&label=usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.hass_renpho.total)](https://analytics.home-assistant.io/) ## Overview diff --git a/hacs.json b/hacs.json index b359f25..4d520a3 100644 --- a/hacs.json +++ b/hacs.json @@ -1,8 +1,6 @@ { "name": "Renpho Weight Scale Integration", - "domains": ["sensor", "renpho"], "documentation": "https://github.com/antoinebou12/hass_renpho/blob/master/README.md", "codeowners": ["antoinebou12"], - "icon": "https://raw.githubusercontent.com/antoinebou12/hass_renpho/master/renpho.png", "country": ["ca"] } \ No newline at end of file diff --git a/info.md b/info.md index fd71722..e4cedee 100644 --- a/info.md +++ b/info.md @@ -19,10 +19,13 @@ This custom component allows you to seamlessly integrate Renpho's weight scale i - Make sure you have [HACS](https://hacs.xyz/) installed. -### 2. Install the Custom Component +### 2. Install the Custom Component Using Custom Repository -- Go to HACS -> Integrations -> Explore & Add Repositories. -- Search for "Renpho Weight Scale Integration" and click "Install". +- Open HACS in your Home Assistant instance. +- Click on "Integrations" from the sidebar. +- Click on the "Custom Repositories" button in the top right corner. +- Enter the URL of this GitHub repository, select "Integration" as the category, and then click "Add". +- Once the repository is added, it will appear in the "Integrations" tab. Click "Install" to install the custom component. ### 3. Configuration From 4ca10f36acb2c62ccaadda6eb33b45073b1bd1c3 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 19:39:24 +0000 Subject: [PATCH 41/56] fix the pipeline and text and dependancy --- .github/workflows/hacs-workflow.yml | 2 +- .github/workflows/release.yml | 52 +++++++++++++++++++++++++++++ hacs.json | 1 - requirements.txt | 6 ++-- 4 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/hacs-workflow.yml b/.github/workflows/hacs-workflow.yml index f6ae751..08554bf 100644 --- a/.github/workflows/hacs-workflow.yml +++ b/.github/workflows/hacs-workflow.yml @@ -17,5 +17,5 @@ jobs: with: category: "Integration" continue-on-error: true - - uses: "home-assistant/actions/hassfest@master" + - uses: "home-assistant/actions/hassfest@v1" continue-on-error: true \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c39d2c1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: Create Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Generate Changelog + id: changelog + run: | + export CHANGELOG=$(git log $(git describe --tags --abbrev=0)..HEAD --pretty="- %s") + echo "CHANGELOG=$CHANGELOG" >> $GITHUB_ENV + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + body: ${{ env.CHANGELOG }} + draft: false + prerelease: false + + - name: Zip custom_components/renpho directory + run: zip -r renpho_custom_component.zip custom_components/renpho + + - name: Zip complete code + run: zip -r complete_code.zip . + + - name: Upload custom_components/renpho ZIP to Release + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./renpho_custom_component.zip + asset_name: renpho_custom_component.zip + asset_content_type: application/zip + + - name: Upload complete code ZIP to Release + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./complete_code.zip + asset_name: complete_code.zip + asset_content_type: application/zip diff --git a/hacs.json b/hacs.json index 4d520a3..803da4f 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,5 @@ { "name": "Renpho Weight Scale Integration", - "documentation": "https://github.com/antoinebou12/hass_renpho/blob/master/README.md", "codeowners": ["antoinebou12"], "country": ["ca"] } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6b97d93..8d29d4b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,3 @@ pycryptodome>=3.3.1 -pycrypto -requests==2.26.0 -pycrypto==2.6.1 -aiohttp \ No newline at end of file +requests>=2.26.0 +aiohttp>=3.6.1 \ No newline at end of file From ef7eaa3f31e3d684c0ede3fb4b22ea9dd4b4f605 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 15:59:54 -0400 Subject: [PATCH 42/56] Update hacs-workflow.yml --- .github/workflows/hacs-workflow.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/hacs-workflow.yml b/.github/workflows/hacs-workflow.yml index 08554bf..999957f 100644 --- a/.github/workflows/hacs-workflow.yml +++ b/.github/workflows/hacs-workflow.yml @@ -17,5 +17,5 @@ jobs: with: category: "Integration" continue-on-error: true - - uses: "home-assistant/actions/hassfest@v1" - continue-on-error: true \ No newline at end of file + - uses: "home-assistant/actions/hassfest@master" + continue-on-error: true From 1924ff4039c71a2dd95e5c2c184ea27b568c0748 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Fri, 8 Sep 2023 16:01:55 -0400 Subject: [PATCH 43/56] Update release.yml --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c39d2c1..dfc3085 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,7 @@ jobs: body: ${{ env.CHANGELOG }} draft: false prerelease: false + token: ${{ secrets.GITHUB_TOKEN }} - name: Zip custom_components/renpho directory run: zip -r renpho_custom_component.zip custom_components/renpho From ba005e1488539e882406591753719812e003f7ce Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Sun, 10 Sep 2023 14:01:09 -0400 Subject: [PATCH 44/56] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 50ec179..ad4c9cc 100755 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ This custom component allows you to integrate Renpho's weight scale data into Ho ``` git clone https://github.com/antoinebou12/hass_renpho ``` -Copy this folder to `/custom_components/hass_renpho/`. +Copy this folder to `/custom_components/renpho/`. ## Configuration Add the following entry in your `configuration.yaml`: From 94255031137da95cb6fec864ca3eb8c95b283ef5 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Sun, 10 Sep 2023 14:30:42 -0400 Subject: [PATCH 45/56] Update RenphoWeight.py --- custom_components/renpho/RenphoWeight.py | 37 ++++++++++++++---------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/custom_components/renpho/RenphoWeight.py b/custom_components/renpho/RenphoWeight.py index 1f36c40..e12c3ff 100755 --- a/custom_components/renpho/RenphoWeight.py +++ b/custom_components/renpho/RenphoWeight.py @@ -31,7 +31,6 @@ class RenphoWeight: weight (float): The most recent weight measurement. time_stamp (int): The timestamp of the most recent weight measurement. session_key (str): The session key obtained after successful authentication. - executor (ThreadPoolExecutor): Executor for running synchronous requests. """ def __init__(self, public_key, email, password, user_id=None): @@ -45,6 +44,7 @@ def __init__(self, public_key, email, password, user_id=None): self.weight = None self.time_stamp = None self.session_key = None + self.session = aiohttp.ClientSession() def prepare_data(self, data): if isinstance(data, bytes): @@ -59,19 +59,17 @@ def prepare_data(self, data): async def _request(self, method, url, **kwargs): try: kwargs = self.prepare_data(kwargs) - async with aiohttp.ClientSession() as session: - async with session.request(method, url, **kwargs) as response: - response.raise_for_status() - parsed_response = await response.json() - - # Check for 40302 status code - if parsed_response.get('status_code') == '40302': - await self.auth() # Assuming you have this method implemented - - return parsed_response + async with self.session.request(method, url, **kwargs) as response: # Reuse session + response.raise_for_status() + parsed_response = await response.json() + + if parsed_response.get('status_code') == '40302': + await self.auth() + + return parsed_response except Exception as e: _LOGGER.error(f"Error in request: {e}") - raise # Or raise a custom exception + raise APIError("API request failed") # Raise a custom exception def _requestSync(self, method, url, **kwargs): try: @@ -98,7 +96,7 @@ def authSync(self): parsed = self._requestSync('POST', API_AUTH_URL, data=data) if 'terminal_user_session_key' not in parsed: - raise Exception("Authentication failed.") + raise AuthenticationError("Authentication failed") self.session_key = parsed['terminal_user_session_key'] return parsed @@ -118,7 +116,7 @@ async def auth(self): parsed = await self._request('POST', API_AUTH_URL, json=data) if 'terminal_user_session_key' not in parsed: - raise Exception("Authentication failed.") + raise AuthenticationError("Authentication failed") self.session_key = parsed['terminal_user_session_key'] return parsed @@ -300,10 +298,11 @@ def get_user_id(self): """ return self.user_id - def close(self): + async def close(self): """ Shutdown the executor when you are done using the RenphoWeight instance. """ + await self.session.close() self.executor.shutdown() class Interval(Timer): @@ -316,4 +315,10 @@ def run(self): Run the function at the given interval. """ while not self.finished.wait(self.interval): - self.function(*self.args, **self.kwargs) \ No newline at end of file + self.function(*self.args, **self.kwargs) + +class AuthenticationError(Exception): + pass + +class APIError(Exception): + pass From 27cd3cd29f03f0fe2d302052acdb4aba6013b26a Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Sun, 10 Sep 2023 14:30:55 -0400 Subject: [PATCH 46/56] Update sensor.py --- custom_components/renpho/sensor.py | 102 ++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/custom_components/renpho/sensor.py b/custom_components/renpho/sensor.py index 0534acb..5c854d3 100755 --- a/custom_components/renpho/sensor.py +++ b/custom_components/renpho/sensor.py @@ -6,6 +6,7 @@ from homeassistant.util import slugify from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry from homeassistant.const import MASS_KILOGRAMS, TIME_SECONDS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -15,6 +16,96 @@ from .const import CM_TO_INCH, DOMAIN, CONF_EMAIL, CONF_PASSWORD, KG_TO_LBS +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renpho sensor platform.""" + renpho = hass.data[DOMAIN] + async_add_entities( + [ + # Physical Metrics + RenphoSensor(renpho, "weight", "Weight", "kg", category="Measurements", label="Physical Metrics"), + RenphoSensor(renpho, "bmi", "BMI", "", category="Measurements", label="Physical Metrics"), + RenphoSensor(renpho, "muscle", "Muscle Mass", "%", category="Measurements", label="Physical Metrics"), + RenphoSensor(renpho, "bone", "Bone Mass", "%", category="Measurements", label="Physical Metrics"), + RenphoSensor(renpho, "waistline", "Waistline", "cm", category="Measurements", label="Physical Metrics"), + RenphoSensor(renpho, "hip", "Hip", "cm", category="Measurements", label="Physical Metrics"), + RenphoSensor(renpho, "stature", "Stature", "cm", category="Measurements", label="Physical Metrics"), + + # Body Composition + RenphoSensor(renpho, "bodyfat", "Body Fat", "%", category="Measurements", label="Body Composition"), + RenphoSensor(renpho, "water", "Water Content", "%", category="Measurements", label="Body Composition"), + RenphoSensor(renpho, "subfat", "Subcutaneous Fat", "%", category="Measurements", label="Body Composition"), + RenphoSensor(renpho, "visfat", "Visceral Fat", "Level", category="Measurements", label="Body Composition"), + + # Metabolic Metrics + RenphoSensor(renpho, "bmr", "BMR", "kcal/day", category="Measurements", label="Metabolic Metrics"), + RenphoSensor(renpho, "protein", "Protein Content", "%", category="Measurements", label="Metabolic Metrics"), + + # Age Metrics + RenphoSensor(renpho, "bodyage", "Body Age", "Years", category="Measurements", label="Age Metrics"), + + # Device Information + RenphoSensor(renpho, "mac", "MAC Address", "", category="Device", label="Device Information"), + RenphoSensor(renpho, "scale_type", "Scale Type", "", category="Device", label="Device Information"), + RenphoSensor(renpho, "scale_name", "Scale Name", "", category="Device", label="Device Information"), + + # Miscellaneous + RenphoSensor(renpho, "method", "Measurement Method", "", category="Miscellaneous", label="Additional Metrics"), + RenphoSensor(renpho, "pregnant_flag", "Pregnant Flag", "", category="Miscellaneous", label="Additional Metrics"), + RenphoSensor(renpho, "sport_flag", "Sport Flag", "", category="Miscellaneous", label="Additional Metrics"), + RenphoSensor(renpho, "score", "Score", "", category="Miscellaneous", label="Additional Metrics"), + RenphoSensor(renpho, "remark", "Remark", "", category="Miscellaneous", label="Additional Metrics"), + + # Meta Information + RenphoSensor(renpho, "id", "Record ID", "", category="Meta", label="Meta Information"), + RenphoSensor(renpho, "b_user_id", "User ID", "", category="Meta", label="Meta Information"), + RenphoSensor(renpho, "time_stamp", "Time Stamp", "UNIX Time", category="Meta", label="Meta Information"), + RenphoSensor(renpho, "created_at", "Created At", "", category="Meta", label="Meta Information"), + + # User Profile + RenphoSensor(renpho, "gender", "Gender", "", category="User", label="User Profile"), + RenphoSensor(renpho, "height", "Height", "cm", category="User", label="User Profile"), + RenphoSensor(renpho, "birthday", "Birthday", "", category="User", label="User Profile"), + + # Electrical Measurements + RenphoSensor(renpho, "resistance", "Electrical Resistance", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "sec_resistance", "Secondary Electrical Resistance", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "actual_resistance", "Actual Electrical Resistance", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "actual_sec_resistance", "Actual Secondary Electrical Resistance", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "resistance20_left_arm", "Resistance20 Left Arm", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "resistance20_left_leg", "Resistance20 Left Leg", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "resistance20_right_arm", "Resistance20 Right Arm", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "resistance20_right_leg", "Resistance20 Right Leg", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "resistance20_trunk", "Resistance20 Trunk", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "resistance100_left_arm", "Resistance100 Left Arm", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "resistance100_left_leg", "Resistance100 Left Leg", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "resistance100_right_arm", "Resistance100 Right Arm", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "resistance100_right_leg", "Resistance100 Right Leg", "Ohms", category="Measurements", label="Electrical Measurements"), + RenphoSensor(renpho, "resistance100_trunk", "Resistance100 Trunk", "Ohms", category="Measurements", label="Electrical Measurements"), + + + # Cardiovascular Metrics + RenphoSensor(renpho, "heart_rate", "Heart Rate", "bpm", category="Measurements", label="Cardiovascular Metrics"), + RenphoSensor(renpho, "cardiac_index", "Cardiac Index", "", category="Measurements", label="Cardiovascular Metrics"), + + # Other Metrics + RenphoSensor(renpho, "method", "Method Used", "", category="Miscellaneous", label="Other Metrics"), + RenphoSensor(renpho, "sport_flag", "Sports Flag", "", category="Miscellaneous", label="Other Metrics"), + RenphoSensor(renpho, "left_weight", "Left Weight", "kg", category="Measurements", label="Other Metrics"), + RenphoSensor(renpho, "right_weight", "Right Weight", "kg", category="Measurements", label="Other Metrics"), + RenphoSensor(renpho, "local_created_at", "Local Created At", "", category="Meta", label="Other Metrics"), + RenphoSensor(renpho, "time_zone", "Time Zone", "", category="Device", label="Other Metrics"), + RenphoSensor(renpho, "remark", "Additional Remarks", "", category="Miscellaneous", label="Other Metrics"), + RenphoSensor(renpho, "score", "Health Score", "", category="Miscellaneous", label="Other Metrics"), + RenphoSensor(renpho, "pregnant_flag", "Pregnancy Flag", "", category="Miscellaneous", label="Other Metrics"), + RenphoSensor(renpho, "stature", "Stature Information", "cm", category="Measurements", label="Other Metrics"), + RenphoSensor(renpho, "category", "Category Identifier", "", category="Miscellaneous", label="Other Metrics"), + ] + ) + # Existing setup_platform function def setup_platform( hass: HomeAssistant, @@ -30,6 +121,7 @@ def setup_platform( # entities = [RenphoSensor(renpho, *config) for config in sensor_configurations] # add_entities(entities) + add_entities( [ # Physical Metrics @@ -190,13 +282,8 @@ def label(self) -> str: """ Return the label of the sensor. """ return self._label - def update(self): - """ Update the sensor using the event loop for asynchronous code. """ - self.hass.async_add_job(self._async_internal_update()) - - @callback - async def _async_internal_update(self): - """ Internal method to update the sensor asynchronously. """ + async def async_update(self): + """Update the sensor using the event loop for asynchronous code.""" try: metric_value = await self._renpho.getSpecificMetric(self._metric) # Assuming asynchronous version if metric_value is not None: @@ -209,4 +296,3 @@ async def _async_internal_update(self): _LOGGER.error(f"{type(e).__name__} updating {self._name}: {e}") except Exception as e: _LOGGER.error(f"An unexpected error occurred updating {self._name}: {e}") - From 644c5a526b85d662532de54da04ec875285d1327 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Sun, 10 Sep 2023 14:31:05 -0400 Subject: [PATCH 47/56] Update config_flow.py --- custom_components/renpho/config_flow.py | 35 ++++++++++++++++++------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/custom_components/renpho/config_flow.py b/custom_components/renpho/config_flow.py index 447cc93..d66b8f0 100644 --- a/custom_components/renpho/config_flow.py +++ b/custom_components/renpho/config_flow.py @@ -1,3 +1,5 @@ +# config_flow.py + from __future__ import annotations import logging from typing import Any @@ -31,31 +33,46 @@ class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): errors = {} + if user_input is not None: try: info = await async_validate_input(self.hass, user_input) return self.async_create_entry(title=info["title"], data=user_input) + except CannotConnect as e: - _LOGGER.error("Cannot connect: %s, details: %s", e.reason, e.get_details()) errors["base"] = "cannot_connect" + _LOGGER.error(f"Cannot connect due to {e.reason}. Details: {e.get_details()}") + except exceptions.HomeAssistantError as e: - _LOGGER.error("Home Assistant specific error: %s", str(e)) errors["base"] = "home_assistant_error" + _LOGGER.error(f"Home Assistant specific error: {str(e)}") + except Exception as e: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception: %s", e) errors["base"] = "unknown_error" + _LOGGER.exception(f"Unexpected exception: {e}") + # Use description_placeholders for dynamic info + placeholders = { + "additional_info": "Please provide your Renpho login details.", + "icon": "renpho.png", + "description": "This is a description of your Renpho integration." + } + return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA.extend({vol.Set(user_input)} if user_input else {}), # Prefill form + data_schema=DATA_SCHEMA, errors=errors, - description_placeholders={ - "additional_info": "Please provide your Renpho login details.", - "icon": "renpho.png", # Replace with your actual icon path - "description": "This is a description of your Renpho integration." - }, + description_placeholders=placeholders ) + async def async_step_advanced_options(self, user_input=None): + # Implement advanced options step here + pass + + async def async_step_select_device(self, user_input=None): + # Implement device selection step here + pass + class CannotConnect(exceptions.HomeAssistantError): def __init__(self, reason: str = "", details: dict = None): super().__init__(self) From 34a53ae94a51feb5ce034854f6d60bdd62912cd7 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Sun, 10 Sep 2023 14:31:16 -0400 Subject: [PATCH 48/56] Update __init__.py --- custom_components/renpho/__init__.py | 76 +++++++++++++++++++--------- 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/custom_components/renpho/__init__.py b/custom_components/renpho/__init__.py index 33ca5d4..c5cb1ec 100755 --- a/custom_components/renpho/__init__.py +++ b/custom_components/renpho/__init__.py @@ -1,49 +1,77 @@ +import logging +import asyncio from homeassistant.helpers import service -from .custom_components.renpho.const import ( +from homeassistant.core import callback + +from .const import ( CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, CONF_PUBLIC_KEY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP ) from .RenphoWeight import RenphoWeight -import logging # Initialize logger _LOGGER = logging.getLogger(__name__) +# ------------------- Setup Methods ------------------- + async def async_setup(hass, config): - """Set up the Renpho component.""" + """Set up the Renpho component from YAML configuration.""" _LOGGER.debug("Starting hass_renpho") + + conf = config.get(DOMAIN) + if conf: + await setup_renpho(hass, conf) + return True - # Extract configuration values - conf = config[DOMAIN] - email = conf[CONF_EMAIL] - password = conf[CONF_PASSWORD] - user_id = conf.get(CONF_USER_ID) # Using get in case it's optional - refresh = conf[CONF_REFRESH] +async def async_setup_entry(hass, entry): + """Set up Renpho from a config entry.""" + await setup_renpho(hass, entry.data) - # Create an instance of RenphoWeight - renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + return True - # Define a cleanup function - async def async_cleanup(event): - await renpho.stopPolling() +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + # Remove Renpho instance if it exists + await hass.config_entries.async_forward_entry_unload(entry, "sensor") + if DOMAIN in hass.data: + del hass.data[DOMAIN] + return True - # Define a prepare function - async def async_prepare(event): - await renpho.startPolling(refresh) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_cleanup) +# ------------------- Helper Methods ------------------- - # Register the prepare function - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_prepare) +async def setup_renpho(hass, conf): + """Common setup logic for YAML and UI.""" + email = conf[CONF_EMAIL] + password = conf[CONF_PASSWORD] + user_id = conf.get(CONF_USER_ID, None) + refresh = conf.get(CONF_REFRESH, 600) + + renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) - # Store the Renpho instance + @callback + def async_on_start(event): + hass.async_create_task(async_prepare(hass, renpho, refresh)) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_on_start) hass.data[DOMAIN] = renpho - return True +async def async_prepare(hass, renpho, refresh): + """Prepare and start polling.""" + await renpho.startPolling(refresh) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_cleanup(renpho)) -if __name__ == "__main__": - import asyncio +async def async_cleanup(renpho): + """Cleanup logic.""" + await renpho.stopPolling() +# ------------------- Main Method for Testing ------------------- + +if __name__ == "__main__": async def main(): renpho = RenphoWeight(CONF_PUBLIC_KEY, '', '', '') await renpho.startPolling(10) From f5f062a8983aef3b9844b2db0a1c6fce969bfe90 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Sun, 10 Sep 2023 23:23:28 -0400 Subject: [PATCH 49/56] Update RenphoWeight.py --- custom_components/renpho/RenphoWeight.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/custom_components/renpho/RenphoWeight.py b/custom_components/renpho/RenphoWeight.py index e12c3ff..c5a42da 100755 --- a/custom_components/renpho/RenphoWeight.py +++ b/custom_components/renpho/RenphoWeight.py @@ -298,6 +298,22 @@ def get_user_id(self): """ return self.user_id + async def get_device_info(self): + response = await self._request("https://renpho.qnclouds.com/api/v3/device_binds/get_device.json") + return response + + async def list_latest_model(self): + response = await self.generic_request("https://renpho.qnclouds.com/api/v3/devices/list_lastest_model.json") + return response + + async def list_girth(self): + response = await self.generic_request("https://renpho.qnclouds.com/api/v3/girths/list_girth.json") + return response + + async def list_growth_record(self): + response = await self.generic_request("https://renpho.qnclouds.com/api/v3/growth_record/list_growth_record.json") + return response + async def close(self): """ Shutdown the executor when you are done using the RenphoWeight instance. From 3fdefe8ab3140285da2b6cf06ad5f16c205b4029 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Mon, 11 Sep 2023 23:39:30 +0000 Subject: [PATCH 50/56] added girth and goal and clean the repo more python syntax --- .devcontainer/README.md | 2 +- .devcontainer/devcontainer.json | 44 +- .github/pull_request_template.md | 11 +- .github/workflows/release.yml | 83 +-- .github/workflows/tests.yml | 29 +- .pre-commit-config.yaml | 13 +- .vscode/launch.json | 66 +- .vscode/tasks.json | 80 +- README.md | 220 +++--- SECURITY.md | 1 + custom_components/renpho/RenphoWeight.py | 340 --------- custom_components/renpho/__init__.py | 97 ++- custom_components/renpho/config_flow.py | 62 +- custom_components/renpho/const.py | 67 +- custom_components/renpho/manifest.json | 7 +- custom_components/renpho/renpho.py | 570 ++++++++++++++ custom_components/renpho/sensor.py | 913 +++++++++++++++++------ docs/README.md | 481 +++++++++--- docs/hacs/dev.md | 3 +- example/configuration.yaml | 8 +- example/googletheme.yaml | 2 +- hacs.json | 8 +- info.md | 2 +- renovate.json | 48 +- requirements.txt | 2 +- requirements_dev.txt | 3 +- requirements_test.txt | 5 +- tests/tests.py | 254 ------- 28 files changed, 2174 insertions(+), 1247 deletions(-) delete mode 100755 custom_components/renpho/RenphoWeight.py create mode 100755 custom_components/renpho/renpho.py delete mode 100644 tests/tests.py diff --git a/.devcontainer/README.md b/.devcontainer/README.md index f4763c2..38dc4fd 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -57,4 +57,4 @@ by uncommenting the line: Then launch the task `Run Home Assistant on port 9123`, and launch the debugger with the existing debugging configuration `Python: Attach Local`. -For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/). \ No newline at end of file +For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/). diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b3e3c7a..62bb3e1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,24 +1,24 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "image": "ghcr.io/ludeeus/devcontainer/integration:stable", - "name": "HASS Renpho development", - "context": "..", - "appPort": ["9123:8123"], - "postCreateCommand": "container install && tools/post_create_command.sh", - "extensions": [ - "ms-python.python", - "github.vscode-pull-request-github", - "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance", - "foxundermoon.shell-format" - ], - "settings": { - "editor.tabSize": 4, - "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.flake8Enabled": true, - "python.linting.enabled": true, - "python.formatting.provider": "black", - "ha_strava.remote_host": "home.local" - } - } \ No newline at end of file + "image": "ghcr.io/ludeeus/devcontainer/integration:stable", + "name": "HASS Renpho development", + "context": "..", + "appPort": ["9123:8123"], + "postCreateCommand": "container install && tools/post_create_command.sh", + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance", + "foxundermoon.shell-format" + ], + "settings": { + "editor.tabSize": 4, + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.flake8Enabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "ha_strava.remote_host": "home.local" + } +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8fe91ed..7d70baa 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -25,10 +25,11 @@ Please describe the tests that you ran to verify your changes. Provide instructi - [ ] Test B **Test Configuration**: -* Firmware version: -* Hardware: -* Toolchain: -* SDK: + +- Firmware version: +- Hardware: +- Toolchain: +- SDK: # Checklist: @@ -39,4 +40,4 @@ Please describe the tests that you ran to verify your changes. Provide instructi - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes -- [ ] Any dependent changes have been merged and published in downstream modules \ No newline at end of file +- [ ] Any dependent changes have been merged and published in downstream modules diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfc3085..14667f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,51 +3,50 @@ name: Create Release on: push: tags: - - 'v*' + - "v*" jobs: release: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Generate Changelog - id: changelog - run: | - export CHANGELOG=$(git log $(git describe --tags --abbrev=0)..HEAD --pretty="- %s") - echo "CHANGELOG=$CHANGELOG" >> $GITHUB_ENV - - - name: Create Release - id: create_release - uses: actions/create-release@v1 - with: - tag_name: ${{ github.ref }} - release_name: Release ${{ github.ref }} - body: ${{ env.CHANGELOG }} - draft: false - prerelease: false - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Zip custom_components/renpho directory - run: zip -r renpho_custom_component.zip custom_components/renpho - - - name: Zip complete code - run: zip -r complete_code.zip . - - - name: Upload custom_components/renpho ZIP to Release - uses: actions/upload-release-asset@v1 - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./renpho_custom_component.zip - asset_name: renpho_custom_component.zip - asset_content_type: application/zip - - - name: Upload complete code ZIP to Release - uses: actions/upload-release-asset@v1 - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./complete_code.zip - asset_name: complete_code.zip - asset_content_type: application/zip + - name: Checkout code + uses: actions/checkout@v2 + + - name: Generate Changelog + id: changelog + run: | + export CHANGELOG=$(git log $(git describe --tags --abbrev=0)..HEAD --pretty="- %s") + echo "CHANGELOG=$CHANGELOG" >> $GITHUB_ENV + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + body: ${{ env.CHANGELOG }} + draft: false + prerelease: false + + - name: Zip custom_components/renpho directory + run: zip -r renpho_custom_component.zip custom_components/renpho + + - name: Zip complete code + run: zip -r complete_code.zip . + + - name: Upload custom_components/renpho ZIP to Release + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./renpho_custom_component.zip + asset_name: renpho_custom_component.zip + asset_content_type: application/zip + + - name: Upload complete code ZIP to Release + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./complete_code.zip + asset_name: complete_code.zip + asset_content_type: application/zip diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b7e1dbc..e849354 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,20 +13,19 @@ jobs: runs-on: ubuntu-latest container: - image: homeassistant/home-assistant:latest # Use Home Assistant's Docker image + image: homeassistant/home-assistant:latest # Use Home Assistant's Docker image steps: - - uses: actions/checkout@v2 - - name: Set up Python 3 - run: | - python3 -m ensurepip - python3 -m pip install --upgrade pip - - name: Install Dependencies - run: | - pip install -r requirements.txt - pip install -r requirements_dev.txt - pip install -r requirements_test.txt - - name: Run Tests - run: | - python3 -m unittest discover - continue-on-error: true + - uses: actions/checkout@v2 + - name: Set up Python 3 + run: | + python3 -m ensurepip + python3 -m pip install --upgrade pip + - name: Install Dependencies + run: | + pip install -r requirements.txt + pip install -r requirements_dev.txt + pip install -r requirements_test.txt + - name: Run Tests + run: | + python3 -m unittest discover diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d4b782c..c6653f2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,4 +37,15 @@ repos: name: pylint entry: pylint language: system - types: [python] \ No newline at end of file + types: [python] + require_serial: true + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.910 + hooks: + - id: mypy + args: [--ignore-missing-imports] + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.8.0 + hooks: + - id: python-check-blanket-noqa + - id: python-check-mock-methods diff --git a/.vscode/launch.json b/.vscode/launch.json index 6703d7f..024c063 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,34 +1,34 @@ { - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - // Example of attaching to local debug server - "name": "Python: Attach Local", - "type": "python", - "request": "attach", - "port": 5678, - "host": "localhost", - "pathMappings": [ - { - "localRoot": "${workspaceFolder}", - "remoteRoot": "." - } - ] - }, - { - // Example of attaching to my production server - "name": "Python: Attach Remote", - "type": "python", - "request": "attach", - "port": 5678, - "host": "${config:ha_strava.remote_host}", - "pathMappings": [ - { - "localRoot": "${workspaceFolder}", - "remoteRoot": "/usr/src/homeassistant" - } - ] - } - ] - } \ No newline at end of file + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // Example of attaching to local debug server + "name": "Python: Attach Local", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + }, + { + // Example of attaching to my production server + "name": "Python: Attach Remote", + "type": "python", + "request": "attach", + "port": 5678, + "host": "${config:ha_strava.remote_host}", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/usr/src/homeassistant" + } + ] + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6423a7d..a7800c6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,41 +1,41 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "Run Home Assistant on port 9123", - "type": "shell", - "command": "container start", - "problemMatcher": [] - }, - { - "label": "Run Home Assistant configuration check /config", - "type": "shell", - "command": "container check", - "problemMatcher": [] - }, - { - "label": "Upgrade Home Assistant to latest dev", - "type": "shell", - "command": "container install", - "problemMatcher": [] - }, - { - "label": "Install a specific version of Home Assistant", - "type": "shell", - "command": "container set-version", - "problemMatcher": [] - }, - { - "label": "Pre-commit", - "type": "shell", - "command": "pre-commit", - "problemMatcher": [] - }, - { - "label": "Push Component to Remote Host", - "type": "shell", - "command": "tools/push_remote.sh ${config:ha_strava.remote_host}", - "problemMatcher": [] - } - ] - } \ No newline at end of file + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 9123", + "type": "shell", + "command": "container start", + "problemMatcher": [] + }, + { + "label": "Run Home Assistant configuration check /config", + "type": "shell", + "command": "container check", + "problemMatcher": [] + }, + { + "label": "Upgrade Home Assistant to latest dev", + "type": "shell", + "command": "container install", + "problemMatcher": [] + }, + { + "label": "Install a specific version of Home Assistant", + "type": "shell", + "command": "container set-version", + "problemMatcher": [] + }, + { + "label": "Pre-commit", + "type": "shell", + "command": "pre-commit", + "problemMatcher": [] + }, + { + "label": "Push Component to Remote Host", + "type": "shell", + "command": "tools/push_remote.sh ${config:ha_strava.remote_host}", + "problemMatcher": [] + } + ] +} diff --git a/README.md b/README.md index ad4c9cc..d38cf64 100755 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ ![HACS Integration](https://img.shields.io/badge/Category-Integration-blue) ![IoT Class](https://img.shields.io/badge/IoT%20Class-cloud_polling-blue) - [![HACS Badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/antoinebou12/hass_renpho?color=41BDF5&style=for-the-badge)](https://github.com/antoinebou12/hass_renpho/releases/latest) [![Integration Usage](https://img.shields.io/badge/dynamic/json?color=41BDF5&style=for-the-badge&logo=home-assistant&label=usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.hass_renpho.total)](https://analytics.home-assistant.io/) @@ -14,12 +13,17 @@ ## Overview This custom component allows you to integrate Renpho's weight scale data into Home Assistant. It fetches weight and various other health metrics and displays them as sensors in Home Assistant. + ### Weight + ![Weight Sensor](docs/images/weight.png) + ### Complete View + ![Sensors](docs/images/renpho_google.png) ## Table of Contents + - [Prerequisites](#prerequisites) - [Installation](#installation) - [Configuration](#configuration) @@ -27,8 +31,8 @@ This custom component allows you to integrate Renpho's weight scale data into Ho - [Roadmap](#roadmap) - [License](#license) - ## Prerequisites + 1. You must have a Renpho account. If you don't have one, you can create one [here](https://renpho.com/). 2. You must have a Renpho scale. If you don't have one, you can purchase one [here](https://renpho.com/collections/body-fat-scale). 3. You must have the Renpho app installed on your mobile device. You can download it [here](https://play.google.com/store/apps/details?id=com.renpho.smart&hl=en_US&gl=US) for Android and [here](https://apps.apple.com/us/app/renpho/id1115563582) for iOS. @@ -37,20 +41,23 @@ This custom component allows you to integrate Renpho's weight scale data into Ho 6. Visual Studio Code is recommended for editing the files. ## Installation + ``` git clone https://github.com/antoinebou12/hass_renpho ``` + Copy this folder to `/custom_components/renpho/`. ## Configuration + Add the following entry in your `configuration.yaml`: ```yaml renpho: - email: test@test.com # email address + email: test@test.com # email address password: MySecurePassword # password - user_id: 123456789 # user id (optional) - refresh: 600 # time to poll (ms) + user_id: 123456789 # user id (optional) + refresh: 600 # time to poll (ms) ``` And then add the sensor platform: @@ -68,122 +75,167 @@ Restart home assistant and you should see the sensors: ### General Information -| Metric | Description | Data Type | Unit of Measurement | -|--------------|---------------------------------------------|-----------|---------------------| -| id | Unique identifier for the record | Numeric | N/A | -| b_user_id | Unique identifier for the user | Numeric | N/A | -| time_stamp | Unix timestamp for the record | Numeric | UNIX Time | -| created_at | Time the data was created | DateTime | N/A | -| created_stamp| Unix timestamp for when the data was created| Numeric | UNIX Time | +| Metric | Description | Data Type | Unit of Measurement | +| ------------- | -------------------------------------------- | --------- | ------------------- | +| id | Unique identifier for the record | Numeric | N/A | +| b_user_id | Unique identifier for the user | Numeric | N/A | +| time_stamp | Unix timestamp for the record | Numeric | UNIX Time | +| created_at | Time the data was created | DateTime | N/A | +| created_stamp | Unix timestamp for when the data was created | Numeric | UNIX Time | ### Device Information -| Metric | Description | Data Type | Unit of Measurement | -|----------------|-----------------------------|-----------|---------------------| -| scale_type | Type of scale used | Numeric | N/A | -| scale_name | Name of the scale | String | N/A | -| mac | MAC address of the device | String | N/A | -| internal_model | Internal model code | String | N/A | -| time_zone | Time zone information | String | N/A | +| Metric | Description | Data Type | Unit of Measurement | +| -------------- | ------------------------- | --------- | ------------------- | +| scale_type | Type of scale used | Numeric | N/A | +| scale_name | Name of the scale | String | N/A | +| mac | MAC address of the device | String | N/A | +| internal_model | Internal model code | String | N/A | +| time_zone | Time zone information | String | N/A | ### User Profile -| Metric | Description | Data Type | Unit of Measurement | -|--------------|------------------------------|-----------|---------------------| -| gender | Gender of the user | Numeric | N/A | -| height | Height of the user | Numeric | cm | -| height_unit | Unit for height | Numeric | N/A | -| birthday | Birth date of the user | Date | N/A | +| Metric | Description | Data Type | Unit of Measurement | +| ----------- | ---------------------- | --------- | ------------------- | +| gender | Gender of the user | Numeric | N/A | +| height | Height of the user | Numeric | cm | +| height_unit | Unit for height | Numeric | N/A | +| birthday | Birth date of the user | Date | N/A | ### Physical Metrics -| Metric | Description | Data Type | Unit of Measurement | -|------------|------------------------------|-----------|---------------------| -| weight | Body weight | Numeric | kg | -| bmi | Body Mass Index | Numeric | N/A | -| muscle | Muscle mass | Numeric | % | -| bone | Bone mass | Numeric | % | -| waistline | Waistline size | Numeric | cm | -| hip | Hip size | Numeric | cm | -| stature | Stature information | Numeric | cm | +| Metric | Description | Data Type | Unit of Measurement | +| --------- | ------------------- | --------- | ------------------- | +| weight | Body weight | Numeric | kg | +| bmi | Body Mass Index | Numeric | N/A | +| muscle | Muscle mass | Numeric | % | +| bone | Bone mass | Numeric | % | +| waistline | Waistline size | Numeric | cm | +| hip | Hip size | Numeric | cm | +| stature | Stature information | Numeric | cm | ### Body Composition -| Metric | Description | Data Type | Unit of Measurement | -|----------|------------------------------|-----------|---------------------| -| bodyfat | Body fat percentage | Numeric | % | -| water | Water content in the body | Numeric | % | -| subfat | Subcutaneous fat | Numeric | % | -| visfat | Visceral fat level | Numeric | Level | +| Metric | Description | Data Type | Unit of Measurement | +| ------- | ------------------------- | --------- | ------------------- | +| bodyfat | Body fat percentage | Numeric | % | +| water | Water content in the body | Numeric | % | +| subfat | Subcutaneous fat | Numeric | % | +| visfat | Visceral fat level | Numeric | Level | ### Metabolic Metrics -| Metric | Description | Data Type | Unit of Measurement | -|----------|------------------------------|-----------|---------------------| -| bmr | Basal Metabolic Rate | Numeric | kcal/day | -| protein | Protein content in the body | Numeric | % | +| Metric | Description | Data Type | Unit of Measurement | +| ------- | --------------------------- | --------- | ------------------- | +| bmr | Basal Metabolic Rate | Numeric | kcal/day | +| protein | Protein content in the body | Numeric | % | ### Age Metrics -| Metric | Description | Data Type | Unit of Measurement | -|----------|------------------------------|-----------|---------------------| -| bodyage | Estimated biological age | Numeric | Years | +| Metric | Description | Data Type | Unit of Measurement | +| ------- | ------------------------ | --------- | ------------------- | +| bodyage | Estimated biological age | Numeric | Years | Certainly, you can expand the existing table to include the "Unit of Measurement" column for each metric. Here's how you can continue to organize the metrics into categories, similar to your previous table, but now with the added units: ### Electrical Measurements (not sure if this is the correct name) -| Metric | Description | Data Type | Unit of Measurement | -|------------------------|-------------------------------------|-----------|---------------------| -| resistance | Electrical resistance | Numeric | Ohms | -| sec_resistance | Secondary electrical resistance | Numeric | Ohms | -| actual_resistance | Actual electrical resistance | Numeric | Ohms | -| actual_sec_resistance | Actual secondary electrical resistance| Numeric | Ohms | -| resistance20_left_arm | Resistance20 in the left arm | Numeric | Ohms | -| resistance20_left_leg | Resistance20 in the left leg | Numeric | Ohms | -| resistance20_right_leg | Resistance20 in the right leg | Numeric | Ohms | -| resistance20_right_arm | Resistance20 in the right arm | Numeric | Ohms | -| resistance20_trunk | Resistance20 in the trunk | Numeric | Ohms | -| resistance100_left_arm | Resistance100 in the left arm | Numeric | Ohms | -| resistance100_left_leg | Resistance100 in the left leg | Numeric | Ohms | -| resistance100_right_arm| Resistance100 in the right arm | Numeric | Ohms | -| resistance100_right_leg| Resistance100 in the right leg | Numeric | Ohms | -| resistance100_trunk | Resistance100 in the trunk | Numeric | Ohms | +| Metric | Description | Data Type | Unit of Measurement | +| ----------------------- | -------------------------------------- | --------- | ------------------- | +| resistance | Electrical resistance | Numeric | Ohms | +| sec_resistance | Secondary electrical resistance | Numeric | Ohms | +| actual_resistance | Actual electrical resistance | Numeric | Ohms | +| actual_sec_resistance | Actual secondary electrical resistance | Numeric | Ohms | +| resistance20_left_arm | Resistance20 in the left arm | Numeric | Ohms | +| resistance20_left_leg | Resistance20 in the left leg | Numeric | Ohms | +| resistance20_right_leg | Resistance20 in the right leg | Numeric | Ohms | +| resistance20_right_arm | Resistance20 in the right arm | Numeric | Ohms | +| resistance20_trunk | Resistance20 in the trunk | Numeric | Ohms | +| resistance100_left_arm | Resistance100 in the left arm | Numeric | Ohms | +| resistance100_left_leg | Resistance100 in the left leg | Numeric | Ohms | +| resistance100_right_arm | Resistance100 in the right arm | Numeric | Ohms | +| resistance100_right_leg | Resistance100 in the right leg | Numeric | Ohms | +| resistance100_trunk | Resistance100 in the trunk | Numeric | Ohms | ### Cardiovascular Metrics -| Metric | Description | Data Type | Unit of Measurement | -|-----------------|-------------------|-----------|---------------------| -| heart_rate | Heart rate | Numeric | bpm | -| cardiac_index | Cardiac index | Numeric | N/A | +| Metric | Description | Data Type | Unit of Measurement | +| ------------- | ------------- | --------- | ------------------- | +| heart_rate | Heart rate | Numeric | bpm | +| cardiac_index | Cardiac index | Numeric | N/A | ### Other Metrics -| Metric | Description | Data Type | Unit of Measurement | -|-----------------|------------------------------------|-----------|---------------------| -| method | Method used for measurement | Numeric | N/A | -| sport_flag | Sports flag | Numeric | N/A | -| left_weight | Weight on the left side of the body| Numeric | kg | -| right_weight | Weight on the right side of the body| Numeric | kg | -| waistline | Waistline size | Numeric | cm | -| hip | Hip size | Numeric | cm | -| local_created_at| Local time the data was created | DateTime | N/A | -| time_zone | Time zone information | String | N/A | -| remark | Additional remarks | String | N/A | -| score | Health score | Numeric | N/A | -| pregnant_flag | Pregnancy flag | Numeric | N/A | -| stature | Stature information | Numeric | cm | -| category | Category identifier | Numeric | N/A | - +| Metric | Description | Data Type | Unit of Measurement | +| ---------------- | ------------------------------------ | --------- | ------------------- | +| method | Method used for measurement | Numeric | N/A | +| sport_flag | Sports flag | Numeric | N/A | +| left_weight | Weight on the left side of the body | Numeric | kg | +| right_weight | Weight on the right side of the body | Numeric | kg | +| waistline | Waistline size | Numeric | cm | +| hip | Hip size | Numeric | cm | +| local_created_at | Local time the data was created | DateTime | N/A | +| time_zone | Time zone information | String | N/A | +| remark | Additional remarks | String | N/A | +| score | Health score | Numeric | N/A | +| pregnant_flag | Pregnancy flag | Numeric | N/A | +| stature | Stature information | Numeric | cm | +| category | Category identifier | Numeric | N/A | + +### Girth Measurements + +| Metric | Description | Data Type | Unit of Measurement | Category | Label | +| ----------------- | ----------------- | --------- | ------------------- | ------------ | ------------------ | +| neck_value | Neck Value | Numeric | cm | Measurements | Girth Measurements | +| shoulder_value | Shoulder Value | Numeric | cm | Measurements | Girth Measurements | +| arm_value | Arm Value | Numeric | cm | Measurements | Girth Measurements | +| chest_value | Chest Value | Numeric | cm | Measurements | Girth Measurements | +| waist_value | Waist Value | Numeric | cm | Measurements | Girth Measurements | +| hip_value | Hip Value | Numeric | cm | Measurements | Girth Measurements | +| thigh_value | Thigh Value | Numeric | cm | Measurements | Girth Measurements | +| calf_value | Calf Value | Numeric | cm | Measurements | Girth Measurements | +| left_arm_value | Left Arm Value | Numeric | cm | Measurements | Girth Measurements | +| left_thigh_value | Left Thigh Value | Numeric | cm | Measurements | Girth Measurements | +| left_calf_value | Left Calf Value | Numeric | cm | Measurements | Girth Measurements | +| right_arm_value | Right Arm Value | Numeric | cm | Measurements | Girth Measurements | +| right_thigh_value | Right Thigh Value | Numeric | cm | Measurements | Girth Measurements | +| right_calf_value | Right Calf Value | Numeric | cm | Measurements | Girth Measurements | +| whr_value | WHR Value | Numeric | ratio | Measurements | Girth Measurements | +| abdomen_value | Abdomen Value | Numeric | cm | Measurements | Girth Measurements | + +--- + +### Girth Goals + +| Metric | Description | Data Type | Unit of Measurement | Category | Label | +| ---------------------- | ---------------------- | --------- | ------------------- | -------- | ----------- | +| neck_goal_value | Neck Goal Value | Numeric | cm | Goals | Girth Goals | +| shoulder_goal_value | Shoulder Goal Value | Numeric | cm | Goals | Girth Goals | +| arm_goal_value | Arm Goal Value | Numeric | cm | Goals | Girth Goals | +| chest_goal_value | Chest Goal Value | Numeric | cm | Goals | Girth Goals | +| waist_goal_value | Waist Goal Value | Numeric | cm | Goals | Girth Goals | +| hip_goal_value | Hip Goal Value | Numeric | cm | Goals | Girth Goals | +| thigh_goal_value | Thigh Goal Value | Numeric | cm | Goals | Girth Goals | +| calf_goal_value | Calf Goal Value | Numeric | cm | Goals | Girth Goals | +| left_arm_goal_value | Left Arm Goal Value | Numeric | cm | Goals | Girth Goals | +| left_thigh_goal_value | Left Thigh Goal Value | Numeric | cm | Goals | Girth Goals | +| left_calf_goal_value | Left Calf Goal Value | Numeric | cm | Goals | Girth Goals | +| right_arm_goal_value | Right Arm Goal Value | Numeric | cm | Goals | Girth Goals | +| right_thigh_goal_value | Right Thigh Goal Value | Numeric | cm | Goals | Girth Goals | +| right_calf_goal_value | Right Calf Goal Value | Numeric | cm | Goals | Girth Goals | +| whr_goal_value | WHR Goal Value | Numeric | ratio | Goals | Girth Goals | +| abdomen_goal_value | Abdomen Goal Value | Numeric | cm | Goals | Girth Goals | ## Roadmap + 1. Add support for all user information. ```yaml sensor: platform: renpho - user: your_username # username, email, or something identifiable + user: your_username # username, email, or something identifiable ``` 2. Find a way to prevent logging out from the mobile app upon every login from Home Assistant (if feasible). ## License + MIT License. See `LICENSE` for more information. diff --git a/SECURITY.md b/SECURITY.md index 5b4ffb2..4ccdfeb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,4 +28,5 @@ We take security very seriously. If you believe you've found a security vulnerab - Public acknowledgment of the vulnerability after it has been fixed. ### Note + Vulnerabilities that are not validated within 7 days will be closed. diff --git a/custom_components/renpho/RenphoWeight.py b/custom_components/renpho/RenphoWeight.py deleted file mode 100755 index c5a42da..0000000 --- a/custom_components/renpho/RenphoWeight.py +++ /dev/null @@ -1,340 +0,0 @@ -from threading import Timer -import requests -import json -import datetime -import asyncio -from Crypto.PublicKey import RSA -from Crypto.Cipher import PKCS1_v1_5 -from base64 import b64encode -import logging -import time -import aiohttp -from typing import Optional, List, Dict - -# Initialize logging -_LOGGER = logging.getLogger(__name__) - -# API Endpoints -API_AUTH_URL = 'https://renpho.qnclouds.com/api/v3/users/sign_in.json?app_id=Renpho' -API_SCALE_USERS_URL = 'https://renpho.qnclouds.com/api/v3/scale_users/list_scale_user' -API_MEASUREMENTS_URL = 'https://renpho.qnclouds.com/api/v2/measurements/list.json' - -class RenphoWeight: - """ - A class to interact with Renpho's weight scale API. - - Attributes: - public_key (str): The public RSA key used for encrypting the password. - email (str): The email address for the Renpho account. - password (str): The password for the Renpho account. - user_id (str, optional): The ID of the user for whom weight data should be fetched. - weight (float): The most recent weight measurement. - time_stamp (int): The timestamp of the most recent weight measurement. - session_key (str): The session key obtained after successful authentication. - """ - - def __init__(self, public_key, email, password, user_id=None): - """Initialize a new RenphoWeight instance.""" - self.public_key = public_key - self.email = email - self.password = password - if user_id == "": - self.user_id = None - self.user_id = user_id - self.weight = None - self.time_stamp = None - self.session_key = None - self.session = aiohttp.ClientSession() - - def prepare_data(self, data): - if isinstance(data, bytes): - return data.decode('utf-8') - elif isinstance(data, dict): - return {key: self.prepare_data(value) for key, value in data.items()} - elif isinstance(data, list): - return [self.prepare_data(element) for element in data] - else: - return data - - async def _request(self, method, url, **kwargs): - try: - kwargs = self.prepare_data(kwargs) - async with self.session.request(method, url, **kwargs) as response: # Reuse session - response.raise_for_status() - parsed_response = await response.json() - - if parsed_response.get('status_code') == '40302': - await self.auth() - - return parsed_response - except Exception as e: - _LOGGER.error(f"Error in request: {e}") - raise APIError("API request failed") # Raise a custom exception - - def _requestSync(self, method, url, **kwargs): - try: - kwargs = self.prepare_data(kwargs) # Update this line - response = requests.request(method, url, **kwargs) - response.raise_for_status() - return response.json() - except Exception as e: - _LOGGER.error(f"Error in request: {e}") - raise # Or raise a custom exception - - def authSync(self): - """ - Authenticate with the Renpho API to obtain a session key. - """ - if not self.email or not self.password: - raise Exception("Email and password must be provided") - - key = RSA.importKey(self.public_key) - cipher = PKCS1_v1_5.new(key) - encrypted_password = b64encode(cipher.encrypt(self.password.encode("utf-8"))) - - data = {'secure_flag': 1, 'email': self.email, 'password': encrypted_password} - parsed = self._requestSync('POST', API_AUTH_URL, data=data) - - if 'terminal_user_session_key' not in parsed: - raise AuthenticationError("Authentication failed") - - self.session_key = parsed['terminal_user_session_key'] - return parsed - - async def auth(self): - """ - Authenticate with the Renpho API to obtain a session key. - """ - if not self.email or not self.password: - raise Exception("Email and password must be provided") - - key = RSA.importKey(self.public_key) - cipher = PKCS1_v1_5.new(key) - encrypted_password = b64encode(cipher.encrypt(self.password.encode("utf-8"))) - - data = {'secure_flag': '1', 'email': self.email, 'password': encrypted_password} - parsed = await self._request('POST', API_AUTH_URL, json=data) - - if 'terminal_user_session_key' not in parsed: - raise AuthenticationError("Authentication failed") - - self.session_key = parsed['terminal_user_session_key'] - return parsed - - async def validate_credentials(self): - """ - Validate the current credentials by attempting to authenticate. - Returns True if authentication succeeds, False otherwise. - """ - try: - await self.auth() - return True - except Exception as e: - _LOGGER.error(f"Validation failed: {e}") - return False - - def getScaleUsersSync(self): - """ - Fetch the list of users associated with the scale. - """ - url = f"{API_SCALE_USERS_URL}?locale=en&terminal_user_session_key={self.session_key}" - parsed = self._requestSync('GET', url) - self.set_user_id(parsed['scale_users'][0]['user_id']) - return parsed['scale_users'] - - async def getScaleUsers(self): - """ - Fetch the list of users associated with the scale. - """ - try: - url = f"{API_SCALE_USERS_URL}?locale=en&terminal_user_session_key={self.session_key}" - parsed = await self._request('GET', url) - - if not parsed or 'scale_users' not in parsed: - _LOGGER.warning("Invalid response or 'scale_users' not in the response.") - return None - - self.set_user_id(parsed['scale_users'][0]['user_id']) - return parsed['scale_users'] - except aiohttp.ClientError as e: - _LOGGER.error(f"Aiohttp client error: {e}") - except Exception as e: - _LOGGER.error(f"An unexpected error occurred: {e}") - return None - - def getMeasurementsSync(self) -> Optional[List[Dict]]: - """ - Fetch the most recent weight measurements for the user. - """ - try: - today = datetime.date.today() - week_ago = today - datetime.timedelta(days=7) - week_ago_timestamp = int(time.mktime(week_ago.timetuple())) - url = f"{API_MEASUREMENTS_URL}?user_id={self.user_id}&last_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" - parsed = self._requestSync('GET', url) - - if 'last_ary' not in parsed: - _LOGGER.warning(f"Field 'last_ary' is not in the response: {parsed}") - return None - - last_measurement = parsed['last_ary'][0] - self.weight = last_measurement.get('weight', None) - self.time_stamp = last_measurement.get('time_stamp', None) - return parsed['last_ary'] - except Exception as e: - _LOGGER.error(f"An error occurred: {e}") - return None - - - async def getMeasurements(self) -> Optional[List[Dict]]: - """ - Fetch the most recent weight measurements for the user. - """ - try: - today = datetime.date.today() - week_ago = today - datetime.timedelta(days=7) - week_ago_timestamp = int(time.mktime(week_ago.timetuple())) - url = f"{API_MEASUREMENTS_URL}?user_id={self.user_id}&last_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" - parsed = await self._request('GET', url) - - if not parsed or 'last_ary' not in parsed: - _LOGGER.warning("Invalid response or 'last_ary' not in the response.") - return None - - last_measurement = parsed['last_ary'][0] - self.weight = last_measurement.get('weight', None) - self.time_stamp = last_measurement.get('time_stamp', None) - return parsed['last_ary'] - except aiohttp.ClientError as e: - _LOGGER.error(f"Aiohttp client error: {e}") - except Exception as e: - _LOGGER.error(f"An unexpected error occurred: {e}") - return None - - - def getSpecificMetricSync(self, metric: str) -> Optional[float]: - """ - Synchronous version of getSpecificMetric. - """ - try: - last_measurement = self.getMeasurementsSync() # Assuming you have a synchronous version of getMeasurements - if last_measurement: - return last_measurement[0].get(metric, None) - return None - except Exception as e: - _LOGGER.error(f"An error occurred: {e}") - return None - - async def getSpecificMetric(self, metric: str) -> Optional[float]: - """ - Fetch a specific metric from the most recent weight measurement. - """ - try: - last_measurement = await self.getMeasurements() - if last_measurement: - return last_measurement[0].get(metric, None) - return None - except Exception as e: - print(f"An error occurred: {e}") - return None - - async def getSpecificMetricFromUserID(self, metric: str, user_id: Optional[str] = None) -> Optional[float]: - """ - Fetch a specific metric for a particular user ID from the most recent weight measurement. - """ - try: - if user_id: - self.set_user_id(user_id) # Update the user_id if provided - info = await self.getInfo() - last_measurement = await self.getMeasurements() - if last_measurement: - return last_measurement[0].get(metric, None) - return None - except Exception as e: - print(f"An error occurred: {e}") - return None - - def getInfoSync(self): - """ - Wrapper method to authenticate, fetch users, and get measurements. - """ - self.authSync() - self.getScaleUsersSync() - self.getMeasurementsSync() - - async def getInfo(self): - """ - Wrapper method to authenticate, fetch users, and get measurements. - """ - await self.auth() - await self.getScaleUsers() - await self.getMeasurements() - - async def startPolling(self, polling_interval=60): - """ - Start polling for weight data at a given interval. - """ - await self.getInfo() - while True: - await asyncio.sleep(polling_interval) - await self.getInfo() - - def stopPolling(self): - """ - Stop polling for weight data. - """ - if hasattr(self, 'polling'): - self.polling.cancel() - - def set_user_id(self, user_id): - """ - Set the user ID for whom the weight data should be fetched. - """ - self.user_id = user_id - - def get_user_id(self): - """ - Get the current user ID for whom the weight data is being fetched. - """ - return self.user_id - - async def get_device_info(self): - response = await self._request("https://renpho.qnclouds.com/api/v3/device_binds/get_device.json") - return response - - async def list_latest_model(self): - response = await self.generic_request("https://renpho.qnclouds.com/api/v3/devices/list_lastest_model.json") - return response - - async def list_girth(self): - response = await self.generic_request("https://renpho.qnclouds.com/api/v3/girths/list_girth.json") - return response - - async def list_growth_record(self): - response = await self.generic_request("https://renpho.qnclouds.com/api/v3/growth_record/list_growth_record.json") - return response - - async def close(self): - """ - Shutdown the executor when you are done using the RenphoWeight instance. - """ - await self.session.close() - self.executor.shutdown() - -class Interval(Timer): - """ - A subclass of Timer to repeatedly run a function at a specified interval. - """ - - def run(self): - """ - Run the function at the given interval. - """ - while not self.finished.wait(self.interval): - self.function(*self.args, **self.kwargs) - -class AuthenticationError(Exception): - pass - -class APIError(Exception): - pass diff --git a/custom_components/renpho/__init__.py b/custom_components/renpho/__init__.py index c5cb1ec..0982ac9 100755 --- a/custom_components/renpho/__init__.py +++ b/custom_components/renpho/__init__.py @@ -1,29 +1,37 @@ -import logging import asyncio -from homeassistant.helpers import service -from homeassistant.core import callback +import logging from .const import ( - CONF_USER_ID, DOMAIN, CONF_EMAIL, CONF_PASSWORD, - CONF_REFRESH, CONF_PUBLIC_KEY, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP + CONF_EMAIL, + CONF_PASSWORD, + CONF_PUBLIC_KEY, + CONF_REFRESH, + CONF_USER_ID, + DOMAIN, + EVENT_HOMEASSISTANT_STOP, ) -from .RenphoWeight import RenphoWeight +from .renpho import RenphoWeight + +# from homeassistant.helpers import service +# from homeassistant.core import callback + # Initialize logger _LOGGER = logging.getLogger(__name__) # ------------------- Setup Methods ------------------- + async def async_setup(hass, config): """Set up the Renpho component from YAML configuration.""" _LOGGER.debug("Starting hass_renpho") - + conf = config.get(DOMAIN) if conf: await setup_renpho(hass, conf) return True + async def async_setup_entry(hass, entry): """Set up Renpho from a config entry.""" await setup_renpho(hass, entry.data) @@ -31,9 +39,10 @@ async def async_setup_entry(hass, entry): hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "sensor") ) - + return True + async def async_unload_entry(hass, entry): """Unload a config entry.""" # Remove Renpho instance if it exists @@ -42,44 +51,80 @@ async def async_unload_entry(hass, entry): del hass.data[DOMAIN] return True + # ------------------- Helper Methods ------------------- + async def setup_renpho(hass, conf): """Common setup logic for YAML and UI.""" email = conf[CONF_EMAIL] password = conf[CONF_PASSWORD] user_id = conf.get(CONF_USER_ID, None) refresh = conf.get(CONF_REFRESH, 600) - - renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) - - @callback - def async_on_start(event): - hass.async_create_task(async_prepare(hass, renpho, refresh)) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_on_start) + renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id, refresh) + + # @callback + # def async_on_start(event): + # hass.async_create_task(async_prepare(hass, renpho, refresh)) + + # hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_on_start) hass.data[DOMAIN] = renpho + async def async_prepare(hass, renpho, refresh): """Prepare and start polling.""" - await renpho.startPolling(refresh) + await renpho.start_polling(refresh) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_cleanup(renpho)) + async def async_cleanup(renpho): """Cleanup logic.""" - await renpho.stopPolling() + await renpho.stop_polling() + # ------------------- Main Method for Testing ------------------- if __name__ == "__main__": + async def main(): - renpho = RenphoWeight(CONF_PUBLIC_KEY, '', '', '') - await renpho.startPolling(10) - print(await renpho.getScaleUsers()) - print(await renpho.getSpecificMetricFromUserID("bodyfat")) - print(await renpho.getSpecificMetricFromUserID("bodyfat", "")) - print(await renpho.getInfo()) + import os + + from dotenv import load_dotenv + + load_dotenv() + email = os.environ.get("EMAIL") + password = os.environ.get("PASSWORD") + user_id = os.environ.get("USER_ID", None) + try: + renpho = RenphoWeight(CONF_PUBLIC_KEY, email, password, user_id) + print("Before polling") + renpho.start_polling(10) + print("After polling") + renpho.get_info_sync() + users = await renpho.get_scale_users() + print("Fetched scale users:", users) + metric = await renpho.get_specific_metric_from_user_ID("bodyfat") + print("Fetched specific metric:", metric) + metric_for_user = await renpho.get_specific_metric_from_user_ID( + "bodyfat", "" + ) + print("Fetched specific metric for user:", metric_for_user) + get_device_info = await renpho.get_device_info() + print("Fetched device info:", get_device_info) + list_growth_record = await renpho.list_growth_record() + print("Fetched list growth record:", list_growth_record) + list_girth = await renpho.list_girth() + print("Fetched list girth:", list_girth) + list_girth_goal = await renpho.list_girth_goal() + print("Fetched list girth goal:", list_girth_goal) + message_list = await renpho.message_list() + print("Fetched message list:", message_list) + info = await renpho.get_info() + print("Fetched info:", info) + except Exception as e: + print(f"An exception occurred: {e}") + input("Press Enter to stop polling") - await renpho.stopPolling() + renpho.stop_polling() asyncio.run(main()) diff --git a/custom_components/renpho/config_flow.py b/custom_components/renpho/config_flow.py index d66b8f0..f617125 100644 --- a/custom_components/renpho/config_flow.py +++ b/custom_components/renpho/config_flow.py @@ -1,6 +1,7 @@ # config_flow.py from __future__ import annotations + import logging from typing import Any @@ -8,61 +9,83 @@ from homeassistant import config_entries, exceptions from homeassistant.core import HomeAssistant -from .const import DOMAIN, CONF_USER_ID, CONF_PUBLIC_KEY, CONF_EMAIL, CONF_PASSWORD -from .RenphoWeight import RenphoWeight +from .const import CONF_EMAIL, CONF_PASSWORD, CONF_PUBLIC_KEY, CONF_USER_ID, DOMAIN +from .renpho import RenphoWeight _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema({ - vol.Required(CONF_EMAIL, description={"suggested_value": "example@email.com"}): str, - vol.Required(CONF_PASSWORD, description={"suggested_value": "Password"}): str, - vol.Optional(CONF_USER_ID, description={"suggested_value": "OptionalUserID"}): str, -}) +DATA_SCHEMA = vol.Schema( + { + vol.Required( + CONF_EMAIL, description={"suggested_value": "example@email.com"} + ): str, + vol.Required(CONF_PASSWORD, description={"suggested_value": "Password"}): str, + vol.Optional( + CONF_USER_ID, description={"suggested_value": "OptionalUserID"} + ): str, + } +) + async def async_validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: + """Validate the user input allows us to connect.""" _LOGGER.debug("Starting to validate input: %s", data) - renpho = RenphoWeight(CONF_PUBLIC_KEY, data[CONF_EMAIL], data[CONF_PASSWORD], data.get(CONF_USER_ID, None)) + renpho = RenphoWeight( + CONF_PUBLIC_KEY, + data[CONF_EMAIL], + data[CONF_PASSWORD], + data.get(CONF_USER_ID), + ) is_valid = await renpho.validate_credentials() if not is_valid: - raise CannotConnect(reason="Invalid credentials", details={"email": data[CONF_EMAIL], "user_id": data.get(CONF_USER_ID, None)}) + raise CannotConnect( + reason="Invalid credentials", + details={ + "email": data[CONF_EMAIL], + "user_id": data.get(CONF_USER_ID, None), + }, + ) return {"title": data[CONF_EMAIL]} + class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL async def async_step_user(self, user_input=None): errors = {} - + if user_input is not None: try: info = await async_validate_input(self.hass, user_input) return self.async_create_entry(title=info["title"], data=user_input) - + except CannotConnect as e: errors["base"] = "cannot_connect" - _LOGGER.error(f"Cannot connect due to {e.reason}. Details: {e.get_details()}") - + _LOGGER.error( + f"Cannot connect due to {e.reason}. Details: {e.get_details()}" + ) + except exceptions.HomeAssistantError as e: errors["base"] = "home_assistant_error" _LOGGER.error(f"Home Assistant specific error: {str(e)}") - + except Exception as e: # pylint: disable=broad-except errors["base"] = "unknown_error" _LOGGER.exception(f"Unexpected exception: {e}") - + # Use description_placeholders for dynamic info placeholders = { "additional_info": "Please provide your Renpho login details.", "icon": "renpho.png", - "description": "This is a description of your Renpho integration." + "description": "This is a description of your Renpho integration.", } return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors, - description_placeholders=placeholders + description_placeholders=placeholders, ) async def async_step_advanced_options(self, user_input=None): @@ -73,14 +96,15 @@ async def async_step_select_device(self, user_input=None): # Implement device selection step here pass + class CannotConnect(exceptions.HomeAssistantError): def __init__(self, reason: str = "", details: dict = None): super().__init__(self) self.reason = reason self.details = details or {} - + def __str__(self): return f"CannotConnect: {self.reason}" - + def get_details(self): return self.details diff --git a/custom_components/renpho/const.py b/custom_components/renpho/const.py index 464476e..0858ac9 100755 --- a/custom_components/renpho/const.py +++ b/custom_components/renpho/const.py @@ -3,7 +3,6 @@ # The domain of the component. Used to store data in hass.data. from typing import Final - DOMAIN: Final = "renpho" VERSION: Final = "1.0.0" EVENT_HOMEASSISTANT_CLOSE: Final = "homeassistant_close" @@ -14,11 +13,13 @@ TIME_SECONDS: Final = "s" # Configuration keys -CONF_EMAIL: Final = 'email' # The email used for Renpho login -CONF_PASSWORD: Final = 'password' # The password used for Renpho login -CONF_REFRESH: Final = 'refresh' # Refresh rate for pulling new data -CONF_UNIT: Final = 'unit' # Unit of measurement for weight (kg/lbs) -CONF_USER_ID: Final = 'user_id' # The ID of the user for whom weight data should be fetched +CONF_EMAIL: Final = "email" # The email used for Renpho login +CONF_PASSWORD: Final = "password" # The password used for Renpho login +CONF_REFRESH: Final = "refresh" # Refresh rate for pulling new data +CONF_UNIT: Final = "unit" # Unit of measurement for weight (kg/lbs) +CONF_USER_ID: Final = ( + "user_id" # The ID of the user for whom weight data should be fetched +) KG_TO_LBS: Final = 2.20462 CM_TO_INCH: Final = 0.393701 @@ -65,10 +66,60 @@ # Age Metrics BODYAGE: Final = "bodyage" +GIRTH_METRICS: Final = [ + "neck_value", + "shoulder_value", + "arm_value", + "chest_value", + "waist_value", + "hip_value", + "thigh_value", + "calf_value", + "left_arm_value", + "left_thigh_value", + "left_calf_value", + "right_arm_value", + "right_thigh_value", + "right_calf_value", + "whr_value", + "abdomen_value", + "custom", + "custom_value", + "custom_unit", + "custom1", + "custom_value1", + "custom_unit1", + "custom2", + "custom_value2", + "custom_unit2", + "custom3", + "custom_value3", + "custom_unit3", + "custom4", + "custom_value4", + "custom_unit4", + "custom5", + "custom_value5", + "custom_unit5", +] + +# Constants for Girth Goals +GIRTH_GOALS: Final = [ + "girth_type", + "setup_goal_at", + "goal_value", + "goal_unit", + "initial_value", + "initial_unit", + "finish_goal_at", + "finish_value", + "finish_unit", +] + # Public key for encrypting the password -CONF_PUBLIC_KEY: Final = '''-----BEGIN PUBLIC KEY----- +CONF_PUBLIC_KEY: Final = """-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC+25I2upukpfQ7rIaaTZtVE744 u2zV+HaagrUhDOTq8fMVf9yFQvEZh2/HKxFudUxP0dXUa8F6X4XmWumHdQnum3zm Jr04fz2b2WCcN0ta/rbF2nYAnMVAk2OJVZAMudOiMWhcxV1nNJiKgTNNr13de0EQ IiOL2CUBzu+HmIfUbQIDAQAB ------END PUBLIC KEY-----''' +-----END PUBLIC KEY-----""" diff --git a/custom_components/renpho/manifest.json b/custom_components/renpho/manifest.json index 6be9c14..8159d8e 100755 --- a/custom_components/renpho/manifest.json +++ b/custom_components/renpho/manifest.json @@ -5,7 +5,12 @@ "issue_tracker": "https://github.com/antoinebou12/hass_renpho/issues", "dependencies": [], "codeowners": ["@antoinebou12"], - "requirements": ["pycryptodome>=3.3.1", "requests>=2.25.0", "aiohttp>=3.6.1", "voluptuous>=0.11.7"], + "requirements": [ + "pycryptodome>=3.3.1", + "requests>=2.25.0", + "aiohttp>=3.6.1", + "voluptuous>=0.11.7" + ], "iot_class": "cloud_polling", "version": "1.0.0", "config_flow": true diff --git a/custom_components/renpho/renpho.py b/custom_components/renpho/renpho.py new file mode 100755 index 0000000..93dc5a6 --- /dev/null +++ b/custom_components/renpho/renpho.py @@ -0,0 +1,570 @@ +import asyncio +import datetime +import json +import logging +import time +from base64 import b64encode +from threading import Timer +from typing import Dict, List, Optional, Union + +import aiohttp +import requests +from Crypto.Cipher import PKCS1_v1_5 +from Crypto.PublicKey import RSA + +# Initialize logging +_LOGGER = logging.getLogger(__name__) + +# API Endpoints +API_AUTH_URL = "https://renpho.qnclouds.com/api/v3/users/sign_in.json?app_id=Renpho" +API_SCALE_USERS_URL = "https://renpho.qnclouds.com/api/v3/scale_users/list_scale_user" +API_MEASUREMENTS_URL = "https://renpho.qnclouds.com/api/v2/measurements/list.json" +DEVICE_INFO_URL = "https://renpho.qnclouds.com/api/v2/device_binds/get_device.json" +LATEST_MODEL_URL = "https://renpho.qnclouds.com/api/v3/devices/list_lastest_model.json" +GIRTH_URL = "https://renpho.qnclouds.com/api/v3/girths/list_girth.json" +GIRTH_GOAL_URL = "https://renpho.qnclouds.com/api/v3/girth_goals/list_girth_goal.json" +GROWTH_RECORD_URL = ( + "https://renpho.qnclouds.com/api/v3/growth_records/list_growth_record.json" +) + + +class RenphoWeight: + """ + A class to interact with Renpho's weight scale API. + + Attributes: + public_key (str): The public RSA key used for encrypting the password. + email (str): The email address for the Renpho account. + password (str): The password for the Renpho account. + user_id (str, optional): The ID of the user for whom weight data should be fetched. + weight (float): The most recent weight measurement. + time_stamp (int): The timestamp of the most recent weight measurement. + session_key (str): The session key obtained after successful authentication. + """ + + def __init__(self, public_key, email, password, user_id=None, refresh=None): + """Initialize a new RenphoWeight instance.""" + self.public_key = public_key + self.email = email + self.password = password + if user_id == "": + self.user_id = None + self.user_id = user_id + self.weight = None + self.time_stamp = None + self.session_key = None + if refresh is None: + self.refresh = 60 + self.session = aiohttp.ClientSession() + + @staticmethod + def get_week_ago_timestamp() -> int: + week_ago = datetime.date.today() - datetime.timedelta(days=7) + return int(time.mktime(week_ago.timetuple())) + + def prepare_data(self, data): + if isinstance(data, bytes): + return data.decode("utf-8") + elif isinstance(data, dict): + return {key: self.prepare_data(value) for key, value in data.items()} + elif isinstance(data, list): + return [self.prepare_data(element) for element in data] + else: + return data + + async def _request(self, method: str, url: str, **kwargs) -> Union[Dict, List]: + try: + kwargs = self.prepare_data(kwargs) + # Reuse session + async with self.session.request(method, url, **kwargs) as response: + response.raise_for_status() + parsed_response = await response.json() + + if parsed_response.get("status_code") == "40302": + await self.auth() + + return parsed_response + except Exception as e: + _LOGGER.error(f"Error in request: {e}") + raise APIError("API request failed") # Raise a custom exception + + def _request_sync(self, method: str, url: str, **kwargs) -> Union[Dict, List]: + try: + kwargs = self.prepare_data(kwargs) # Update this line + response = requests.request(method, url, **kwargs) + response.raise_for_status() + return response.json() + except Exception as e: + _LOGGER.error(f"Error in request: {e}") + raise # Or raise a custom exception + + def auth_sync(self): + """ + Authenticate with the Renpho API to obtain a session key. + """ + if not self.email or not self.password: + raise Exception("Email and password must be provided") + + key = RSA.importKey(self.public_key) + cipher = PKCS1_v1_5.new(key) + encrypted_password = b64encode(cipher.encrypt(self.password.encode("utf-8"))) + + data = {"secure_flag": 1, "email": self.email, "password": encrypted_password} + parsed = self._request_sync("POST", API_AUTH_URL, data=data) + + if "terminal_user_session_key" not in parsed: + raise AuthenticationError("Authentication failed") + + self.session_key = parsed["terminal_user_session_key"] + return parsed + + async def auth(self): + """ + Authenticate with the Renpho API to obtain a session key. + """ + if not self.email or not self.password: + raise AuthenticationError("Email and password must be provided") + + key = RSA.importKey(self.public_key) + cipher = PKCS1_v1_5.new(key) + encrypted_password = b64encode(cipher.encrypt(self.password.encode("utf-8"))) + + data = {"secure_flag": "1", "email": self.email, "password": encrypted_password} + parsed = await self._request("POST", API_AUTH_URL, json=data) + + if "terminal_user_session_key" not in parsed: + raise AuthenticationError("Authentication failed") + + self.session_key = parsed["terminal_user_session_key"] + return parsed + + async def validate_credentials(self): + """ + Validate the current credentials by attempting to authenticate. + Returns True if authentication succeeds, False otherwise. + """ + try: + await self.auth() + return True + except Exception as e: + _LOGGER.error(f"Validation failed: {e}") + return False + + def get_scale_users_sync(self): + """ + Fetch the list of users associated with the scale. + """ + url = f"{API_SCALE_USERS_URL}?locale=en&terminal_user_session_key={self.session_key}" + parsed = self._request_sync("GET", url) + self.set_user_id(parsed["scale_users"][0]["user_id"]) + return parsed["scale_users"] + + async def get_scale_users(self): + """ + Fetch the list of users associated with the scale. + """ + try: + url = f"{API_SCALE_USERS_URL}?locale=en&terminal_user_session_key={self.session_key}" + parsed = await self._request("GET", url) + + if not parsed or "scale_users" not in parsed: + _LOGGER.warning( + "Invalid response or 'scale_users' not in the response." + ) + return None + + self.set_user_id(parsed["scale_users"][0]["user_id"]) + return parsed["scale_users"] + except aiohttp.ClientError as e: + _LOGGER.error(f"Aiohttp client error: {e}") + except Exception as e: + _LOGGER.error(f"An unexpected error occurred: {e}") + return None + + def get_measurements_sync(self) -> Optional[List[Dict]]: + """ + Fetch the most recent weight measurements for the user. + """ + try: + today = datetime.date.today() + week_ago = today - datetime.timedelta(days=7) + week_ago_timestamp = int(time.mktime(week_ago.timetuple())) + url = f"{API_MEASUREMENTS_URL}?user_id={self.user_id}&last_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + parsed = self._request_sync("GET", url) + + if "last_ary" not in parsed: + _LOGGER.warning(f"Field 'last_ary' is not in the response: {parsed}") + return None + + last_measurement = parsed["last_ary"][0] + self.weight = last_measurement.get("weight", None) + self.time_stamp = last_measurement.get("time_stamp", None) + return parsed["last_ary"] + except Exception as e: + _LOGGER.error(f"An error occurred: {e}") + return None + + async def get_measurements(self) -> Optional[List[Dict]]: + """ + Fetch the most recent weight measurements for the user. + """ + try: + today = datetime.date.today() + week_ago = today - datetime.timedelta(days=7) + week_ago_timestamp = int(time.mktime(week_ago.timetuple())) + url = f"{API_MEASUREMENTS_URL}?user_id={self.user_id}&last_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + parsed = await self._request("GET", url) + + if not parsed or "last_ary" not in parsed: + _LOGGER.warning("Invalid response or 'last_ary' not in the response.") + return None + + last_measurement = parsed["last_ary"][0] + self.weight = last_measurement.get("weight", None) + self.time_stamp = last_measurement.get("time_stamp", None) + return parsed["last_ary"] + except aiohttp.ClientError as e: + _LOGGER.error(f"Aiohttp client error: {e}") + except Exception as e: + _LOGGER.error(f"An unexpected error occurred: {e}") + return None + + def get_specific_metric_sync( + self, metric_type: str, metric: str, user_id: Optional[str] = None + ) -> Optional[float]: + """ + Synchronous version of get_specific_metric based on the type specified (weight, growth goal, or growth metric). + + Parameters: + metric_type (str): The type of metric to fetch ('weight', 'growth_goal', 'growth'). + metric (str): The specific metric to fetch (e.g., "height", "growth_rate", "weight"). + user_id (str, optional): The user ID for whom to fetch the metric. Defaults to None. + + Returns: + float, None: The fetched metric value, or None if it couldn't be fetched. + """ + try: + if user_id: + self.set_user_id(user_id) # Update the user_id if provided + + if metric_type == "weight": + last_measurement = self.get_measurements_sync() + return ( + last_measurement[0].get(metric, None) if last_measurement else None + ) + + elif metric_type == "growth_goal": + growth_goal_info = self.list_growth_goal_sync() + last_goal = ( + growth_goal_info.get("growth_goals", [])[0] + if growth_goal_info.get("growth_goals") + else None + ) + return last_goal.get(metric, None) if last_goal else None + + elif metric_type == "growth": + growth_info = self.list_growth_sync() + last_measurement = ( + growth_info.get("growths", [])[0] + if growth_info.get("growths") + else None + ) + return last_measurement.get(metric, None) if last_measurement else None + + else: + _LOGGER.error( + "Invalid metric_type. Must be 'weight', 'growth_goal', or 'growth'." + ) + return None + + except Exception as e: + _LOGGER.error(f"An error occurred: {e}") + return None + + async def get_specific_metric( + self, metric_type: str, metric: str, user_id: Optional[str] = None + ) -> Optional[float]: + """ + Fetch a specific metric based on the type specified (weight, growth goal, or growth metric). + + Parameters: + metric_type (str): The type of metric to fetch ('weight', 'growth_goal', 'growth'). + metric (str): The specific metric to fetch (e.g., "height", "growth_rate", "weight"). + user_id (str, optional): The user ID for whom to fetch the metric. Defaults to None. + + Returns: + float, None: The fetched metric value, or None if it couldn't be fetched. + """ + try: + if user_id: + self.set_user_id(user_id) # Update the user_id if provided + + if metric_type == "weight": + last_measurement = await self.get_measurements() + return ( + last_measurement[0].get(metric, None) if last_measurement else None + ) + + elif metric_type == "growth_goal": + growth_goal_info = await self.list_growth_goal() + last_goal = ( + growth_goal_info.get("growth_goals", [])[0] + if growth_goal_info.get("growth_goals") + else None + ) + return last_goal.get(metric, None) if last_goal else None + elif metric_type == "growth": + growth_info = await self.list_growth() + last_measurement = ( + growth_info.get("growths", [])[0] + if growth_info.get("growths") + else None + ) + return last_measurement.get(metric, None) if last_measurement else None + else: + print( + "Invalid metric_type. Must be 'weight', 'growth_goal', or 'growth'." + ) + return None + + except Exception as e: + print(f"An error occurred: {e}") + return None + + async def get_specific_metric_from_user_ID( + self, metric_type: str, metric: str, user_id: Optional[str] = None + ) -> Optional[float]: + """ + Fetch a specific metric for a particular user ID based on the type specified (weight, growth goal, or growth metric). + + Parameters: + metric_type (str): The type of metric to fetch ('weight', 'growth_goal', 'growth'). + metric (str): The specific metric to fetch (e.g., "height", "growth_rate", "weight"). + user_id (str, optional): The user ID for whom to fetch the metric. Defaults to None. + + Returns: + float, None: The fetched metric value, or None if it couldn't be fetched. + """ + try: + if user_id: + self.set_user_id(user_id) # Update the user_id if provided + + if metric_type == "weight": + last_measurement = await self.get_measurements() + return ( + last_measurement[0].get(metric, None) if last_measurement else None + ) + + elif metric_type == "growth_goal": + growth_goal_info = await self.list_growth_goal() + last_goal = ( + growth_goal_info.get("growth_goals", [])[0] + if growth_goal_info.get("growth_goals") + else None + ) + return last_goal.get(metric, None) if last_goal else None + + elif metric_type == "growth": + growth_info = await self.list_growth() + last_measurement = ( + growth_info.get("growths", [])[0] + if growth_info.get("growths") + else None + ) + return last_measurement.get(metric, None) if last_measurement else None + + else: + print( + "Invalid metric_type. Must be 'weight', 'growth_goal', or 'growth'." + ) + return None + + except Exception as e: + print(f"An error occurred: {e}") + return None + + def get_info_sync(self): + """ + Wrapper method to authenticate, fetch users, and get measurements. + """ + self.auth_sync() + self.get_scale_users_sync() + self.get_measurements_sync() + return self.get_measurements_sync() + + async def get_info(self): + """ + Wrapper method to authenticate, fetch users, and get measurements. + """ + await self.auth() + await self.get_scale_users() + return await self.get_measurements() + + async def start_polling(self, polling_interval=60): + """ + Start polling for weight data at a given interval. + """ + await self.get_info() + polling_interval = polling_interval if polling_interval > 0 else 60 + while True: + await asyncio.sleep(self.refresh) + await self.get_info() + + def stop_polling(self): + """ + Stop polling for weight data. + """ + if hasattr(self, "polling"): + self.polling.cancel() + + def set_user_id(self, user_id): + """ + Set the user ID for whom the weight data should be fetched. + """ + self.user_id = user_id + + def get_user_id(self): + """ + Get the current user ID for whom the weight data is being fetched. + """ + return self.user_id + + async def get_device_info(self): + """ + Asynchronously get device information. + Returns: + dict: The API response as a dictionary. + """ + week_ago_timestamp = self.get_week_ago_timestamp() + url = f"{DEVICE_INFO_URL}?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + return await self._request("GET", url) + + async def list_latest_model(self): + """ + Asynchronously list the latest model information. + + Returns: + dict: The API response as a dictionary. + """ + week_ago_timestamp = self.get_week_ago_timestamp() + url = f"{LATEST_MODEL_URL}?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + return await self._request("GET", url) + + async def list_girth(self): + """ + Asynchronously list girth information. + + Returns: + dict: The API response as a dictionary. + """ + week_ago_timestamp = self.get_week_ago_timestamp() + url = f"{GIRTH_URL}?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + return await self._request("GET", url) + + async def list_girth_goal(self): + """ + Asynchronously list girth goal information. + + Returns: + dict: The API response as a dictionary. + """ + week_ago_timestamp = self.get_week_ago_timestamp() + url = f"{GIRTH_GOAL_URL}?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + return await self._request("GET", url) + + async def get_specific_growth_goal_metric( + self, metric: str, user_id: Optional[str] = None + ) -> Optional[float]: + """ + Fetch a specific growth goal metric for a particular user ID from the most recent growth goal information. + + Parameters: + metric (str): The specific metric to fetch (e.g., "height_goal", "growth_rate_goal"). + user_id (str, optional): The user ID for whom to fetch the metric. Defaults to None. + + Returns: + float, None: The fetched metric value, or None if it couldn't be fetched. + """ + try: + if user_id: + self.set_user_id(user_id) # Update the user_id if provided + growth_goal_info = await self.list_growth_goal() + last_goal = ( + growth_goal_info.get("growth_goals", [])[0] + if growth_goal_info.get("growth_goals") + else None + ) + return last_goal.get(metric, None) if last_goal else None + except Exception as e: + print(f"An error occurred: {e}") + return None + + async def list_growth_record(self): + """ + Asynchronously list growth records. + Returns: + dict: The API response as a dictionary. + """ + week_ago_timestamp = self.get_week_ago_timestamp() + url = f"https://renpho.qnclouds.com/api/v3/growth_records/list_growth_record.json?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + return await self._request("GET", url) + + async def get_specific_growth_metric( + self, metric: str, user_id: Optional[str] = None + ) -> Optional[float]: + """ + Fetch a specific growth metric for a particular user ID from the most recent growth measurement. + + Parameters: + metric (str): The specific metric to fetch (e.g., "height", "growth_rate"). + user_id (str, optional): The user ID for whom to fetch the metric. Defaults to None. + + Returns: + float, None: The fetched metric value, or None if it couldn't be fetched. + """ + try: + if user_id: + self.set_user_id(user_id) # Update the user_id if provided + growth_info = await self.list_growth() + last_measurement = ( + growth_info.get("growths", [])[0] + if growth_info.get("growths") + else None + ) + return last_measurement.get(metric, None) if last_measurement else None + except Exception as e: + print(f"An error occurred: {e}") + return None + + async def message_list(self): + week_ago_timestamp = self.get_week_ago_timestamp() + url = f"https://renpho.qnclouds.com/api/v2/messages/list.json?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + return await self._request("GET", url) + + async def close(self): + """ + Shutdown the executor when you are done using the RenphoWeight instance. + """ + await self.session.close() + self.executor.shutdown() + + +class Interval(Timer): + """ + A subclass of Timer to repeatedly run a function at a specified interval. + """ + + def run(self): + """ + Run the function at the given interval. + """ + while not self.finished.wait(self.interval): + self.function(*self.args, **self.kwargs) + + +class AuthenticationError(Exception): + pass + + +class APIError(Exception): + pass diff --git a/custom_components/renpho/sensor.py b/custom_components/renpho/sensor.py index 5c854d3..40dda09 100755 --- a/custom_components/renpho/sensor.py +++ b/custom_components/renpho/sensor.py @@ -1,215 +1,690 @@ """Platform for sensor integration.""" from __future__ import annotations + from datetime import datetime from homeassistant.components.sensor import SensorEntity -from homeassistant.util import slugify -from homeassistant.core import callback - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import MASS_KILOGRAMS, TIME_SECONDS +from homeassistant.const import MASS_KILOGRAMS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import slugify -from .RenphoWeight import _LOGGER -from .const import CM_TO_INCH, DOMAIN, CONF_EMAIL, CONF_PASSWORD, KG_TO_LBS - - -async def async_setup_entry( +from .const import CM_TO_INCH, DOMAIN, KG_TO_LBS +from .renpho import _LOGGER, RenphoWeight + + +async def sensors_list( + hass: HomeAssistant, config_entry: ConfigEntry +) -> list[RenphoSensor]: + sensor_configurations = [ + # Physical Metrics + { + "id": "weight", + "name": "Weight", + "unit": "kg", + "category": "Measurements", + "label": "Physical Metrics", + }, + { + "id": "bmi", + "name": "BMI", + "unit": "", + "category": "Measurements", + "label": "Physical Metrics", + }, + { + "id": "muscle", + "name": "Muscle Mass", + "unit": "%", + "category": "Measurements", + "label": "Physical Metrics", + }, + { + "id": "bone", + "name": "Bone Mass", + "unit": "%", + "category": "Measurements", + "label": "Physical Metrics", + }, + { + "id": "waistline", + "name": "Waistline", + "unit": "cm", + "category": "Measurements", + "label": "Physical Metrics", + }, + { + "id": "hip", + "name": "Hip", + "unit": "cm", + "category": "Measurements", + "label": "Physical Metrics", + }, + { + "id": "stature", + "name": "Stature", + "unit": "cm", + "category": "Measurements", + "label": "Physical Metrics", + }, + # Body Composition + { + "id": "bodyfat", + "name": "Body Fat", + "unit": "%", + "category": "Measurements", + "label": "Body Composition", + }, + { + "id": "water", + "name": "Water Content", + "unit": "%", + "category": "Measurements", + "label": "Body Composition", + }, + { + "id": "subfat", + "name": "Subcutaneous Fat", + "unit": "%", + "category": "Measurements", + "label": "Body Composition", + }, + { + "id": "visfat", + "name": "Visceral Fat", + "unit": "Level", + "category": "Measurements", + "label": "Body Composition", + }, + # Metabolic Metrics + { + "id": "bmr", + "name": "BMR", + "unit": "kcal/day", + "category": "Measurements", + "label": "Metabolic Metrics", + }, + { + "id": "protein", + "name": "Protein Content", + "unit": "%", + "category": "Measurements", + "label": "Metabolic Metrics", + }, + # Age Metrics + { + "id": "bodyage", + "name": "Body Age", + "unit": "Years", + "category": "Measurements", + "label": "Age Metrics", + }, + # Device Information + { + "id": "mac", + "name": "MAC Address", + "unit": "", + "category": "Device", + "label": "Device Information", + }, + { + "id": "scale_type", + "name": "Scale Type", + "unit": "", + "category": "Device", + "label": "Device Information", + }, + { + "id": "scale_name", + "name": "Scale Name", + "unit": "", + "category": "Device", + "label": "Device Information", + }, + # Miscellaneous + { + "id": "method", + "name": "Measurement Method", + "unit": "", + "category": "Miscellaneous", + "label": "Additional Metrics", + }, + { + "id": "pregnant_flag", + "name": "Pregnant Flag", + "unit": "", + "category": "Miscellaneous", + "label": "Additional Metrics", + }, + { + "id": "sport_flag", + "name": "Sport Flag", + "unit": "", + "category": "Miscellaneous", + "label": "Additional Metrics", + }, + { + "id": "score", + "name": "Score", + "unit": "", + "category": "Miscellaneous", + "label": "Additional Metrics", + }, + { + "id": "remark", + "name": "Remark", + "unit": "", + "category": "Miscellaneous", + "label": "Additional Metrics", + }, + # Meta Information + { + "id": "id", + "name": "Record ID", + "unit": "", + "category": "Meta", + "label": "Meta Information", + }, + { + "id": "b_user_id", + "name": "User ID", + "unit": "", + "category": "Meta", + "label": "Meta Information", + }, + { + "id": "time_stamp", + "name": "Time Stamp", + "unit": "UNIX Time", + "category": "Meta", + "label": "Meta Information", + }, + { + "id": "created_at", + "name": "Created At", + "unit": "", + "category": "Meta", + "label": "Meta Information", + }, + # User Profile + { + "id": "gender", + "name": "Gender", + "unit": "", + "category": "User", + "label": "User Profile", + }, + { + "id": "height", + "name": "Height", + "unit": "cm", + "category": "User", + "label": "User Profile", + }, + { + "id": "birthday", + "name": "Birthday", + "unit": "", + "category": "User", + "label": "User Profile", + }, + # Electrical Measurements + { + "id": "resistance", + "name": "Electrical Resistance", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "sec_resistance", + "name": "Secondary Electrical Resistance", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "actual_resistance", + "name": "Actual Electrical Resistance", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "actual_sec_resistance", + "name": "Actual Secondary Electrical Resistance", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "resistance20_left_arm", + "name": "Resistance20 Left Arm", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "resistance20_left_leg", + "name": "Resistance20 Left Leg", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "resistance20_right_arm", + "name": "Resistance20 Right Arm", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "resistance20_right_leg", + "name": "Resistance20 Right Leg", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "resistance20_trunk", + "name": "Resistance20 Trunk", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "resistance100_left_arm", + "name": "Resistance100 Left Arm", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "resistance100_left_leg", + "name": "Resistance100 Left Leg", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "resistance100_right_arm", + "name": "Resistance100 Right Arm", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "resistance100_right_leg", + "name": "Resistance100 Right Leg", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + { + "id": "resistance100_trunk", + "name": "Resistance100 Trunk", + "unit": "Ohms", + "category": "Measurements", + "label": "Electrical Measurements", + }, + # Cardiovascular Metrics + { + "id": "heart_rate", + "name": "Heart Rate", + "unit": "bpm", + "category": "Measurements", + "label": "Cardiovascular Metrics", + }, + { + "id": "cardiac_index", + "name": "Cardiac Index", + "unit": "", + "category": "Measurements", + "label": "Cardiovascular Metrics", + }, + # Other Metrics + { + "id": "method", + "name": "Method Used", + "unit": "", + "category": "Miscellaneous", + "label": "Other Metrics", + }, + { + "id": "sport_flag", + "name": "Sports Flag", + "unit": "", + "category": "Miscellaneous", + "label": "Other Metrics", + }, + { + "id": "left_weight", + "name": "Left Weight", + "unit": "kg", + "category": "Measurements", + "label": "Other Metrics", + }, + { + "id": "right_weight", + "name": "Right Weight", + "unit": "kg", + "category": "Measurements", + "label": "Other Metrics", + }, + { + "id": "local_created_at", + "name": "Local Created At", + "unit": "", + "category": "Meta", + "label": "Other Metrics", + }, + { + "id": "time_zone", + "name": "Time Zone", + "unit": "", + "category": "Device", + "label": "Other Metrics", + }, + { + "id": "remark", + "name": "Additional Remarks", + "unit": "", + "category": "Miscellaneous", + "label": "Other Metrics", + }, + { + "id": "score", + "name": "Health Score", + "unit": "", + "category": "Miscellaneous", + "label": "Other Metrics", + }, + { + "id": "pregnant_flag", + "name": "Pregnancy Flag", + "unit": "", + "category": "Miscellaneous", + "label": "Other Metrics", + }, + { + "id": "stature", + "name": "Stature Information", + "unit": "cm", + "category": "Measurements", + "label": "Other Metrics", + }, + { + "id": "category", + "name": "Category Identifier", + "unit": "", + "category": "Miscellaneous", + "label": "Other Metrics", + }, + # Girth Measurements + { + "id": "neck_value", + "name": "Neck Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "shoulder_value", + "name": "Shoulder Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "arm_value", + "name": "Arm Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "chest_value", + "name": "Chest Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "waist_value", + "name": "Waist Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "hip_value", + "name": "Hip Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "thigh_value", + "name": "Thigh Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "calf_value", + "name": "Calf Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "left_arm_value", + "name": "Left Arm Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "left_thigh_value", + "name": "Left Thigh Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "left_calf_value", + "name": "Left Calf Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "right_arm_value", + "name": "Right Arm Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "right_thigh_value", + "name": "Right Thigh Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "right_calf_value", + "name": "Right Calf Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "whr_value", + "name": "WHR Value", + "unit": "ratio", + "category": "Measurements", + "label": "Girth Measurements", + }, + { + "id": "abdomen_value", + "name": "Abdomen Value", + "unit": "cm", + "category": "Measurements", + "label": "Girth Measurements", + }, + # Girth Goals + { + "id": "neck_goal_value", + "name": "Neck Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "shoulder_goal_value", + "name": "Shoulder Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "arm_goal_value", + "name": "Arm Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "chest_goal_value", + "name": "Chest Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "waist_goal_value", + "name": "Waist Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "hip_goal_value", + "name": "Hip Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "thigh_goal_value", + "name": "Thigh Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "calf_goal_value", + "name": "Calf Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "left_arm_goal_value", + "name": "Left Arm Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "left_thigh_goal_value", + "name": "Left Thigh Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "left_calf_goal_value", + "name": "Left Calf Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "right_arm_goal_value", + "name": "Right Arm Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "right_thigh_goal_value", + "name": "Right Thigh Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "right_calf_goal_value", + "name": "Right Calf Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "whr_goal_value", + "name": "WHR Goal Value", + "unit": "ratio", + "category": "Goals", + "label": "Girth Goals", + }, + { + "id": "abdomen_goal_value", + "name": "Abdomen Goal Value", + "unit": "cm", + "category": "Goals", + "label": "Girth Goals", + }, + ] + + return [ + RenphoSensor(hass.data[DOMAIN], **config) for config in sensor_configurations + ] + + +async def async_setup( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Renpho sensor platform.""" - renpho = hass.data[DOMAIN] - async_add_entities( - [ - # Physical Metrics - RenphoSensor(renpho, "weight", "Weight", "kg", category="Measurements", label="Physical Metrics"), - RenphoSensor(renpho, "bmi", "BMI", "", category="Measurements", label="Physical Metrics"), - RenphoSensor(renpho, "muscle", "Muscle Mass", "%", category="Measurements", label="Physical Metrics"), - RenphoSensor(renpho, "bone", "Bone Mass", "%", category="Measurements", label="Physical Metrics"), - RenphoSensor(renpho, "waistline", "Waistline", "cm", category="Measurements", label="Physical Metrics"), - RenphoSensor(renpho, "hip", "Hip", "cm", category="Measurements", label="Physical Metrics"), - RenphoSensor(renpho, "stature", "Stature", "cm", category="Measurements", label="Physical Metrics"), - - # Body Composition - RenphoSensor(renpho, "bodyfat", "Body Fat", "%", category="Measurements", label="Body Composition"), - RenphoSensor(renpho, "water", "Water Content", "%", category="Measurements", label="Body Composition"), - RenphoSensor(renpho, "subfat", "Subcutaneous Fat", "%", category="Measurements", label="Body Composition"), - RenphoSensor(renpho, "visfat", "Visceral Fat", "Level", category="Measurements", label="Body Composition"), - - # Metabolic Metrics - RenphoSensor(renpho, "bmr", "BMR", "kcal/day", category="Measurements", label="Metabolic Metrics"), - RenphoSensor(renpho, "protein", "Protein Content", "%", category="Measurements", label="Metabolic Metrics"), - - # Age Metrics - RenphoSensor(renpho, "bodyage", "Body Age", "Years", category="Measurements", label="Age Metrics"), - - # Device Information - RenphoSensor(renpho, "mac", "MAC Address", "", category="Device", label="Device Information"), - RenphoSensor(renpho, "scale_type", "Scale Type", "", category="Device", label="Device Information"), - RenphoSensor(renpho, "scale_name", "Scale Name", "", category="Device", label="Device Information"), +): + sensor_entities = await sensors_list(hass, config_entry) + async_add_entities(sensor_entities) - # Miscellaneous - RenphoSensor(renpho, "method", "Measurement Method", "", category="Miscellaneous", label="Additional Metrics"), - RenphoSensor(renpho, "pregnant_flag", "Pregnant Flag", "", category="Miscellaneous", label="Additional Metrics"), - RenphoSensor(renpho, "sport_flag", "Sport Flag", "", category="Miscellaneous", label="Additional Metrics"), - RenphoSensor(renpho, "score", "Score", "", category="Miscellaneous", label="Additional Metrics"), - RenphoSensor(renpho, "remark", "Remark", "", category="Miscellaneous", label="Additional Metrics"), - # Meta Information - RenphoSensor(renpho, "id", "Record ID", "", category="Meta", label="Meta Information"), - RenphoSensor(renpho, "b_user_id", "User ID", "", category="Meta", label="Meta Information"), - RenphoSensor(renpho, "time_stamp", "Time Stamp", "UNIX Time", category="Meta", label="Meta Information"), - RenphoSensor(renpho, "created_at", "Created At", "", category="Meta", label="Meta Information"), - - # User Profile - RenphoSensor(renpho, "gender", "Gender", "", category="User", label="User Profile"), - RenphoSensor(renpho, "height", "Height", "cm", category="User", label="User Profile"), - RenphoSensor(renpho, "birthday", "Birthday", "", category="User", label="User Profile"), - - # Electrical Measurements - RenphoSensor(renpho, "resistance", "Electrical Resistance", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "sec_resistance", "Secondary Electrical Resistance", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "actual_resistance", "Actual Electrical Resistance", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "actual_sec_resistance", "Actual Secondary Electrical Resistance", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance20_left_arm", "Resistance20 Left Arm", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance20_left_leg", "Resistance20 Left Leg", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance20_right_arm", "Resistance20 Right Arm", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance20_right_leg", "Resistance20 Right Leg", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance20_trunk", "Resistance20 Trunk", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance100_left_arm", "Resistance100 Left Arm", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance100_left_leg", "Resistance100 Left Leg", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance100_right_arm", "Resistance100 Right Arm", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance100_right_leg", "Resistance100 Right Leg", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance100_trunk", "Resistance100 Trunk", "Ohms", category="Measurements", label="Electrical Measurements"), - - - # Cardiovascular Metrics - RenphoSensor(renpho, "heart_rate", "Heart Rate", "bpm", category="Measurements", label="Cardiovascular Metrics"), - RenphoSensor(renpho, "cardiac_index", "Cardiac Index", "", category="Measurements", label="Cardiovascular Metrics"), - - # Other Metrics - RenphoSensor(renpho, "method", "Method Used", "", category="Miscellaneous", label="Other Metrics"), - RenphoSensor(renpho, "sport_flag", "Sports Flag", "", category="Miscellaneous", label="Other Metrics"), - RenphoSensor(renpho, "left_weight", "Left Weight", "kg", category="Measurements", label="Other Metrics"), - RenphoSensor(renpho, "right_weight", "Right Weight", "kg", category="Measurements", label="Other Metrics"), - RenphoSensor(renpho, "local_created_at", "Local Created At", "", category="Meta", label="Other Metrics"), - RenphoSensor(renpho, "time_zone", "Time Zone", "", category="Device", label="Other Metrics"), - RenphoSensor(renpho, "remark", "Additional Remarks", "", category="Miscellaneous", label="Other Metrics"), - RenphoSensor(renpho, "score", "Health Score", "", category="Miscellaneous", label="Other Metrics"), - RenphoSensor(renpho, "pregnant_flag", "Pregnancy Flag", "", category="Miscellaneous", label="Other Metrics"), - RenphoSensor(renpho, "stature", "Stature Information", "cm", category="Measurements", label="Other Metrics"), - RenphoSensor(renpho, "category", "Category Identifier", "", category="Miscellaneous", label="Other Metrics"), - ] - ) - -# Existing setup_platform function def setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType = None, -) -> None: - """Set up the sensor platform.""" - - renpho = hass.data[DOMAIN] - - # sensor_configurations = [] - # entities = [RenphoSensor(renpho, *config) for config in sensor_configurations] - # add_entities(entities) - - - add_entities( - [ - # Physical Metrics - RenphoSensor(renpho, "weight", "Weight", "kg", category="Measurements", label="Physical Metrics"), - RenphoSensor(renpho, "bmi", "BMI", "", category="Measurements", label="Physical Metrics"), - RenphoSensor(renpho, "muscle", "Muscle Mass", "%", category="Measurements", label="Physical Metrics"), - RenphoSensor(renpho, "bone", "Bone Mass", "%", category="Measurements", label="Physical Metrics"), - RenphoSensor(renpho, "waistline", "Waistline", "cm", category="Measurements", label="Physical Metrics"), - RenphoSensor(renpho, "hip", "Hip", "cm", category="Measurements", label="Physical Metrics"), - RenphoSensor(renpho, "stature", "Stature", "cm", category="Measurements", label="Physical Metrics"), - - # Body Composition - RenphoSensor(renpho, "bodyfat", "Body Fat", "%", category="Measurements", label="Body Composition"), - RenphoSensor(renpho, "water", "Water Content", "%", category="Measurements", label="Body Composition"), - RenphoSensor(renpho, "subfat", "Subcutaneous Fat", "%", category="Measurements", label="Body Composition"), - RenphoSensor(renpho, "visfat", "Visceral Fat", "Level", category="Measurements", label="Body Composition"), - - # Metabolic Metrics - RenphoSensor(renpho, "bmr", "BMR", "kcal/day", category="Measurements", label="Metabolic Metrics"), - RenphoSensor(renpho, "protein", "Protein Content", "%", category="Measurements", label="Metabolic Metrics"), - - # Age Metrics - RenphoSensor(renpho, "bodyage", "Body Age", "Years", category="Measurements", label="Age Metrics"), - - # Device Information - RenphoSensor(renpho, "mac", "MAC Address", "", category="Device", label="Device Information"), - RenphoSensor(renpho, "scale_type", "Scale Type", "", category="Device", label="Device Information"), - RenphoSensor(renpho, "scale_name", "Scale Name", "", category="Device", label="Device Information"), - - # Miscellaneous - RenphoSensor(renpho, "method", "Measurement Method", "", category="Miscellaneous", label="Additional Metrics"), - RenphoSensor(renpho, "pregnant_flag", "Pregnant Flag", "", category="Miscellaneous", label="Additional Metrics"), - RenphoSensor(renpho, "sport_flag", "Sport Flag", "", category="Miscellaneous", label="Additional Metrics"), - RenphoSensor(renpho, "score", "Score", "", category="Miscellaneous", label="Additional Metrics"), - RenphoSensor(renpho, "remark", "Remark", "", category="Miscellaneous", label="Additional Metrics"), - - # Meta Information - RenphoSensor(renpho, "id", "Record ID", "", category="Meta", label="Meta Information"), - RenphoSensor(renpho, "b_user_id", "User ID", "", category="Meta", label="Meta Information"), - RenphoSensor(renpho, "time_stamp", "Time Stamp", "UNIX Time", category="Meta", label="Meta Information"), - RenphoSensor(renpho, "created_at", "Created At", "", category="Meta", label="Meta Information"), - - # User Profile - RenphoSensor(renpho, "gender", "Gender", "", category="User", label="User Profile"), - RenphoSensor(renpho, "height", "Height", "cm", category="User", label="User Profile"), - RenphoSensor(renpho, "birthday", "Birthday", "", category="User", label="User Profile"), - - # Electrical Measurements - RenphoSensor(renpho, "resistance", "Electrical Resistance", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "sec_resistance", "Secondary Electrical Resistance", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "actual_resistance", "Actual Electrical Resistance", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "actual_sec_resistance", "Actual Secondary Electrical Resistance", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance20_left_arm", "Resistance20 Left Arm", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance20_left_leg", "Resistance20 Left Leg", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance20_right_arm", "Resistance20 Right Arm", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance20_right_leg", "Resistance20 Right Leg", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance20_trunk", "Resistance20 Trunk", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance100_left_arm", "Resistance100 Left Arm", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance100_left_leg", "Resistance100 Left Leg", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance100_right_arm", "Resistance100 Right Arm", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance100_right_leg", "Resistance100 Right Leg", "Ohms", category="Measurements", label="Electrical Measurements"), - RenphoSensor(renpho, "resistance100_trunk", "Resistance100 Trunk", "Ohms", category="Measurements", label="Electrical Measurements"), - - - # Cardiovascular Metrics - RenphoSensor(renpho, "heart_rate", "Heart Rate", "bpm", category="Measurements", label="Cardiovascular Metrics"), - RenphoSensor(renpho, "cardiac_index", "Cardiac Index", "", category="Measurements", label="Cardiovascular Metrics"), - - # Other Metrics - RenphoSensor(renpho, "method", "Method Used", "", category="Miscellaneous", label="Other Metrics"), - RenphoSensor(renpho, "sport_flag", "Sports Flag", "", category="Miscellaneous", label="Other Metrics"), - RenphoSensor(renpho, "left_weight", "Left Weight", "kg", category="Measurements", label="Other Metrics"), - RenphoSensor(renpho, "right_weight", "Right Weight", "kg", category="Measurements", label="Other Metrics"), - RenphoSensor(renpho, "local_created_at", "Local Created At", "", category="Meta", label="Other Metrics"), - RenphoSensor(renpho, "time_zone", "Time Zone", "", category="Device", label="Other Metrics"), - RenphoSensor(renpho, "remark", "Additional Remarks", "", category="Miscellaneous", label="Other Metrics"), - RenphoSensor(renpho, "score", "Health Score", "", category="Miscellaneous", label="Other Metrics"), - RenphoSensor(renpho, "pregnant_flag", "Pregnancy Flag", "", category="Miscellaneous", label="Other Metrics"), - RenphoSensor(renpho, "stature", "Stature Information", "cm", category="Measurements", label="Other Metrics"), - RenphoSensor(renpho, "category", "Category Identifier", "", category="Miscellaneous", label="Other Metrics"), - ] - ) - +): + sensor_entities = sensors_list(hass, discovery_info) + add_entities(sensor_entities) class RenphoSensor(SensorEntity): def __init__( - self, renpho, metric, name, unit_of_measurement, category="Renpho", label="Data", convert_unit=False) -> None: + self, + renpho: RenphoWeight, + metric: str, + name: str, + unit_of_measurement: str, + category="Renpho", + label="Data", + convert_unit=False, + ) -> None: self._renpho = renpho self._metric = metric self._name = f"Renpho {name}" @@ -229,18 +704,14 @@ def unique_id(self) -> str: def device_state_attributes(self): """Return the state attributes.""" return { - 'timestamp': self._timestamp, - 'category': self._category, - 'label': self._label + "timestamp": self._timestamp, + "category": self._category, + "label": self._label, } - # Conversion method for kg to lbs - def kg_to_lbs(self, kg): - return kg * KG_TO_LBS - - # Conversion method for cm to inch - def cm_to_inch(self, cm): - return cm * CM_TO_INCH + def convert_unit(self, value, unit): + conversions = {"kg": value * KG_TO_LBS, "cm": value * CM_TO_INCH} + return conversions.get(unit, value) @property def name(self) -> str: @@ -248,8 +719,8 @@ def name(self) -> str: @property def state(self): - """ Return the state of the sensor. - If the unit is kg or cm, convert to lbs or inch respectively. + """Return the state of the sensor. + If the unit is kg or cm, convert to lbs or inch respectively. """ if self._convert_unit: @@ -261,8 +732,8 @@ def state(self): @property def unit_of_measurement(self) -> str: - """ Return the unit of measurement. - If the unit is kg or cm, convert to lbs or inch respectively. + """Return the unit of measurement. + If the unit is kg or cm, convert to lbs or inch respectively. """ if self._convert_unit: @@ -274,25 +745,31 @@ def unit_of_measurement(self) -> str: @property def category(self) -> str: - """ Return the category of the sensor. """ + """Return the category of the sensor.""" return self._category @property def label(self) -> str: - """ Return the label of the sensor. """ + """Return the label of the sensor.""" return self._label - async def async_update(self): - """Update the sensor using the event loop for asynchronous code.""" - try: - metric_value = await self._renpho.getSpecificMetric(self._metric) # Assuming asynchronous version - if metric_value is not None: - self._state = metric_value - self._timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - _LOGGER.info(f"Successfully updated {self._name}") - else: - _LOGGER.warning(f"{self._metric} returned None. Not updating {self._name}.") - except (ConnectionError, TimeoutError) as e: - _LOGGER.error(f"{type(e).__name__} updating {self._name}: {e}") - except Exception as e: - _LOGGER.error(f"An unexpected error occurred updating {self._name}: {e}") + +async def async_update(self): + """Update the sensor using the event loop for asynchronous code.""" + try: + metric_value = await self._renpho.get_specific_metric(self._metric) + + # Update state with the new metric_value + self._state = metric_value if metric_value is not None else self._state + + # Convert the unit if necessary + self._state = self.convert_unit(self._state, self._unit_of_measurement) + + # Update the timestamp + self._timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + _LOGGER.info(f"Successfully updated {self._name}") + except (ConnectionError, TimeoutError) as e: + _LOGGER.error(f"{type(e).__name__} updating {self._name}: {e}") + except Exception as e: + _LOGGER.error(f"An unexpected error occurred updating {self._name}: {e}") diff --git a/docs/README.md b/docs/README.md index 46f18ff..01c3098 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ This custom component allows you to integrate Renpho's weight scale data into Home Assistant. It fetches weight and various other health metrics and displays them as sensors in Home Assistant. ## Table of Contents + - [Prerequisites](#prerequisites) - [File Structure](#file-structure) - [Supported Metrics](#supported-metrics) @@ -19,8 +20,8 @@ This custom component allows you to integrate Renpho's weight scale data into Ho - [API Documentation](#api-documentation) - [API endpoints](#api-endpoints) - ## Prerequisites + 1. You must have a Renpho account. If you don't have one, you can create one [here](https://renpho.com/). 2. You must have a Renpho scale. If you don't have one, you can purchase one [here](https://renpho.com/collections/body-fat-scale). 3. You must have the Renpho app installed on your mobile device. You can download it [here](https://play.google.com/store/apps/details?id=com.renpho.smart&hl=en_US&gl=US) for Android and [here](https://apps.apple.com/us/app/renpho/id1115563582) for iOS. @@ -57,112 +58,156 @@ The following shows the organization of the project's files and directories: ### General Information -| Metric | Description | Data Type | Unit of Measurement | -|--------------|---------------------------------------------|-----------|---------------------| -| id | Unique identifier for the record | Numeric | N/A | -| b_user_id | Unique identifier for the user | Numeric | N/A | -| time_stamp | Unix timestamp for the record | Numeric | UNIX Time | -| created_at | Time the data was created | DateTime | N/A | -| created_stamp| Unix timestamp for when the data was created| Numeric | UNIX Time | +| Metric | Description | Data Type | Unit of Measurement | +| ------------- | -------------------------------------------- | --------- | ------------------- | +| id | Unique identifier for the record | Numeric | N/A | +| b_user_id | Unique identifier for the user | Numeric | N/A | +| time_stamp | Unix timestamp for the record | Numeric | UNIX Time | +| created_at | Time the data was created | DateTime | N/A | +| created_stamp | Unix timestamp for when the data was created | Numeric | UNIX Time | ### Device Information -| Metric | Description | Data Type | Unit of Measurement | -|----------------|-----------------------------|-----------|---------------------| -| scale_type | Type of scale used | Numeric | N/A | -| scale_name | Name of the scale | String | N/A | -| mac | MAC address of the device | String | N/A | -| internal_model | Internal model code | String | N/A | -| time_zone | Time zone information | String | N/A | +| Metric | Description | Data Type | Unit of Measurement | +| -------------- | ------------------------- | --------- | ------------------- | +| scale_type | Type of scale used | Numeric | N/A | +| scale_name | Name of the scale | String | N/A | +| mac | MAC address of the device | String | N/A | +| internal_model | Internal model code | String | N/A | +| time_zone | Time zone information | String | N/A | ### User Profile -| Metric | Description | Data Type | Unit of Measurement | -|--------------|------------------------------|-----------|---------------------| -| gender | Gender of the user | Numeric | N/A | -| height | Height of the user | Numeric | cm | -| height_unit | Unit for height | Numeric | N/A | -| birthday | Birth date of the user | Date | N/A | +| Metric | Description | Data Type | Unit of Measurement | +| ----------- | ---------------------- | --------- | ------------------- | +| gender | Gender of the user | Numeric | N/A | +| height | Height of the user | Numeric | cm | +| height_unit | Unit for height | Numeric | N/A | +| birthday | Birth date of the user | Date | N/A | ### Physical Metrics -| Metric | Description | Data Type | Unit of Measurement | -|------------|------------------------------|-----------|---------------------| -| weight | Body weight | Numeric | kg | -| bmi | Body Mass Index | Numeric | N/A | -| muscle | Muscle mass | Numeric | % | -| bone | Bone mass | Numeric | % | -| waistline | Waistline size | Numeric | cm | -| hip | Hip size | Numeric | cm | -| stature | Stature information | Numeric | cm | +| Metric | Description | Data Type | Unit of Measurement | +| --------- | ------------------- | --------- | ------------------- | +| weight | Body weight | Numeric | kg | +| bmi | Body Mass Index | Numeric | N/A | +| muscle | Muscle mass | Numeric | % | +| bone | Bone mass | Numeric | % | +| waistline | Waistline size | Numeric | cm | +| hip | Hip size | Numeric | cm | +| stature | Stature information | Numeric | cm | ### Body Composition -| Metric | Description | Data Type | Unit of Measurement | -|----------|------------------------------|-----------|---------------------| -| bodyfat | Body fat percentage | Numeric | % | -| water | Water content in the body | Numeric | % | -| subfat | Subcutaneous fat | Numeric | % | -| visfat | Visceral fat level | Numeric | Level | +| Metric | Description | Data Type | Unit of Measurement | +| ------- | ------------------------- | --------- | ------------------- | +| bodyfat | Body fat percentage | Numeric | % | +| water | Water content in the body | Numeric | % | +| subfat | Subcutaneous fat | Numeric | % | +| visfat | Visceral fat level | Numeric | Level | ### Metabolic Metrics -| Metric | Description | Data Type | Unit of Measurement | -|----------|------------------------------|-----------|---------------------| -| bmr | Basal Metabolic Rate | Numeric | kcal/day | -| protein | Protein content in the body | Numeric | % | +| Metric | Description | Data Type | Unit of Measurement | +| ------- | --------------------------- | --------- | ------------------- | +| bmr | Basal Metabolic Rate | Numeric | kcal/day | +| protein | Protein content in the body | Numeric | % | ### Age Metrics -| Metric | Description | Data Type | Unit of Measurement | -|----------|------------------------------|-----------|---------------------| -| bodyage | Estimated biological age | Numeric | Years | +| Metric | Description | Data Type | Unit of Measurement | +| ------- | ------------------------ | --------- | ------------------- | +| bodyage | Estimated biological age | Numeric | Years | Certainly, you can expand the existing table to include the "Unit of Measurement" column for each metric. Here's how you can continue to organize the metrics into categories, similar to your previous table, but now with the added units: ### Electrical Measurements (not sure if this is the correct name) -| Metric | Description | Data Type | Unit of Measurement | -|------------------------|-------------------------------------|-----------|---------------------| -| resistance | Electrical resistance | Numeric | Ohms | -| sec_resistance | Secondary electrical resistance | Numeric | Ohms | -| actual_resistance | Actual electrical resistance | Numeric | Ohms | -| actual_sec_resistance | Actual secondary electrical resistance| Numeric | Ohms | -| resistance20_left_arm | Resistance20 in the left arm | Numeric | Ohms | -| resistance20_left_leg | Resistance20 in the left leg | Numeric | Ohms | -| resistance20_right_leg | Resistance20 in the right leg | Numeric | Ohms | -| resistance20_right_arm | Resistance20 in the right arm | Numeric | Ohms | -| resistance20_trunk | Resistance20 in the trunk | Numeric | Ohms | -| resistance100_left_arm | Resistance100 in the left arm | Numeric | Ohms | -| resistance100_left_leg | Resistance100 in the left leg | Numeric | Ohms | -| resistance100_right_arm| Resistance100 in the right arm | Numeric | Ohms | -| resistance100_right_leg| Resistance100 in the right leg | Numeric | Ohms | -| resistance100_trunk | Resistance100 in the trunk | Numeric | Ohms | +| Metric | Description | Data Type | Unit of Measurement | +| ----------------------- | -------------------------------------- | --------- | ------------------- | +| resistance | Electrical resistance | Numeric | Ohms | +| sec_resistance | Secondary electrical resistance | Numeric | Ohms | +| actual_resistance | Actual electrical resistance | Numeric | Ohms | +| actual_sec_resistance | Actual secondary electrical resistance | Numeric | Ohms | +| resistance20_left_arm | Resistance20 in the left arm | Numeric | Ohms | +| resistance20_left_leg | Resistance20 in the left leg | Numeric | Ohms | +| resistance20_right_leg | Resistance20 in the right leg | Numeric | Ohms | +| resistance20_right_arm | Resistance20 in the right arm | Numeric | Ohms | +| resistance20_trunk | Resistance20 in the trunk | Numeric | Ohms | +| resistance100_left_arm | Resistance100 in the left arm | Numeric | Ohms | +| resistance100_left_leg | Resistance100 in the left leg | Numeric | Ohms | +| resistance100_right_arm | Resistance100 in the right arm | Numeric | Ohms | +| resistance100_right_leg | Resistance100 in the right leg | Numeric | Ohms | +| resistance100_trunk | Resistance100 in the trunk | Numeric | Ohms | ### Cardiovascular Metrics -| Metric | Description | Data Type | Unit of Measurement | -|-----------------|-------------------|-----------|---------------------| -| heart_rate | Heart rate | Numeric | bpm | -| cardiac_index | Cardiac index | Numeric | N/A | +| Metric | Description | Data Type | Unit of Measurement | +| ------------- | ------------- | --------- | ------------------- | +| heart_rate | Heart rate | Numeric | bpm | +| cardiac_index | Cardiac index | Numeric | N/A | ### Other Metrics -| Metric | Description | Data Type | Unit of Measurement | -|-----------------|------------------------------------|-----------|---------------------| -| method | Method used for measurement | Numeric | N/A | -| sport_flag | Sports flag | Numeric | N/A | -| left_weight | Weight on the left side of the body| Numeric | kg | -| right_weight | Weight on the right side of the body| Numeric | kg | -| waistline | Waistline size | Numeric | cm | -| hip | Hip size | Numeric | cm | -| local_created_at| Local time the data was created | DateTime | N/A | -| time_zone | Time zone information | String | N/A | -| remark | Additional remarks | String | N/A | -| score | Health score | Numeric | N/A | -| pregnant_flag | Pregnancy flag | Numeric | N/A | -| stature | Stature information | Numeric | cm | -| category | Category identifier | Numeric | N/A | +| Metric | Description | Data Type | Unit of Measurement | +| ---------------- | ------------------------------------ | --------- | ------------------- | +| method | Method used for measurement | Numeric | N/A | +| sport_flag | Sports flag | Numeric | N/A | +| left_weight | Weight on the left side of the body | Numeric | kg | +| right_weight | Weight on the right side of the body | Numeric | kg | +| waistline | Waistline size | Numeric | cm | +| hip | Hip size | Numeric | cm | +| local_created_at | Local time the data was created | DateTime | N/A | +| time_zone | Time zone information | String | N/A | +| remark | Additional remarks | String | N/A | +| score | Health score | Numeric | N/A | +| pregnant_flag | Pregnancy flag | Numeric | N/A | +| stature | Stature information | Numeric | cm | +| category | Category identifier | Numeric | N/A | + +### Girth Measurements + +| Metric | Description | Data Type | Unit of Measurement | Category | Label | +| ----------------- | ----------------- | --------- | ------------------- | ------------ | ------------------ | +| neck_value | Neck Value | Numeric | cm | Measurements | Girth Measurements | +| shoulder_value | Shoulder Value | Numeric | cm | Measurements | Girth Measurements | +| arm_value | Arm Value | Numeric | cm | Measurements | Girth Measurements | +| chest_value | Chest Value | Numeric | cm | Measurements | Girth Measurements | +| waist_value | Waist Value | Numeric | cm | Measurements | Girth Measurements | +| hip_value | Hip Value | Numeric | cm | Measurements | Girth Measurements | +| thigh_value | Thigh Value | Numeric | cm | Measurements | Girth Measurements | +| calf_value | Calf Value | Numeric | cm | Measurements | Girth Measurements | +| left_arm_value | Left Arm Value | Numeric | cm | Measurements | Girth Measurements | +| left_thigh_value | Left Thigh Value | Numeric | cm | Measurements | Girth Measurements | +| left_calf_value | Left Calf Value | Numeric | cm | Measurements | Girth Measurements | +| right_arm_value | Right Arm Value | Numeric | cm | Measurements | Girth Measurements | +| right_thigh_value | Right Thigh Value | Numeric | cm | Measurements | Girth Measurements | +| right_calf_value | Right Calf Value | Numeric | cm | Measurements | Girth Measurements | +| whr_value | WHR Value | Numeric | ratio | Measurements | Girth Measurements | +| abdomen_value | Abdomen Value | Numeric | cm | Measurements | Girth Measurements | + +--- + +### Girth Goals + +| Metric | Description | Data Type | Unit of Measurement | Category | Label | +| ---------------------- | ---------------------- | --------- | ------------------- | -------- | ----------- | +| neck_goal_value | Neck Goal Value | Numeric | cm | Goals | Girth Goals | +| shoulder_goal_value | Shoulder Goal Value | Numeric | cm | Goals | Girth Goals | +| arm_goal_value | Arm Goal Value | Numeric | cm | Goals | Girth Goals | +| chest_goal_value | Chest Goal Value | Numeric | cm | Goals | Girth Goals | +| waist_goal_value | Waist Goal Value | Numeric | cm | Goals | Girth Goals | +| hip_goal_value | Hip Goal Value | Numeric | cm | Goals | Girth Goals | +| thigh_goal_value | Thigh Goal Value | Numeric | cm | Goals | Girth Goals | +| calf_goal_value | Calf Goal Value | Numeric | cm | Goals | Girth Goals | +| left_arm_goal_value | Left Arm Goal Value | Numeric | cm | Goals | Girth Goals | +| left_thigh_goal_value | Left Thigh Goal Value | Numeric | cm | Goals | Girth Goals | +| left_calf_goal_value | Left Calf Goal Value | Numeric | cm | Goals | Girth Goals | +| right_arm_goal_value | Right Arm Goal Value | Numeric | cm | Goals | Girth Goals | +| right_thigh_goal_value | Right Thigh Goal Value | Numeric | cm | Goals | Girth Goals | +| right_calf_goal_value | Right Calf Goal Value | Numeric | cm | Goals | Girth Goals | +| whr_goal_value | WHR Goal Value | Numeric | ratio | Goals | Girth Goals | +| abdomen_goal_value | Abdomen Goal Value | Numeric | cm | Goals | Girth Goals | ## Installation @@ -175,10 +220,10 @@ Add the following entry in your `configuration.yaml`: ```yaml renpho: - email: your_email@example.com # email address - password: YourSecurePassword # password - refresh: 600 # time to poll (ms) - user_id: 123456789 # user ID (optional) + email: your_email@example.com # email address + password: YourSecurePassword # password + refresh: 600 # time to poll (ms) + user_id: 123456789 # user ID (optional) ``` Then add the sensor platform: @@ -195,83 +240,103 @@ The `RenphoWeight` class is the core of this integration, providing methods to i ### `auth()` #### Description + Authenticates the user with the Renpho API and fetches a session key. The session key is stored within the class and is used for subsequent API calls. #### Parameters + None #### Returns + - `dict`: Parsed JSON response from the API containing the session key and other authentication details. -### `getScaleUsers()` +### `get_scale_users()` #### Description + Fetches the list of users associated with the Renpho scale. #### Parameters + None #### Returns + - `list`: A list of dictionaries, each containing details of a user (e.g., user ID, name). -### `getMeasurements()` +### `get_measurements()` #### Description + Retrieves the latest weight measurements for the user specified by `user_id`. This method updates the `weight` and `time_stamp` attributes of the class. #### Parameters + None #### Returns + - `list`: A list of dictionaries containing the latest measurements. -### `getSpecificMetric(metric)` +### `get_specific_metric(metric)` #### Description + Retrieves a specific metric from the most recent weight measurement. #### Parameters + - `metric (str)`: The specific metric to retrieve (e.g., 'weight', 'bmi'). #### Returns + - `float`: The value of the specified metric. - `None`: If the metric is not found. -### `getSpecificMetricFromUserID(metric, user_id=None)` +### `get_specific_metric_from_user_ID(metric, user_id=None)` #### Description + Retrieves a specific metric for a particular user ID from the most recent weight measurement. #### Parameters + - `metric (str)`: The metric to fetch (e.g., 'bodyfat', 'water'). - `user_id (str, optional)`: The user ID for whom the metric should be fetched. Defaults to the object's `user_id` if not provided. #### Returns + - `float`: Value of the specified metric. - `None`: If an error occurs or the metric is not found. -### `startPolling(polling_interval=60)` +### `start_polling(polling_interval=60)` #### Description -Starts polling for weight data at a given interval. The polling will automatically call `getMeasurements()` at the specified interval. + +Starts polling for weight data at a given interval. The polling will automatically call `get_measurements()` at the specified interval. #### Parameters + - `polling_interval (int)`: Time in seconds between each polling call. Defaults to 60 seconds. #### Returns + None -### `stopPolling()` +### `stop_polling()` #### Description + Stops the ongoing polling for weight data. #### Parameters + None #### Returns -None +None ## API endpoints @@ -284,21 +349,27 @@ This document describes the API endpoints and methods utilized by the `RenphoWei ### API_AUTH_URL #### Endpoint + `https://renpho.qnclouds.com/api/v3/users/sign_in.json?app_id=Renpho` #### HTTP Method + `POST` #### Parameters + - `app_id`: Application identifier (fixed as "Renpho"). - `email`: User's email address for Renpho account. - `password`: Encrypted password for the Renpho account. #### Returns + JSON payload containing: + - `session_key`: Session key for future API calls. #### Usage in Code + This URL is used in the `auth()` method to authenticate the user and fetch the session key. --- @@ -308,21 +379,27 @@ This URL is used in the `auth()` method to authenticate the user and fetch the s ### API_SCALE_USERS_URL #### Endpoint + `https://renpho.qnclouds.com/api/v3/scale_users/list_scale_user` #### HTTP Method + `GET` #### Parameters + - `locale`: Language/locale setting, usually "en". - `terminal_user_session_key`: Session key obtained from authentication. #### Returns + JSON payload containing: + - `users`: Array of user objects containing user details like user ID, scale user ID, MAC address, and more. #### Usage in Code -This URL is used in the `getScaleUsers()` method to fetch the list of users associated with the scale. + +This URL is used in the `get_scale_users()` method to fetch the list of users associated with the scale. --- @@ -331,22 +408,242 @@ This URL is used in the `getScaleUsers()` method to fetch the list of users asso ### API_MEASUREMENTS_URL #### Endpoint + `https://renpho.qnclouds.com/api/v2/measurements/list.json` #### HTTP Method + `GET` #### Parameters + - `user_id`: (Optional) User ID for fetching weight data. - `last_at`: Unix timestamp for the oldest data to fetch. - `locale`: Language/locale setting, usually "en". - `terminal_user_session_key`: Session key obtained from authentication. #### Returns + JSON payload containing: + - `last_ary`: Array of most recent measurements for the user. #### Usage in Code -This URL is used in the `getMeasurements()` method to fetch the most recent weight measurements for the user. +This URL is used in the `get_measurements()` method to fetch the most recent weight measurements for the user. + +## Device Information + +### DEVICE_INFO_URL + +#### Endpoint + +`https://renpho.qnclouds.com/api/v2/device_binds/get_device.json` + +#### HTTP Method + +`GET` + +#### Parameters + +- `user_id`: User ID for fetching device information. +- `last_updated_at`: Unix timestamp for the oldest data to fetch. +- `locale`: Language/locale setting, usually "en". +- `terminal_user_session_key`: Session key obtained from authentication. + +#### Returns + +JSON payload containing: + +- `device_info`: Object containing details like device type, device ID, and more. + +#### Usage in Code + +This URL is used in the `get_device_info()` method to fetch information about the bound device for the user. + +#### Data Example + +```json +{ + "device_info": { + "device_type": "Scale", + "device_id": "abc123", + "mac_address": "12:34:56:78:90" + } +} +``` + +--- + +## Latest Model Information + +### LATEST_MODEL_URL + +#### Endpoint + +`https://renpho.qnclouds.com/api/v3/devices/list_lastest_model.json` + +#### HTTP Method + +`GET` + +#### Parameters + +- `user_id`: User ID for fetching latest model information. +- `last_updated_at`: Unix timestamp for the oldest data to fetch. +- `locale`: Language/locale setting, usually "en". +- `terminal_user_session_key`: Session key obtained from authentication. + +#### Returns +JSON payload containing: + +- `latest_models`: Array of objects containing latest model details. + +#### Usage in Code + +This URL is used in the `list_latest_model()` method to fetch the latest model information. + +#### Data Example + +```json +{ + "latest_models": [ + { + "model_name": "Renpho Smart Scale", + "model_id": "xyz789" + } + ] +} +``` + +--- + +## Girth Information + +### GIRTH_URL + +#### Endpoint + +`https://renpho.qnclouds.com/api/v3/girths/list_girth.json` + +#### HTTP Method + +`GET` + +#### Parameters + +- `user_id`: User ID for fetching girth information. +- `last_updated_at`: Unix timestamp for the oldest data to fetch. +- `locale`: Language/locale setting, usually "en". +- `terminal_user_session_key`: Session key obtained from authentication. + +#### Returns + +JSON payload containing: + +- `girths`: Array of objects containing girth measurements for the user. + +#### Usage in Code + +This URL is used in the `list_girth()` method to fetch the girth measurements for the user. + +#### Data Example + +```json +{ + "girths": [ + { + "waist_girth": 30, + "arm_girth": 12 + } + ] +} +``` + +## Girth Goal Information + +### GIRTH_GOAL_URL + +#### Endpoint + +`https://renpho.qnclouds.com/api/v3/girth_goals/list_girth_goal.json` + +#### HTTP Method + +`GET` + +#### Parameters + +- `user_id`: User ID for fetching girth goal information. +- `last_updated_at`: Unix timestamp for the oldest data to fetch. +- `locale`: Language/locale setting, usually "en". +- `terminal_user_session_key`: Session key obtained from authentication. + +#### Returns + +JSON payload containing: + +- `girth_goals`: Array of objects containing girth goal measurements for the user. + +#### Usage in Code + +This URL is used in the `list_girth_goal()` method to fetch the girth goal measurements for the user. + +#### Data Example + +```json +{ + "girth_goals": [ + { + "waist_girth_goal": 28, + "arm_girth_goal": 11 + } + ] +} +``` + +--- + +## Growth Record Information + +### GROWTH_RECORD_URL + +#### Endpoint + +`https://renpho.qnclouds.com/api/v3/growth_records/list_growth_record.json` + +#### HTTP Method + +`GET` + +#### Parameters + +- `user_id`: User ID for fetching growth records. +- `last_updated_at`: Unix timestamp for the oldest data to fetch. +- `locale`: Language/locale setting, usually "en". +- `terminal_user_session_key`: Session key obtained from authentication. + +#### Returns + +JSON payload containing: + +- `growth_records`: Array of objects containing growth measurements for the user. + +#### Usage in Code + +This URL is used in the `list_growth_record()` method to fetch the growth records for the user. + +#### Data Example + +```json +{ + "growth_records": [ + { + "height": 175, + "weight": 70, + "growth_rate": 1.5 + } + ] +} +``` diff --git a/docs/hacs/dev.md b/docs/hacs/dev.md index a364257..2d5cae6 100644 --- a/docs/hacs/dev.md +++ b/docs/hacs/dev.md @@ -16,6 +16,7 @@ Make sure your GitHub repository meets the following requirements: "country": ["us"] } ``` + - The repository must contain a `README.md` file. - You must tag a release. The tag should be a version number and the release should include a changelog. @@ -40,4 +41,4 @@ Your Pull Request will be reviewed, and if it meets the criteria, it will be mer #### 4. Update README -Update your README.md file to include instructions on how to install the component via HACS. You can include a HACS badge to show that it's available through HACS. \ No newline at end of file +Update your README.md file to include instructions on how to install the component via HACS. You can include a HACS badge to show that it's available through HACS. diff --git a/example/configuration.yaml b/example/configuration.yaml index 0cb80f5..daca25f 100644 --- a/example/configuration.yaml +++ b/example/configuration.yaml @@ -1,8 +1,8 @@ renpho: - email: # email address - password: # password + email: # email address + password: # password refresh: 600 - user_id: # user id + user_id: # user id sensor: - - platform: renpho \ No newline at end of file + - platform: renpho diff --git a/example/googletheme.yaml b/example/googletheme.yaml index 36cb9b7..1b3a74b 100644 --- a/example/googletheme.yaml +++ b/example/googletheme.yaml @@ -53,4 +53,4 @@ views: - entity: sensor.renpho_cardiac_index - entity: sensor.renpho_sport_flag state_color: true - theme: Google Theme \ No newline at end of file + theme: Google Theme diff --git a/hacs.json b/hacs.json index 803da4f..c89ea21 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { - "name": "Renpho Weight Scale Integration", - "codeowners": ["antoinebou12"], - "country": ["ca"] -} \ No newline at end of file + "name": "Renpho Weight Scale Integration", + "codeowners": ["antoinebou12"], + "country": ["ca"] +} diff --git a/info.md b/info.md index e4cedee..799d3db 100644 --- a/info.md +++ b/info.md @@ -60,4 +60,4 @@ For issues, feature requests or further assistance, head over to our [GitHub Rep Inspired by other health metric integrations and the Home Assistant community. -For more details, please refer to the [Documentation](https://github.com/antoinebou12/hass_renpho). \ No newline at end of file +For more details, please refer to the [Documentation](https://github.com/antoinebou12/hass_renpho). diff --git a/renovate.json b/renovate.json index f6aec7e..06a6dd1 100644 --- a/renovate.json +++ b/renovate.json @@ -1,33 +1,17 @@ { - "extends": [ - "config:base" - ], - "schedule": [ - "at any time" - ], - "baseBranches": [ - "master" - ], - "packageRules": [ - { - "depTypeList": [ - "dependencies" - ], - "updateTypes": [ - "minor", - "patch" - ], - "automerge": true - }, - { - "depTypeList": [ - "devDependencies" - ], - "updateTypes": [ - "minor", - "patch" - ], - "automerge": true - } - ] -} \ No newline at end of file + "extends": ["config:base"], + "schedule": ["at any time"], + "baseBranches": ["master"], + "packageRules": [ + { + "depTypeList": ["dependencies"], + "updateTypes": ["minor", "patch"], + "automerge": true + }, + { + "depTypeList": ["devDependencies"], + "updateTypes": ["minor", "patch"], + "automerge": true + } + ] +} diff --git a/requirements.txt b/requirements.txt index 8d29d4b..450036e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ pycryptodome>=3.3.1 requests>=2.26.0 -aiohttp>=3.6.1 \ No newline at end of file +aiohttp>=3.6.1 diff --git a/requirements_dev.txt b/requirements_dev.txt index 5b310e1..3eeeec6 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -3,4 +3,5 @@ homeassistant black flake8 pre-commit -pylint \ No newline at end of file +pylint +python-dotenv diff --git a/requirements_test.txt b/requirements_test.txt index 49cb893..9bfe93c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1 +1,4 @@ -pytest-homeassistant-custom-component \ No newline at end of file +pytest-homeassistant-custom-component +pytest +pytest-asyncio +python-dotenv diff --git a/tests/tests.py b/tests/tests.py deleted file mode 100644 index 1e05aca..0000000 --- a/tests/tests.py +++ /dev/null @@ -1,254 +0,0 @@ -import unittest -from unittest.mock import Mock, patch -from Crypto.PublicKey import RSA -from Crypto.Cipher import PKCS1_v1_5 -from base64 import b64decode, b64encode - -import requests -from ..custom_components.renpho.sensor import RenphoSensor, WeightSensor, TimeSensor, setup_platform -from ..custom_components.renpho.RenphoWeight import RenphoWeight -from .. import setup -from ..custom_components.renpho.const import CONF_EMAIL, CONF_PASSWORD, CONF_REFRESH, DOMAIN - - -class TestEncryption(unittest.TestCase): - """Test cases for the encryption logic""" - - def setUp(self): - """Initial setup for test cases.""" - # Public key for testing encryption - self.public_key = '''-----BEGIN PUBLIC KEY----- - MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC+25I2upukpfQ7rIaaTZtVE744 - u2zV+HaagrUhDOTq8fMVf9yFQvEZh2/HKxFudUxP0dXUa8F6X4XmWumHdQnum3zm - Jr04fz2b2WCcN0ta/rbF2nYAnMVAk2OJVZAMudOiMWhcxV1nNJiKgTNNr13de0EQ - IiOL2CUBzu+HmIfUbQIDAQAB - -----END PUBLIC KEY-----''' - self.key = RSA.importKey(self.public_key) - self.cipher = PKCS1_v1_5.new(self.key) - - def test_encryption(self): - """Test the encryption logic.""" - test_password = "my_password" - encrypted_password = b64encode( - self.cipher.encrypt(bytes(test_password, 'utf-8'))) - - # For now, just check if encryption doesn't return the original password - self.assertNotEqual(test_password, encrypted_password.decode('utf-8')) - -class TestRenphoWeight(unittest.TestCase): - """Test cases for the RenphoWeight class""" - - def setUp(self): - """Initial setup for test cases.""" - self.mock_response = Mock() - self.renpho = RenphoWeight('public_key', 'test@email.com', 'password') - - @patch('requests.post') - def test_auth(self, mock_post): - """Test the authentication logic.""" - self.mock_response.json.return_value = { - 'terminal_user_session_key': 'session_key'} - mock_post.return_value = self.mock_response - - result = self.renpho.auth() - - self.assertEqual(result, {'terminal_user_session_key': 'session_key'}) - self.assertEqual(self.renpho.session_key, 'session_key') - - @patch('requests.get') - def test_getScaleUsers(self, mock_get): - """Test fetching the scale users.""" - self.mock_response.json.return_value = { - 'scale_users': [{'user_id': '1'}]} - mock_get.return_value = self.mock_response - - result = self.renpho.getScaleUsers() - - self.assertEqual(result, [{'user_id': '1'}]) - self.assertEqual(self.renpho.user_id, '1') - - @patch('requests.get') - def test_getMeasurements(self, mock_get): - """Test fetching the measurements.""" - self.mock_response.json.return_value = { - 'last_ary': [{'weight': 70, 'time_stamp': 1630886400}]} - mock_get.return_value = self.mock_response - - result = self.renpho.getMeasurements() - - self.assertEqual(result, [{'weight': 70, 'time_stamp': 1630886400}]) - self.assertEqual(self.renpho.weight, 70) - self.assertEqual(self.renpho.time_stamp, 1630886400) - - @patch('requests.post') - @patch('requests.get') - def test_getInfo(self, mock_get, mock_post): - """Test the wrapper method getInfo.""" - self.mock_response.json.side_effect = [ - {'terminal_user_session_key': 'session_key'}, - {'scale_users': [{'user_id': '1'}]}, - {'last_ary': [{'weight': 70, 'time_stamp': 1630886400}]} - ] - mock_get.return_value = self.mock_response - mock_post.return_value = self.mock_response - - self.renpho.getInfo() - - self.assertEqual(self.renpho.session_key, 'session_key') - self.assertEqual(self.renpho.user_id, '1') - self.assertEqual(self.renpho.weight, 70) - self.assertEqual(self.renpho.time_stamp, 1630886400) - - @patch('requests.post') - def test_failed_auth(self, mock_post): - """Test failed authentication logic.""" - self.mock_response.json.return_value = {'error': 'Invalid credentials'} - mock_post.return_value = self.mock_response - - result = self.renpho.auth() - - self.assertIsNone(result) - self.assertIsNone(self.renpho.session_key) - - @patch('requests.get') - def test_no_scale_users(self, mock_get): - """Test no scale users are returned.""" - self.mock_response.json.return_value = {'scale_users': []} - mock_get.return_value = self.mock_response - - result = self.renpho.getScaleUsers() - - self.assertEqual(result, []) - self.assertIsNone(self.renpho.user_id) - - @patch('requests.get') - def test_no_measurements(self, mock_get): - """Test no measurements are returned.""" - self.mock_response.json.return_value = {'last_ary': []} - mock_get.return_value = self.mock_response - - result = self.renpho.getMeasurements() - - self.assertEqual(result, []) - self.assertIsNone(self.renpho.weight) - self.assertIsNone(self.renpho.time_stamp) - - @patch('requests.get') - def test_get_specific_metric_not_found(self, mock_get): - """Test fetching a specific metric that does not exist.""" - mock_response = Mock() - mock_response.json.return_value = {'last_ary': [{'weight': 70, 'time_stamp': 1630886400}]} - mock_get.return_value = mock_response - - metric_value = self.renpho.getSpecificMetric("unknown_metric") - - self.assertIsNone(metric_value) - - def test_get_specific_metric_no_measurements(self): - """Test fetching a specific metric with no prior measurements.""" - self.renpho.time_stamp = None # Simulating no prior measurements - - metric_value = self.renpho.getSpecificMetric("bodyfat") - - self.assertIsNone(metric_value) - - @patch('requests.get') - def test_get_info_fail(self, mock_get): - """Test getInfo method failure.""" - mock_response = Mock() - mock_response.raise_for_status.side_effect = requests.HTTPError("Info fetch failed") - mock_get.return_value = mock_response - - with self.assertRaises(Exception): - self.renpho.getInfo() - -class TestRenphoSensor(unittest.TestCase): - - def setUp(self): - self.renpho = Mock() - self.renpho.getSpecificMetric.return_value = 75 # kg - self.sensor = RenphoSensor(self.renpho, "weight", "Weight", "kg") - - def test_name(self): - """Test name property.""" - self.assertEqual(self.sensor.name, "Renpho Weight") - - def test_state(self): - """Test state property.""" - self.sensor.update() - self.assertEqual(self.sensor.state, 75) - - def test_unit_of_measurement(self): - """Test unit_of_measurement property.""" - self.assertEqual(self.sensor.unit_of_measurement, "kg") - - def test_category(self): - """Test category property.""" - self.assertEqual(self.sensor.category, "Renpho") - - def test_label(self): - """Test label property.""" - self.assertEqual(self.sensor.label, "Data") - - def test_update_fail(self): - """Test update method failure.""" - self.renpho.getSpecificMetric.side_effect = Exception("API Error") - with self.assertRaises(Exception): - self.sensor.update() - -class TestSetupPlatform(unittest.TestCase): - - def setUp(self): - self.hass = Mock() - self.config = {} - self.add_entities = Mock() - - @patch('sensor.RenphoSensor') - def test_setup_platform(self, MockRenphoSensor): - renpho = Mock() - self.hass.data = {'renpho': renpho} - - setup_platform(self.hass, self.config, self.add_entities) - - self.add_entities.assert_called_once() # Check if entities were added - - - -class TestSetup(unittest.TestCase): - """Test cases for the setup function of the Renpho component.""" - - def setUp(self): - """Initial setup for test cases.""" - self.hass = Mock() # Mocked Home Assistant core object - self.config = { - DOMAIN: { - CONF_EMAIL: 'test@email.com', # Mock email - CONF_PASSWORD: 'password', # Mock password - CONF_REFRESH: 60 # Mock refresh rate - } - } - self.renpho = Mock() # Mocked RenphoWeight object - - # Replace with the actual import path - @patch('your_init_module.RenphoWeight') - def test_setup(self, MockRenphoWeight): - """Test the setup function.""" - # Mock the return value of RenphoWeight instantiation - MockRenphoWeight.return_value = self.renpho - - # Call the setup function with the mocked Home Assistant and configuration - result = setup(self.hass, self.config) - - # Check if setup was successful - self.assertTrue(result) - - # Check if EVENT_HOMEASSISTANT_START event is registered - self.hass.bus.listen_once.assert_called_with( - EVENT_HOMEASSISTANT_START, any) # Replace `any` with the actual function - - # Check if RenphoWeight object is stored in Home Assistant's data dictionary - self.assertEqual(self.hass.data[DOMAIN], self.renpho) - - -if __name__ == "__main__": - unittest.main() From b7919713cd67144230dc407a280cd095f09b2895 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Mon, 11 Sep 2023 19:43:49 -0400 Subject: [PATCH 51/56] Create sonar-project.properties --- sonar-project.properties | 1 + 1 file changed, 1 insertion(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..5b0f2a1 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1 @@ +sonar.projectKey=antoinebou12_hass_renpho_AYqF9xNY8BAyZJivWQK6 From b99458e74c1905ad5a070e5814d8ad404630137a Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Mon, 11 Sep 2023 19:44:43 -0400 Subject: [PATCH 52/56] Create sonar.yml --- .github/workflows/sonar.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/sonar.yml diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 0000000..82d9322 --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,22 @@ +name: Build + +on: + push: + branches: + - master + - dev + + +jobs: + build: + name: Build + runs-on: ubuntu-latest + permissions: read-all + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - uses: sonarsource/sonarqube-scan-action@master + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} From 74cfdad5a00015daaf7e58fd136a67eb6aa21d82 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Tue, 12 Sep 2023 00:28:20 +0000 Subject: [PATCH 53/56] changes --- custom_components/renpho/__init__.py | 14 +- .../renpho/{renpho.py => api_renpho.py} | 180 ++++-------------- custom_components/renpho/config_flow.py | 5 +- custom_components/renpho/const.py | 5 +- custom_components/renpho/renpho.png | Bin 0 -> 14773 bytes custom_components/renpho/sensor.py | 44 +++-- 6 files changed, 81 insertions(+), 167 deletions(-) rename custom_components/renpho/{renpho.py => api_renpho.py} (75%) create mode 100644 custom_components/renpho/renpho.png diff --git a/custom_components/renpho/__init__.py b/custom_components/renpho/__init__.py index 0982ac9..b15379d 100755 --- a/custom_components/renpho/__init__.py +++ b/custom_components/renpho/__init__.py @@ -10,10 +10,7 @@ DOMAIN, EVENT_HOMEASSISTANT_STOP, ) -from .renpho import RenphoWeight - -# from homeassistant.helpers import service -# from homeassistant.core import callback +from .api_renpho import RenphoWeight # Initialize logger @@ -26,8 +23,7 @@ async def async_setup(hass, config): """Set up the Renpho component from YAML configuration.""" _LOGGER.debug("Starting hass_renpho") - conf = config.get(DOMAIN) - if conf: + if conf := config.get(DOMAIN): await setup_renpho(hass, conf) return True @@ -103,10 +99,10 @@ async def main(): renpho.get_info_sync() users = await renpho.get_scale_users() print("Fetched scale users:", users) - metric = await renpho.get_specific_metric_from_user_ID("bodyfat") + metric = await renpho.get_specific_metric_from_user_ID("weight", "bodyfat") print("Fetched specific metric:", metric) metric_for_user = await renpho.get_specific_metric_from_user_ID( - "bodyfat", "" + "weight", "bodyfat", "" ) print("Fetched specific metric for user:", metric_for_user) get_device_info = await renpho.get_device_info() @@ -118,6 +114,8 @@ async def main(): list_girth_goal = await renpho.list_girth_goal() print("Fetched list girth goal:", list_girth_goal) message_list = await renpho.message_list() + list_growth_record = await renpho.list_growth_record() + print("Fetched list growth record:", list_growth_record) print("Fetched message list:", message_list) info = await renpho.get_info() print("Fetched info:", info) diff --git a/custom_components/renpho/renpho.py b/custom_components/renpho/api_renpho.py similarity index 75% rename from custom_components/renpho/renpho.py rename to custom_components/renpho/api_renpho.py index 93dc5a6..4efe651 100755 --- a/custom_components/renpho/renpho.py +++ b/custom_components/renpho/api_renpho.py @@ -12,6 +12,8 @@ from Crypto.Cipher import PKCS1_v1_5 from Crypto.PublicKey import RSA +from .const import METRIC_TYPE_GROWTH, METRIC_TYPE_GROWTH_GOAL, METRIC_TYPE_WEIGHT + # Initialize logging _LOGGER = logging.getLogger(__name__) @@ -26,6 +28,10 @@ GROWTH_RECORD_URL = ( "https://renpho.qnclouds.com/api/v3/growth_records/list_growth_record.json" ) +GROWTH_GOAL_URL = ( + "https://renpho.qnclouds.com/api/v3/growth_goals/list_growth_goal.json" +) +MESSAGE_LIST_URL = "https://renpho.qnclouds.com/api/v2/messages/list.json" class RenphoWeight: @@ -57,6 +63,18 @@ def __init__(self, public_key, email, password, user_id=None, refresh=None): self.refresh = 60 self.session = aiohttp.ClientSession() + def set_user_id(self, user_id): + """ + Set the user ID for whom the weight data should be fetched. + """ + self.user_id = user_id + + def get_user_id(self): + """ + Get the current user ID for whom the weight data is being fetched. + """ + return self.user_id + @staticmethod def get_week_ago_timestamp() -> int: week_ago = datetime.date.today() - datetime.timedelta(days=7) @@ -229,110 +247,8 @@ async def get_measurements(self) -> Optional[List[Dict]]: _LOGGER.error(f"An unexpected error occurred: {e}") return None - def get_specific_metric_sync( - self, metric_type: str, metric: str, user_id: Optional[str] = None - ) -> Optional[float]: - """ - Synchronous version of get_specific_metric based on the type specified (weight, growth goal, or growth metric). - - Parameters: - metric_type (str): The type of metric to fetch ('weight', 'growth_goal', 'growth'). - metric (str): The specific metric to fetch (e.g., "height", "growth_rate", "weight"). - user_id (str, optional): The user ID for whom to fetch the metric. Defaults to None. - - Returns: - float, None: The fetched metric value, or None if it couldn't be fetched. - """ - try: - if user_id: - self.set_user_id(user_id) # Update the user_id if provided - - if metric_type == "weight": - last_measurement = self.get_measurements_sync() - return ( - last_measurement[0].get(metric, None) if last_measurement else None - ) - - elif metric_type == "growth_goal": - growth_goal_info = self.list_growth_goal_sync() - last_goal = ( - growth_goal_info.get("growth_goals", [])[0] - if growth_goal_info.get("growth_goals") - else None - ) - return last_goal.get(metric, None) if last_goal else None - - elif metric_type == "growth": - growth_info = self.list_growth_sync() - last_measurement = ( - growth_info.get("growths", [])[0] - if growth_info.get("growths") - else None - ) - return last_measurement.get(metric, None) if last_measurement else None - - else: - _LOGGER.error( - "Invalid metric_type. Must be 'weight', 'growth_goal', or 'growth'." - ) - return None - - except Exception as e: - _LOGGER.error(f"An error occurred: {e}") - return None - async def get_specific_metric( self, metric_type: str, metric: str, user_id: Optional[str] = None - ) -> Optional[float]: - """ - Fetch a specific metric based on the type specified (weight, growth goal, or growth metric). - - Parameters: - metric_type (str): The type of metric to fetch ('weight', 'growth_goal', 'growth'). - metric (str): The specific metric to fetch (e.g., "height", "growth_rate", "weight"). - user_id (str, optional): The user ID for whom to fetch the metric. Defaults to None. - - Returns: - float, None: The fetched metric value, or None if it couldn't be fetched. - """ - try: - if user_id: - self.set_user_id(user_id) # Update the user_id if provided - - if metric_type == "weight": - last_measurement = await self.get_measurements() - return ( - last_measurement[0].get(metric, None) if last_measurement else None - ) - - elif metric_type == "growth_goal": - growth_goal_info = await self.list_growth_goal() - last_goal = ( - growth_goal_info.get("growth_goals", [])[0] - if growth_goal_info.get("growth_goals") - else None - ) - return last_goal.get(metric, None) if last_goal else None - elif metric_type == "growth": - growth_info = await self.list_growth() - last_measurement = ( - growth_info.get("growths", [])[0] - if growth_info.get("growths") - else None - ) - return last_measurement.get(metric, None) if last_measurement else None - else: - print( - "Invalid metric_type. Must be 'weight', 'growth_goal', or 'growth'." - ) - return None - - except Exception as e: - print(f"An error occurred: {e}") - return None - - async def get_specific_metric_from_user_ID( - self, metric_type: str, metric: str, user_id: Optional[str] = None ) -> Optional[float]: """ Fetch a specific metric for a particular user ID based on the type specified (weight, growth goal, or growth metric). @@ -347,40 +263,28 @@ async def get_specific_metric_from_user_ID( """ try: if user_id: - self.set_user_id(user_id) # Update the user_id if provided + self.set_user_id(user_id) - if metric_type == "weight": + if metric_type == METRIC_TYPE_WEIGHT: last_measurement = await self.get_measurements() - return ( - last_measurement[0].get(metric, None) if last_measurement else None - ) + return last_measurement[0].get(metric, None) if last_measurement else None - elif metric_type == "growth_goal": + elif metric_type == METRIC_TYPE_GROWTH_GOAL: growth_goal_info = await self.list_growth_goal() - last_goal = ( - growth_goal_info.get("growth_goals", [])[0] - if growth_goal_info.get("growth_goals") - else None - ) + last_goal = growth_goal_info.get("growth_goals", [])[0] if growth_goal_info.get("growth_goals") else None return last_goal.get(metric, None) if last_goal else None - elif metric_type == "growth": + elif metric_type == METRIC_TYPE_GROWTH: growth_info = await self.list_growth() - last_measurement = ( - growth_info.get("growths", [])[0] - if growth_info.get("growths") - else None - ) + last_measurement = growth_info.get("growths", [])[0] if growth_info.get("growths") else None return last_measurement.get(metric, None) if last_measurement else None else: - print( - "Invalid metric_type. Must be 'weight', 'growth_goal', or 'growth'." - ) + _LOGGER.error(f"Invalid metric_type: {metric_type}. Must be one of {METRIC_TYPE_WEIGHT}, {METRIC_TYPE_GROWTH_GOAL}, or {METRIC_TYPE_GROWTH}.") return None except Exception as e: - print(f"An error occurred: {e}") + _LOGGER.error(f"An error occurred: {e}") return None def get_info_sync(self): @@ -417,18 +321,6 @@ def stop_polling(self): if hasattr(self, "polling"): self.polling.cancel() - def set_user_id(self, user_id): - """ - Set the user ID for whom the weight data should be fetched. - """ - self.user_id = user_id - - def get_user_id(self): - """ - Get the current user ID for whom the weight data is being fetched. - """ - return self.user_id - async def get_device_info(self): """ Asynchronously get device information. @@ -472,6 +364,17 @@ async def list_girth_goal(self): url = f"{GIRTH_GOAL_URL}?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" return await self._request("GET", url) + async def list_growth_goal(self): + """ + Asynchronously list girth goal information. + + Returns: + dict: The API response as a dictionary. + """ + week_ago_timestamp = self.get_week_ago_timestamp() + url = f"{GIRTH_GOAL_URL}?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + return await self._request("GET", url) + async def get_specific_growth_goal_metric( self, metric: str, user_id: Optional[str] = None ) -> Optional[float]: @@ -506,7 +409,7 @@ async def list_growth_record(self): dict: The API response as a dictionary. """ week_ago_timestamp = self.get_week_ago_timestamp() - url = f"https://renpho.qnclouds.com/api/v3/growth_records/list_growth_record.json?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + url = f"{GROWTH_RECORD_URL}?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" return await self._request("GET", url) async def get_specific_growth_metric( @@ -525,7 +428,7 @@ async def get_specific_growth_metric( try: if user_id: self.set_user_id(user_id) # Update the user_id if provided - growth_info = await self.list_growth() + growth_info = await self.list_growth_record() last_measurement = ( growth_info.get("growths", [])[0] if growth_info.get("growths") @@ -538,7 +441,7 @@ async def get_specific_growth_metric( async def message_list(self): week_ago_timestamp = self.get_week_ago_timestamp() - url = f"https://renpho.qnclouds.com/api/v2/messages/list.json?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" + url = f"{MESSAGE_LIST_URL}?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" return await self._request("GET", url) async def close(self): @@ -546,7 +449,6 @@ async def close(self): Shutdown the executor when you are done using the RenphoWeight instance. """ await self.session.close() - self.executor.shutdown() class Interval(Timer): diff --git a/custom_components/renpho/config_flow.py b/custom_components/renpho/config_flow.py index f617125..89797d1 100644 --- a/custom_components/renpho/config_flow.py +++ b/custom_components/renpho/config_flow.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from .const import CONF_EMAIL, CONF_PASSWORD, CONF_PUBLIC_KEY, CONF_USER_ID, DOMAIN -from .renpho import RenphoWeight +from .api_renpho import RenphoWeight _LOGGER = logging.getLogger(__name__) @@ -23,6 +23,7 @@ vol.Optional( CONF_USER_ID, description={"suggested_value": "OptionalUserID"} ): str, + vol.Optional(CONF_REFRESH, description={"suggested_value": 60}): int, } ) @@ -35,6 +36,7 @@ async def async_validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any data[CONF_EMAIL], data[CONF_PASSWORD], data.get(CONF_USER_ID), + data.get(CONF_REFRESH,60), ) is_valid = await renpho.validate_credentials() if not is_valid: @@ -42,7 +44,6 @@ async def async_validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any reason="Invalid credentials", details={ "email": data[CONF_EMAIL], - "user_id": data.get(CONF_USER_ID, None), }, ) return {"title": data[CONF_EMAIL]} diff --git a/custom_components/renpho/const.py b/custom_components/renpho/const.py index 0858ac9..435e9ca 100755 --- a/custom_components/renpho/const.py +++ b/custom_components/renpho/const.py @@ -103,7 +103,6 @@ "custom_unit5", ] -# Constants for Girth Goals GIRTH_GOALS: Final = [ "girth_type", "setup_goal_at", @@ -116,6 +115,10 @@ "finish_unit", ] +METRIC_TYPE_WEIGHT: Final = "weight" +METRIC_TYPE_GROWTH_GOAL: Final = "growth_goal" +METRIC_TYPE_GROWTH: Final = "growth" + # Public key for encrypting the password CONF_PUBLIC_KEY: Final = """-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC+25I2upukpfQ7rIaaTZtVE744 diff --git a/custom_components/renpho/renpho.png b/custom_components/renpho/renpho.png new file mode 100644 index 0000000000000000000000000000000000000000..26cf6aca6d562616cfd2289af95b6f217b309eca GIT binary patch literal 14773 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 literal 0 HcmV?d00001 diff --git a/custom_components/renpho/sensor.py b/custom_components/renpho/sensor.py index 40dda09..997ecfc 100755 --- a/custom_components/renpho/sensor.py +++ b/custom_components/renpho/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime +from typing import Optional from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry @@ -12,7 +13,7 @@ from homeassistant.util import slugify from .const import CM_TO_INCH, DOMAIN, KG_TO_LBS -from .renpho import _LOGGER, RenphoWeight +from .api_renpho import _LOGGER, RenphoWeight async def sensors_list( @@ -709,7 +710,8 @@ def device_state_attributes(self): "label": self._label, } - def convert_unit(self, value, unit): + def convert_unit(self, value: Optional[float], unit: str) -> Optional[float]: + """Convert unit based on the conversion mapping.""" conversions = {"kg": value * KG_TO_LBS, "cm": value * CM_TO_INCH} return conversions.get(unit, value) @@ -753,23 +755,31 @@ def label(self) -> str: """Return the label of the sensor.""" return self._label + async def async_update(self) -> None: + """Update the sensor using the event loop for asynchronous code.""" + METRIC_TYPES = ["weight", "growth", "growth_goal", ] # Define all the types of metrics + for metric_type in METRIC_TYPES: + try: + metric_value = await self._renpho.get_specific_metric( + metric_type=metric_type, + metric=self._metric, + user_id=None + ) -async def async_update(self): - """Update the sensor using the event loop for asynchronous code.""" - try: - metric_value = await self._renpho.get_specific_metric(self._metric) + if metric_value is not None: + self._state = metric_value # Update the state if a new value is received - # Update state with the new metric_value - self._state = metric_value if metric_value is not None else self._state + # Convert the unit if necessary + self._state = self.convert_unit(self._state, self._unit_of_measurement) - # Convert the unit if necessary - self._state = self.convert_unit(self._state, self._unit_of_measurement) + # Update the timestamp + self._timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - # Update the timestamp - self._timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + _LOGGER.info(f"Successfully updated {self._name} for metric type {metric_type}") + + except (ConnectionError, TimeoutError) as e: + _LOGGER.error(f"{type(e).__name__} occurred while updating {self._name} for metric type {metric_type}: {e}") + + except Exception as e: + _LOGGER.critical(f"An unexpected error occurred while updating {self._name} for metric type {metric_type}: {e}") - _LOGGER.info(f"Successfully updated {self._name}") - except (ConnectionError, TimeoutError) as e: - _LOGGER.error(f"{type(e).__name__} updating {self._name}: {e}") - except Exception as e: - _LOGGER.error(f"An unexpected error occurred updating {self._name}: {e}") From 569a97b08c125ceeb5265d34b6d6955c133fb657 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Tue, 12 Sep 2023 01:07:23 +0000 Subject: [PATCH 54/56] fix the specific mesurements --- custom_components/renpho/__init__.py | 6 -- custom_components/renpho/api_renpho.py | 98 +++++++++++++++---------- custom_components/renpho/config_flow.py | 2 +- custom_components/renpho/const.py | 12 ++- custom_components/renpho/sensor.py | 15 ++-- 5 files changed, 79 insertions(+), 54 deletions(-) diff --git a/custom_components/renpho/__init__.py b/custom_components/renpho/__init__.py index b15379d..1d0bd37 100755 --- a/custom_components/renpho/__init__.py +++ b/custom_components/renpho/__init__.py @@ -99,12 +99,6 @@ async def main(): renpho.get_info_sync() users = await renpho.get_scale_users() print("Fetched scale users:", users) - metric = await renpho.get_specific_metric_from_user_ID("weight", "bodyfat") - print("Fetched specific metric:", metric) - metric_for_user = await renpho.get_specific_metric_from_user_ID( - "weight", "bodyfat", "" - ) - print("Fetched specific metric for user:", metric_for_user) get_device_info = await renpho.get_device_info() print("Fetched device info:", get_device_info) list_growth_record = await renpho.list_growth_record() diff --git a/custom_components/renpho/api_renpho.py b/custom_components/renpho/api_renpho.py index 4efe651..b49711b 100755 --- a/custom_components/renpho/api_renpho.py +++ b/custom_components/renpho/api_renpho.py @@ -5,7 +5,7 @@ import time from base64 import b64encode from threading import Timer -from typing import Dict, List, Optional, Union +from typing import Callable, Dict, List, Optional, Union import aiohttp import requests @@ -251,37 +251,40 @@ async def get_specific_metric( self, metric_type: str, metric: str, user_id: Optional[str] = None ) -> Optional[float]: """ - Fetch a specific metric for a particular user ID based on the type specified (weight, growth goal, or growth metric). + Fetch a specific metric for a particular user ID based on the type specified. Parameters: - metric_type (str): The type of metric to fetch ('weight', 'growth_goal', 'growth'). - metric (str): The specific metric to fetch (e.g., "height", "growth_rate", "weight"). - user_id (str, optional): The user ID for whom to fetch the metric. Defaults to None. + metric_type (str): The type of metric to fetch. + metric (str): The specific metric to fetch. + user_id (Optional[str]): The user ID for whom to fetch the metric. Defaults to None. Returns: - float, None: The fetched metric value, or None if it couldn't be fetched. + Optional[float]: The fetched metric value, or None if it couldn't be fetched. """ + + METRIC_TYPE_FUNCTIONS = { + METRIC_TYPE_WEIGHT: ("get_measurements", "last_ary"), + METRIC_TYPE_GIRTH: ("list_girth", "girths"), + METRIC_TYPE_GROWTH_GOAL: ("list_growth_goal", "growth_goals"), + METRIC_TYPE_GROWTH_RECORD: ("list_growth_record", "growths"), + } + try: if user_id: self.set_user_id(user_id) - if metric_type == METRIC_TYPE_WEIGHT: - last_measurement = await self.get_measurements() - return last_measurement[0].get(metric, None) if last_measurement else None + func_name, last_measurement_key = METRIC_TYPE_FUNCTIONS.get(metric_type, (None, None)) - elif metric_type == METRIC_TYPE_GROWTH_GOAL: - growth_goal_info = await self.list_growth_goal() - last_goal = growth_goal_info.get("growth_goals", [])[0] if growth_goal_info.get("growth_goals") else None - return last_goal.get(metric, None) if last_goal else None + if func_name is None: + _LOGGER.error(f"Invalid metric_type: {metric_type}. Must be one of {list(METRIC_TYPE_FUNCTIONS.keys())}.") + return None - elif metric_type == METRIC_TYPE_GROWTH: - growth_info = await self.list_growth() - last_measurement = growth_info.get("growths", [])[0] if growth_info.get("growths") else None - return last_measurement.get(metric, None) if last_measurement else None + # Dynamically call the function + func: Callable = getattr(self, func_name) + metric_info = await func() - else: - _LOGGER.error(f"Invalid metric_type: {metric_type}. Must be one of {METRIC_TYPE_WEIGHT}, {METRIC_TYPE_GROWTH_GOAL}, or {METRIC_TYPE_GROWTH}.") - return None + last_measurement = metric_info.get(last_measurement_key, [])[0] if metric_info.get(last_measurement_key) else None + return last_measurement.get(metric, None) if last_measurement else None except Exception as e: _LOGGER.error(f"An error occurred: {e}") @@ -342,29 +345,50 @@ async def list_latest_model(self): url = f"{LATEST_MODEL_URL}?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" return await self._request("GET", url) - async def list_girth(self): + async def list_girth(self) -> Optional[dict]: """ Asynchronously list girth information. Returns: - dict: The API response as a dictionary. + Optional[dict]: The API response as a dictionary, or None if the request fails. """ week_ago_timestamp = self.get_week_ago_timestamp() url = f"{GIRTH_URL}?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" - return await self._request("GET", url) + try: + return await self._request("GET", url) + except Exception as e: + _LOGGER.error(f"An error occurred while listing girth: {e}") + return None - async def list_girth_goal(self): + + async def get_specific_girth_metric( + self, metric: str, user_id: Optional[str] = None + ) -> Optional[float]: """ - Asynchronously list girth goal information. + Fetch a specific girth metric for a particular user ID based on the most recent girth information. + + Parameters: + metric (str): The specific metric to fetch (e.g., "waist", "hip"). + user_id (Optional[str]): The user ID for whom to fetch the metric. Defaults to None. Returns: - dict: The API response as a dictionary. + Optional[float]: The fetched metric value, or None if it couldn't be fetched. """ - week_ago_timestamp = self.get_week_ago_timestamp() - url = f"{GIRTH_GOAL_URL}?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" - return await self._request("GET", url) + try: + if user_id: + self.set_user_id(user_id) + girth_info = await self.list_girth() + last_measurement = ( + girth_info.get("girths", [])[0] + if girth_info.get("girths") + else None + ) + return last_measurement.get(metric, None) if last_measurement else None + except Exception as e: + _LOGGER.error(f"An error occurred: {e}") + return None - async def list_growth_goal(self): + async def list_girth_goal(self): """ Asynchronously list girth goal information. @@ -375,14 +399,14 @@ async def list_growth_goal(self): url = f"{GIRTH_GOAL_URL}?user_id={self.user_id}&last_updated_at={week_ago_timestamp}&locale=en&app_id=Renpho&terminal_user_session_key={self.session_key}" return await self._request("GET", url) - async def get_specific_growth_goal_metric( + async def get_specific_girth_goal_metric( self, metric: str, user_id: Optional[str] = None ) -> Optional[float]: """ - Fetch a specific growth goal metric for a particular user ID from the most recent growth goal information. + Fetch a specific girth goal metric for a particular user ID from the most recent girth goal information. Parameters: - metric (str): The specific metric to fetch (e.g., "height_goal", "growth_rate_goal"). + metric (str): The specific metric to fetch . user_id (str, optional): The user ID for whom to fetch the metric. Defaults to None. Returns: @@ -390,11 +414,11 @@ async def get_specific_growth_goal_metric( """ try: if user_id: - self.set_user_id(user_id) # Update the user_id if provided - growth_goal_info = await self.list_growth_goal() + self.set_user_id(user_id) + girth_goal_info = await self.list_girth_goal() last_goal = ( - growth_goal_info.get("growth_goals", [])[0] - if growth_goal_info.get("growth_goals") + girth_goal_info.get("girth_goals", [])[0] + if girth_goal_info.get("girth_goals") else None ) return last_goal.get(metric, None) if last_goal else None diff --git a/custom_components/renpho/config_flow.py b/custom_components/renpho/config_flow.py index 89797d1..cc895a6 100644 --- a/custom_components/renpho/config_flow.py +++ b/custom_components/renpho/config_flow.py @@ -9,7 +9,7 @@ from homeassistant import config_entries, exceptions from homeassistant.core import HomeAssistant -from .const import CONF_EMAIL, CONF_PASSWORD, CONF_PUBLIC_KEY, CONF_USER_ID, DOMAIN +from .const import CONF_EMAIL, CONF_PASSWORD, CONF_PUBLIC_KEY, CONF_REFRESH, CONF_USER_ID, DOMAIN from .api_renpho import RenphoWeight _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/renpho/const.py b/custom_components/renpho/const.py index 435e9ca..c84d2df 100755 --- a/custom_components/renpho/const.py +++ b/custom_components/renpho/const.py @@ -116,8 +116,16 @@ ] METRIC_TYPE_WEIGHT: Final = "weight" -METRIC_TYPE_GROWTH_GOAL: Final = "growth_goal" -METRIC_TYPE_GROWTH: Final = "growth" +METRIC_TYPE_GROWTH_RECORD: Final = "growth_record" +METRIC_TYPE_GIRTH: Final = "girth" +METRIC_TYPE_GIRTH_GOAL: Final = "girth_goal" + +METRIC_TYPE = [ + METRIC_TYPE_WEIGHT, + METRIC_TYPE_GROWTH_RECORD, + METRIC_TYPE_GIRTH, + METRIC_TYPE_GIRTH_GOAL, +] # Public key for encrypting the password CONF_PUBLIC_KEY: Final = """-----BEGIN PUBLIC KEY----- diff --git a/custom_components/renpho/sensor.py b/custom_components/renpho/sensor.py index 997ecfc..722aa25 100755 --- a/custom_components/renpho/sensor.py +++ b/custom_components/renpho/sensor.py @@ -12,7 +12,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from .const import CM_TO_INCH, DOMAIN, KG_TO_LBS +from .const import CM_TO_INCH, DOMAIN, KG_TO_LBS, METRIC_TYPE from .api_renpho import _LOGGER, RenphoWeight @@ -757,9 +757,8 @@ def label(self) -> str: async def async_update(self) -> None: """Update the sensor using the event loop for asynchronous code.""" - METRIC_TYPES = ["weight", "growth", "growth_goal", ] # Define all the types of metrics - for metric_type in METRIC_TYPES: - try: + try: + for metric_type in METRIC_TYPE: metric_value = await self._renpho.get_specific_metric( metric_type=metric_type, metric=self._metric, @@ -777,9 +776,9 @@ async def async_update(self) -> None: _LOGGER.info(f"Successfully updated {self._name} for metric type {metric_type}") - except (ConnectionError, TimeoutError) as e: - _LOGGER.error(f"{type(e).__name__} occurred while updating {self._name} for metric type {metric_type}: {e}") + except (ConnectionError, TimeoutError) as e: + _LOGGER.error(f"{type(e).__name__} occurred while updating {self._name} for metric type {metric_type}: {e}") - except Exception as e: - _LOGGER.critical(f"An unexpected error occurred while updating {self._name} for metric type {metric_type}: {e}") + except Exception as e: + _LOGGER.critical(f"An unexpected error occurred while updating {self._name} for metric type {metric_type}: {e}") From c733b7a83fb1e225690834d824092a4adb7b87b5 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Tue, 12 Sep 2023 22:40:05 -0400 Subject: [PATCH 55/56] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d38cf64..68dfb54 100755 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ [![GitHub release (latest by date)](https://img.shields.io/github/v/release/antoinebou12/hass_renpho?color=41BDF5&style=for-the-badge)](https://github.com/antoinebou12/hass_renpho/releases/latest) [![Integration Usage](https://img.shields.io/badge/dynamic/json?color=41BDF5&style=for-the-badge&logo=home-assistant&label=usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.hass_renpho.total)](https://analytics.home-assistant.io/) +USE V.1.0.0 Please + ## Overview This custom component allows you to integrate Renpho's weight scale data into Home Assistant. It fetches weight and various other health metrics and displays them as sensors in Home Assistant. From 791e7d321d4baca8750a15939763a9793f5495d4 Mon Sep 17 00:00:00 2001 From: Antoine Boucher Date: Tue, 12 Sep 2023 22:43:48 -0400 Subject: [PATCH 56/56] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 68dfb54..22c9827 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![HACS Badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/antoinebou12/hass_renpho?color=41BDF5&style=for-the-badge)](https://github.com/antoinebou12/hass_renpho/releases/latest) -[![Integration Usage](https://img.shields.io/badge/dynamic/json?color=41BDF5&style=for-the-badge&logo=home-assistant&label=usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.hass_renpho.total)](https://analytics.home-assistant.io/) +[![Integration Usage](https://img.shields.io/badge/dynamic/json?color=41BDF5&style=for-the-badge&logo=home-assistant&label=usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.hass_renpho.total)](https://analytics.home-assistant.io/custom_integrations.json&query=$.hass_renpho.total) USE V.1.0.0 Please