diff --git a/README.md b/README.md index 869aed1..1d47c23 100755 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Renpho Weight Home Assistant Component -# IN DEVELOPPEMENT +# IN DEVELOPPEMENT (Not Working) ![Version](https://img.shields.io/badge/version-v3.0.1-blue) ![License](https://img.shields.io/badge/license-MIT-green) @@ -24,6 +24,9 @@ https://github.com/antoinebou12/hass_renpho/assets/13888068/0bf0e48f-0582-462a-b Use http proxy for the app here a list https://hidemy.io/en/proxy-list/?type=s#list +## Install config +![image](https://github.com/antoinebou12/hass_renpho/assets/13888068/b1a90a12-9f57-42ad-adf8-008a6de92880) + ### Weight ![Weight Sensor](docs/images/weight.png) diff --git a/api/app.py b/api/app.py index 78d0410..93bcf58 100644 --- a/api/app.py +++ b/api/app.py @@ -15,12 +15,12 @@ from contextlib import asynccontextmanager import aiohttp +from aiohttp import ClientTimeout +from aiohttp_socks import ProxyConnector from Crypto.Cipher import PKCS1_v1_5 from Crypto.PublicKey import RSA from pydantic import BaseModel -import logging - METRIC_TYPE_WEIGHT: Final = "weight" METRIC_TYPE_GROWTH_RECORD: Final = "growth_record" @@ -53,8 +53,6 @@ from dataclasses import dataclass -from typing import List, Optional - from pydantic import BaseModel class DeviceBind(BaseModel): @@ -349,7 +347,7 @@ class RenphoWeight: user_id (str, optional): The ID of the user for whom weight data should be fetched. """ - def __init__(self, email, password, user_id=None, refresh=60): + def __init__(self, email, password, user_id=None, refresh=60, proxy=None): """Initialize a new RenphoWeight instance.""" self.public_key: str = CONF_PUBLIC_KEY self.email: str = email @@ -379,6 +377,7 @@ def __init__(self, email, password, user_id=None, refresh=60): self._last_updated_growth_record = None self.auth_in_progress = False self.is_polling_active = False + self.proxy = proxy @staticmethod def get_timestamp() -> int: @@ -411,6 +410,20 @@ async def open_session(self): headers={"Content-Type": "application/json", "Accept": "application/json"}, ) + async def check_proxy(self): + """ + Checks if the proxy is working by making a request to httpbin.org. + """ + test_url = 'https://renpho.qnclouds.com/api/v3/girths/list_girth.json?app_id=Renpho&terminal_user_session_key=' + try: + connector = ProxyConnector.from_url(self.proxy) if self.proxy else None + async with aiohttp.ClientSession(connector=connector) as session: + async with session.get(test_url) as response: + return True + except Exception as e: + _LOGGER.error(f"Proxy connection failed: {e}") + return False + async def _request(self, method: str, url: str, retries: int = 3, skip_auth=False, **kwargs): """ @@ -426,44 +439,45 @@ async def _request(self, method: str, url: str, retries: int = 3, skip_auth=Fals Returns: Union[Dict, List]: The parsed JSON response from the API request. """ - token = self.token + if not await self.check_proxy(): + _LOGGER.error("Proxy check failed. Aborting authentication.") + raise APIError("Proxy check failed. Aborting authentication.") while retries > 0: - session = aiohttp.ClientSession( - headers={"Content-Type": "application/json", "Accept": "application/json", - "User-Agent": "Renpho/2.1.0 (iPhone; iOS 14.4; Scale/2.1.0; en-US)" - } - ) - - if not token and not url.endswith("sign_in.json") and not skip_auth: - auth_success = await self.auth() - token = self.token - if not auth_success: - raise AuthenticationError("Authentication failed. Unable to proceed with the request.") - - kwargs = self.prepare_data(kwargs) - - try: - async with session.request(method, url, **kwargs) as response: - response.raise_for_status() - parsed_response = await response.json() - - if parsed_response.get("status_code") == "40302": - token = None - skip_auth = False - retries -= 1 - await session.close() - continue # Retry the request - if parsed_response.get("status_code") == "50000": - raise APIError(f"Internal server error: {parsed_response.get('status_message')}") - if parsed_response.get("status_code") == "20000" and parsed_response.get("status_message") == "ok": - return parsed_response - else: - raise APIError(f"API request failed {method} {url}: {parsed_response.get('status_message')}") - except (aiohttp.ClientResponseError, aiohttp.ClientConnectionError) as e: - _LOGGER.error(f"Client error: {e}") - raise APIError(f"API request failed {method} {url}") from e - finally: - await session.close() + connector = ProxyConnector.from_url(self.proxy) if self.proxy else None + async with aiohttp.ClientSession(connector=connector, headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "Renpho/2.1.0 (iPhone; iOS 14.4; Scale/2.1.0; en-US)" + }, timeout=ClientTimeout(total=60)) as session: + + if not self.token and not url.endswith("sign_in.json") or not skip_auth: + auth_success = await self.auth() + if not auth_success: + raise AuthenticationError("Authentication failed. Unable to proceed with the request.") + + kwargs = self.prepare_data(kwargs) + + try: + async with session.request(method, url, **kwargs) as response: + response.raise_for_status() + parsed_response = await response.json() + + if parsed_response.get("status_code") == "40302": + skip_auth = False + auth_success = await self.auth() + if not auth_success: + raise AuthenticationError("Authentication failed. Unable to proceed with the request.") + retries -= 1 + continue # Retry the request + if parsed_response.get("status_code") == "50000": + raise APIError(f"Internal server error: {parsed_response.get('status_message')}") + if parsed_response.get("status_code") == "20000" and parsed_response.get("status_message") == "ok": + return parsed_response + else: + raise APIError(f"API request failed {method} {url}: {parsed_response.get('status_message')}") + except (aiohttp.ClientResponseError, aiohttp.ClientConnectionError) as e: + _LOGGER.error(f"Client error: {e}") + raise APIError(f"API request failed {method} {url}") from e @staticmethod def encrypt_password(public_key_str, password): @@ -510,53 +524,64 @@ async def auth(self): data = self.prepare_data({"secure_flag": "1", "email": self.email, "password": encrypted_password}) - try: - self.token = None - session = aiohttp.ClientSession( - headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": "Renpho/2.1.0 (iPhone; iOS 14.4; Scale/2.1.0; en-US)"}, - ) - - async with session.request("POST", API_AUTH_URL, json=data) as response: - response.raise_for_status() - parsed = await response.json() - - if parsed is None: - _LOGGER.error("Authentication failed. No response received.") - raise AuthenticationError("Authentication failed. No response received.") - - if parsed.get("status_code") == "50000" and parsed.get("status_message") == "Email was not registered": - _LOGGER.warning("Email was not registered.") - raise AuthenticationError("Email was not registered.") - - if parsed.get("status_code") == "500" and parsed.get("status_message") == "Internal Server Error": - _LOGGER.warning("Bad Password or Internal Server Error.") - raise AuthenticationError("Bad Password or Internal Server Error.") - - if "terminal_user_session_key" not in parsed: - _LOGGER.error( - "'terminal_user_session_key' not found in parsed object.") - raise AuthenticationError(f"Authentication failed: {parsed}") - - if parsed.get("status_code") == "20000" and parsed.get("status_message") == "ok": - if 'terminal_user_session_key' in parsed: - self.token = parsed["terminal_user_session_key"] - else: - self.token = None - raise AuthenticationError("Session key not found in response.") - if 'device_binds_ary' in parsed: - parsed['device_binds_ary'] = [DeviceBind(**device) for device in parsed['device_binds_ary']] - else: - parsed['device_binds_ary'] = [] - self.login_data = UserResponse(**parsed) - if self.user_id is None: - self.user_id = self.login_data.get("id", None) - return True - except Exception as e: - _LOGGER.error(f"Authentication failed: {e}") - raise AuthenticationError("Authentication failed due to an error. {e}") from e - finally: - self.auth_in_progress = False - await session.close() + for attempt in range(3): + try: + self.token = None + if not await self.check_proxy(): + _LOGGER.error("Proxy check failed. Aborting authentication.") + raise APIError("Proxy check failed. Aborting authentication.") + + connector = ProxyConnector.from_url(self.proxy) if self.proxy else None + async with aiohttp.ClientSession(connector=connector, headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "Renpho/2.1.0 (iPhone; iOS 14.4; Scale/2.1.0; en-US)" + }, timeout=ClientTimeout(total=60)) as session: + + async with session.request("POST", API_AUTH_URL, json=data) as response: + response.raise_for_status() + parsed = await response.json() + + if parsed is None: + _LOGGER.error("Authentication failed. No response received.") + raise AuthenticationError("Authentication failed. No response received.") + + if parsed.get("status_code") == "50000" and parsed.get("status_message") == "Email was not registered": + _LOGGER.warning("Email was not registered.") + raise AuthenticationError("Email was not registered.") + + if parsed.get("status_code") == "500" and parsed.get("status_message") == "Internal Server Error": + _LOGGER.warning("Bad Password or Internal Server Error.") + raise AuthenticationError("Bad Password or Internal Server Error.") + + if "terminal_user_session_key" not in parsed: + _LOGGER.error( + "'terminal_user_session_key' not found in parsed object.") + raise AuthenticationError(f"Authentication failed: {parsed}") + + if parsed.get("status_code") == "20000" and parsed.get("status_message") == "ok": + if 'terminal_user_session_key' in parsed: + self.token = parsed["terminal_user_session_key"] + else: + self.token = None + raise AuthenticationError("Session key not found in response.") + if 'device_binds_ary' in parsed: + parsed['device_binds_ary'] = [DeviceBind(**device) for device in parsed['device_binds_ary']] + else: + parsed['device_binds_ary'] = [] + self.login_data = UserResponse(**parsed) + self.token = parsed["terminal_user_session_key"] + if self.user_id is None: + self.user_id = self.login_data.get("id", None) + return True + except (aiohttp.ClientResponseError, aiohttp.ClientConnectionError) as e: + _LOGGER.error(f"Authentication failed: {e}") + if attempt < 3 - 1: + await asyncio.sleep(5) # Wait before retrying + else: + raise AuthenticationError(f"Authentication failed after retries. {e}") from e + finally: + self.auth_in_progress = False async def get_scale_users(self): """ @@ -629,7 +654,6 @@ async def get_measurements(self): _LOGGER.error(f"Failed to fetch weight measurements: {e}") return None - async def get_measurements_history(self): """ Fetch the most recent weight measurements_history for the user. @@ -735,6 +759,7 @@ async def list_girth(self): if "status_code" in parsed and parsed["status_code"] == "20000": response = GirthResponse(**parsed) + self._last_updated_girth = time.time() self.girth_info = response.girths return self.girth_info else: @@ -870,31 +895,30 @@ async def get_specific_metric(self, metric_type: str, metric: str, user_id: Opti try: if metric_type == METRIC_TYPE_WEIGHT: - if self._last_updated_weight is None or time.time() - self._last_updated_weight > self.refresh: - last_measurement = await self.get_weight() - if last_measurement and self.weight is not None: - return last_measurement[1].get(metric, None) if last_measurement[1] else None + if self._last_updated_weight is None or self.weight: + if self.weight_info is not None: + return self.weight_info.get(metric, None) return self.weight_info.get(metric, None) if self.weight_info else None elif metric_type == METRIC_TYPE_GIRTH: - if self._last_updated_girth is None or time.time() - self._last_updated_girth > self.refresh: + if self._last_updated_girth is None or self.girth_info is None: await self.list_girth() - for girth_entry in self.girth_info: - if hasattr(girth_entry, f"{metric}_value"): - return getattr(girth_entry, f"{metric}_value", None) + if self.girth_info: + valid_girths = sorted([g for g in self.girth_info if getattr(g, f"{metric}_value", 0) not in (None, 0.0)], key=lambda x: x.time_stamp, reverse=True) + for girth in valid_girths: + value = getattr(girth, f"{metric}_value", None) + if value not in (None, 0.0): + return value + return None elif metric_type == METRIC_TYPE_GIRTH_GOAL: - if self._last_updated_girth_goal is None or time.time() - self._last_updated_girth_goal > self.refresh: + if self._last_updated_girth_goal is None or self.girth_goal is None: await self.list_girth_goal() - for goal in self.girth_goal: - if goal.girth_type == metric: - return goal.goal_value - elif metric_type == METRIC_TYPE_GROWTH_RECORD: - if self._last_updated_growth_record is None or time.time() - self._last_updated_growth_record > self.refresh: - last_measurement = ( - self.growth_record.get("growths", [])[0] - if self.growth_record.get("growths") - else None - ) - return last_measurement.get(metric, None) if last_measurement else None + if self.girth_goal: + valid_goals = sorted([g for g in self.girth_goal if g.girth_type == metric and g.goal_value not in (None, 0.0)], key=lambda x: x.setup_goal_at, reverse=True) + # Iterate to find the first valid goal + for goal in valid_goals: + if goal.goal_value not in (None, 0.0): + return goal.goal_value + return None else: _LOGGER.error(f"Invalid metric type: {metric_type}") return None @@ -979,6 +1003,23 @@ class ClientSSLError(Exception): from starlette.responses import Response import httpx +from fastapi import FastAPI, HTTPException, Depends, Request +from fastapi.security import HTTPBasic, HTTPBasicCredentials, APIKeyHeader +from fastapi.responses import JSONResponse +from starlette.middleware.cors import CORSMiddleware +from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND +from datetime import datetime +import hashlib +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 +from Crypto.Hash import SHA256 +from Crypto.Cipher import PKCS1_OAEP +import binascii +from base64 import b64encode, b64decode + +security_basic = HTTPBasic() +API_KEY_NAME = "access_token" +api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) # Initialize FastAPI and Jinja2 app = FastAPI(docs_url="/docs", redoc_url=None) @@ -994,24 +1035,100 @@ class ClientSSLError(Exception): allow_headers=["*"], ) -security = HTTPBasic() - class APIResponse(BaseModel): status: str message: str data: Optional[Any] = None - -async def get_current_user(credentials: HTTPBasicCredentials = Depends(security)): +def load_rsa_keys(): try: - user = RenphoWeight(email=credentials.username, password=credentials.password) - await user.auth() # Ensure that user can authenticate - return user + private_key = RSA.import_key(os.getenv("RSA_PRIVATE_KEY")) + public_key = RSA.import_key(os.getenv("RSA_PUBLIC_KEY")) + return private_key, public_key except Exception as e: - _LOGGER.error(f"Authentication failed: {e}") - raise HTTPException(status_code=401, detail="Authentication failed") from e + print(f"Error loading RSA keys: {e}") + raise HTTPException(status_code=500, detail="Failed to load RSA keys") + +PRIVATE_KEY, PUBLIC_KEY = load_rsa_keys() + + +def generate_api_key(email: str, password: str) -> str: + """Generate a signed and encrypted API key.""" + timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S%f") + payload = f"{email}:{password}:{timestamp}" + # Encrypt the payload + cipher = PKCS1_OAEP.new(PUBLIC_KEY) + encrypted_payload = cipher.encrypt(payload.encode()) + encrypted_hex = binascii.hexlify(encrypted_payload).decode() + + # Sign the encrypted payload + hash_obj = SHA256.new(encrypted_payload) + signature = pkcs1_15.new(PRIVATE_KEY).sign(hash_obj) + signature_hex = binascii.hexlify(signature).decode() + return f"{encrypted_hex}:{signature_hex}" + +def decrypt_api_key(api_key: str) -> dict: + """Verify and decrypt an API key.""" + try: + encrypted_payload_hex, signature_hex = api_key.split(':') + encrypted_payload = binascii.unhexlify(encrypted_payload_hex) + signature = binascii.unhexlify(signature_hex) + + # Verify the signature + hash_obj = SHA256.new(encrypted_payload) + pkcs1_15.new(PUBLIC_KEY).verify(hash_obj, signature) + + # Decrypt the payload + cipher = PKCS1_OAEP.new(PRIVATE_KEY) + payload = cipher.decrypt(encrypted_payload) + email, password, timestamp = payload.decode().split(':') + + return {"email": email, "password": password, "timestamp": timestamp} + except (ValueError, IndexError, TypeError, binascii.Error) as e: + raise HTTPException(status_code=403, detail="Invalid API key") + +def verify_api_key(api_key: str) -> bool: + try: + encrypted_payload_hex, signature_hex = api_key.split(':') + encrypted_payload = binascii.unhexlify(encrypted_payload_hex) + signature = binascii.unhexlify(signature_hex) + + # Verify the signature + hash_obj = SHA256.new(encrypted_payload) + pkcs1_15.new(PUBLIC_KEY).verify(hash_obj, signature) + return True + except Exception: + return False + +def get_credentials_or_api_key(credentials: Optional[HTTPBasicCredentials] = Depends(security_basic), api_key: Optional[str] = Depends(api_key_header)): + if api_key: + if verify_api_key(api_key): + return api_key + else: + raise HTTPException(status_code=403, detail="Invalid API key") + elif credentials: + if credentials.username and credentials.password: + return credentials + else: + raise HTTPException(status_code=403, detail="Invalid HTTP Basic credentials") + else: + raise HTTPException(status_code=401, detail="Authentication credentials were not provided") + +async def get_current_user(auth: Union[HTTPBasicCredentials, str] = Depends(get_credentials_or_api_key)) -> RenphoWeight: + if isinstance(auth, HTTPBasicCredentials): + email, password = auth.username, auth.password + elif isinstance(auth, str): # auth is an API key + user_info = decrypt_api_key(auth) + email, password = user_info['email'], user_info['password'] + else: + raise HTTPException(status_code=401, detail="Invalid authentication method") + + user = RenphoWeight(email=email, password=password) + if not await user.auth(): + raise HTTPException(status_code=403, detail="Invalid email or password") + return user @app.get("/") def read_root(request: Request): @@ -1022,6 +1139,15 @@ async def auth(renpho: RenphoWeight = Depends(get_current_user)): # If this point is reached, authentication was successful return APIResponse(status="success", message="Authentication successful.") +@app.get("/generate_api_key", response_model=APIResponse) +def generate_key(request: Request, email: str, password: str, renpho: RenphoWeight = Depends(get_current_user)): + """Generate API key for a user.""" + try: + api_key = generate_api_key(email, password) + return APIResponse(status="success", message="API key generated.", data={"api_key": api_key}) + except Exception as e: + return APIResponse(status="error", message="Failed to generate API key.", data=str(e)) + @app.get("/info", response_model=APIResponse) async def get_info(renpho: RenphoWeight = Depends(get_current_user)): try: @@ -1152,4 +1278,4 @@ async def message_list(request: Request, renpho: RenphoWeight = Depends(get_curr raise HTTPException(status_code=404, detail="Message list not found") except Exception as e: _LOGGER.error(f"Error fetching message list: {e}") - return APIResponse(status="error", message=str(e)) \ No newline at end of file + return APIResponse(status="error", message=str(e)) diff --git a/api/requirements.txt b/api/requirements.txt index 1906cf3..d7765e7 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -13,3 +13,4 @@ pyjwt python-dotenv pycryptodome starlette +aiohttp_socks diff --git a/custom_components/renpho/api_renpho.py b/custom_components/renpho/api_renpho.py index da68fc9..004876a 100755 --- a/custom_components/renpho/api_renpho.py +++ b/custom_components/renpho/api_renpho.py @@ -79,6 +79,8 @@ def __init__(self, email, password, user_id=None, refresh=60, proxy=None): self.is_polling_active = False self.proxy = proxy + _LOGGER.info(f"Initializing RenphoWeight instance. Proxy is {'enabled: ' + proxy if proxy else 'disabled.'}") + @staticmethod def get_timestamp() -> int: start_date = datetime.date(1998, 1, 1) @@ -112,18 +114,30 @@ async def open_session(self): async def check_proxy(self): """ - Checks if the proxy is working by making a request to httpbin.org. + Checks if the proxy is working by making a request to a Renpho API endpoint. """ - test_url = 'https://renpho.qnclouds.com/api/v3/girths/list_girth.json?app_id=Renpho&terminal_user_session_key=' + test_url = 'http://httpbin.org/get' + + if not self.proxy: + _LOGGER.info("No proxy configured. Proceeding without proxy.") + else: + _LOGGER.info(f"Checking proxy connectivity using proxy: {self.proxy}") + try: connector = ProxyConnector.from_url(self.proxy) if self.proxy else None - async with aiohttp.ClientSession(connector=connector) as session: - async with session.get(test_url) as response: + session = aiohttp.ClientSession(connector=connector) + async with session.get(test_url) as response: + if response.status == 200: + _LOGGER.info("Proxy check successful." if self.proxy else "Direct connection successful.") return True + else: + _LOGGER.error(f"Failed to connect using {'proxy' if self.proxy else 'direct connection'}. HTTP Status: {response.status}") + return False except Exception as e: _LOGGER.error(f"Proxy connection failed: {e}") return False - + finally: + await session.close() async def _request(self, method: str, url: str, retries: int = 3, skip_auth=False, **kwargs): """ @@ -198,11 +212,13 @@ async def validate_credentials(self): Validate the current credentials by attempting to authenticate. Returns True if authentication succeeds, False otherwise. """ + _LOGGER.debug("Validating credentials for user: %s", self.email) try: return await self.auth() except Exception as e: - _LOGGER.error(f"Validation failed: {e}") - return False + _LOGGER.error("Failed to validate credentials for user: %s. Error: %s", self.email, e) + raise AuthenticationError(f"Invalid credentials for user {self.email}. Error details: {e}") from e + async def auth(self): """Authenticate with the Renpho API.""" @@ -665,4 +681,4 @@ class APIError(Exception): class ClientSSLError(Exception): - pass \ No newline at end of file + pass diff --git a/custom_components/renpho/config_flow.py b/custom_components/renpho/config_flow.py index afee581..d8475fa 100644 --- a/custom_components/renpho/config_flow.py +++ b/custom_components/renpho/config_flow.py @@ -34,34 +34,55 @@ 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) + _LOGGER.debug("Starting to validate input for Renpho integration: %s", data) + + # Initialize RenphoWeight instance renpho = RenphoWeight( email=data[CONF_EMAIL], password=data[CONF_PASSWORD], refresh=data.get(CONF_REFRESH, 60), proxy=data.get("proxy", None) ) + + # Check if a proxy is set and validate it + if renpho.proxy: + _LOGGER.info(f"Proxy is configured, checking proxy: {renpho.proxy}") + proxy_is_valid = await renpho.check_proxy() + if not proxy_is_valid: + _LOGGER.error(f"Proxy check failed for proxy: {renpho.proxy}") + raise CannotConnect(reason="Proxy check failed", details={"proxy": renpho.proxy}) + else: + _LOGGER.info("Proxy check passed successfully.") + else: + _LOGGER.info("No proxy configured, skipping proxy check.") + + _LOGGER.info(f"Attempting to validate credentials for {data[CONF_EMAIL]}") + + # Validate credentials is_valid = await renpho.validate_credentials() if not is_valid: + _LOGGER.error(f"Failed to validate credentials for user: {data[CONF_EMAIL]}. Invalid credentials.") raise CannotConnect( reason="Invalid credentials", - details={ - "email": data[CONF_EMAIL], - }, + details={"email": data[CONF_EMAIL]}, ) + else: + _LOGGER.info(f"Credentials validated successfully for {data[CONF_EMAIL]}") + # Fetch and validate scale users + _LOGGER.info("Fetching scale users associated with the account.") await renpho.get_scale_users() - - user_ids = [ - user.get("user_id", None) - for user in renpho.users - ] + user_ids = [user.user_id for user in renpho.users if user.user_id is not None] if not user_ids: + _LOGGER.error(f"No users found associated with the account {data[CONF_EMAIL]}") raise CannotConnect(reason="No users found", details={"email": data[CONF_EMAIL]}) + else: + _LOGGER.info(f"Found users with IDs: {user_ids} for the account {data[CONF_EMAIL]}") return {"title": data[CONF_EMAIL], "user_ids": user_ids, "renpho_instance": renpho} + class RenphoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL @@ -122,4 +143,4 @@ def __str__(self): return f"CannotConnect: {self.reason} - {self.details}" def get_details(self): - return self.details + return self.details \ No newline at end of file diff --git a/custom_components/renpho/const.py b/custom_components/renpho/const.py index a4aa494..0ef30e0 100755 --- a/custom_components/renpho/const.py +++ b/custom_components/renpho/const.py @@ -24,7 +24,7 @@ ) CONF_UNIT_OF_MEASUREMENT = "unit_of_measurement" -KG_TO_LBS: Final = 2.20462 +KG_TO_LBS: Final = 2.2046226218 CM_TO_INCH: Final = 0.393701 # General Information Metrics diff --git a/custom_components/renpho/manifest.json b/custom_components/renpho/manifest.json index 89dcd12..098f6db 100755 --- a/custom_components/renpho/manifest.json +++ b/custom_components/renpho/manifest.json @@ -6,14 +6,14 @@ "dependencies": [], "codeowners": ["@neilzilla", "@antoinebou12"], "requirements": [ - "pycryptodome>=3.3.1", - "requests>=2.25.0", - "aiohttp>=3.6.1", - "voluptuous>=0.11.7", + "pycryptodome", + "requests", + "aiohttp", + "voluptuous", "pydantic", "aiohttp_socks" ], "iot_class": "cloud_polling", - "version": "3.0.1", + "version": "3.0.2", "config_flow": true } diff --git a/custom_components/renpho/renpho.png b/custom_components/renpho/renpho.png deleted file mode 100644 index 26cf6ac..0000000 Binary files a/custom_components/renpho/renpho.png and /dev/null differ diff --git a/custom_components/renpho/string.json b/custom_components/renpho/string.json index 55f72b9..960cb24 100644 --- a/custom_components/renpho/string.json +++ b/custom_components/renpho/string.json @@ -19,5 +19,70 @@ "abort": { "already_configured": "Device is already configured" } + }, + "sensor": { + "weight": "Weight", + "bmi": "BMI", + "muscle": "Muscle Mass", + "bone": "Bone Mass", + "waistline": "Waistline", + "hip": "Hip", + "stature": "Stature", + "bodyfat": "Body Fat", + "water": "Water Content", + "subfat": "Subcutaneous Fat", + "visfat": "Visceral Fat", + "bmr": "Basal Metabolic Rate", + "protein": "Protein Content", + "bodyage": "Body Age", + "neck": "Neck", + "shoulder": "Shoulder", + "left_arm": "Left Arm", + "right_arm": "Right Arm", + "chest": "Chest", + "waist": "Waist", + "hip": "Hip", + "left_thigh": "Left Thigh", + "right_thigh": "Right Thigh", + "left_calf": "Left Calf", + "right_calf": "Right Calf", + "whr": "Waist-Hip Ratio", + "abdomen": "Abdomen", + "unit": { + "kg": "kg", + "percent": "%", + "cm": "cm", + "bpm": "bpm" + }, + "category": { + "measurements": "Measurements", + "body_composition": "Body Composition", + "metabolic_metrics": "Metabolic Metrics", + "age_metrics": "Age Metrics", + "device_information": "Device Information", + "miscellaneous": "Miscellaneous", + "meta_information": "Meta Information", + "user_profile": "User Profile", + "electrical_measurements": "Electrical Measurements", + "cardiovascular_metrics": "Cardiovascular Metrics", + "other_metrics": "Other Metrics", + "girth_measurements": "Girth Measurements", + "girth_goals": "Girth Goals" + }, + "label": { + "physical_metrics": "Physical Metrics", + "body_composition": "Body Composition", + "metabolic_metrics": "Metabolic Metrics", + "age_metrics": "Age Metrics", + "device_information": "Device Information", + "miscellaneous": "Miscellaneous", + "meta_information": "Meta Information", + "user_profile": "User Profile", + "electrical_measurements": "Electrical Measurements", + "cardiovascular_metrics": "Cardiovascular Metrics", + "other_metrics": "Other Metrics", + "girth_measurements": "Girth Measurements", + "girth_goals": "Girth Goals" + } } -} \ No newline at end of file +} diff --git a/custom_components/renpho/translations/de.json b/custom_components/renpho/translations/de.json new file mode 100644 index 0000000..f039fa0 --- /dev/null +++ b/custom_components/renpho/translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Geben Sie Ihre E-Mail-Adresse für Ihr veraltetes Renpho-Konto ein.", + "password": "Geben Sie Ihr Renpho-Kontopasswort ein.", + "refresh": "Stellen Sie die Datenaktualisierungsrate in Sekunden ein. Standard ist 60 Sekunden.", + "unit_of_measurement": "Wählen Sie die Maßeinheit für das Gewicht. Die Optionen sind Kilogramm (kg) oder Pfund (lbs).", + "proxy": "Optional: Geben Sie einen Proxy-Server an (z.B. 'http://127.0.0.1:8080')." + } + } + }, + "error": { + "cannot_connect": "Verbindung zum Dienst kann nicht hergestellt werden", + "invalid_auth": "Ungültige Authentifizierung", + "unknown": "Ein unbekannter Fehler ist aufgetreten" + }, + "abort": { + "already_configured": "Gerät ist bereits konfiguriert" + } + } +} diff --git a/custom_components/renpho/translations/es.json b/custom_components/renpho/translations/es.json new file mode 100644 index 0000000..0808089 --- /dev/null +++ b/custom_components/renpho/translations/es.json @@ -0,0 +1,88 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Ingrese su dirección de correo electrónico de la cuenta Renpho Obsoleta.", + "password": "Ingrese la contraseña de su cuenta Renpho.", + "refresh": "Establezca la frecuencia de actualización de los datos en segundos. El valor predeterminado es de 60 segundos.", + "unit_of_measurement": "Seleccione la unidad de medida para el peso. Las opciones son kilogramos (kg) o libras (lbs).", + "proxy": "Opcional: Especifique un servidor proxy para usar (por ejemplo, 'http://127.0.0.1:8080')." + } + } + }, + "error": { + "cannot_connect": "No se puede conectar al servicio", + "invalid_auth": "Autenticación inválida", + "unknown": "Se produjo un error desconocido" + }, + "abort": { + "already_configured": "El dispositivo ya está configurado" + } + }, + "sensor": { + "weight": "Peso", + "bmi": "IMC", + "muscle": "Masa Muscular", + "bone": "Masa Ósea", + "waistline": "Circunferencia de la Cintura", + "hip": "Cadera", + "stature": "Estatura", + "bodyfat": "Grasa Corporal", + "water": "Contenido de Agua", + "subfat": "Grasa Subcutánea", + "visfat": "Grasa Visceral", + "bmr": "Tasa Metabólica Basal", + "protein": "Contenido de Proteína", + "bodyage": "Edad Corporal", + "neck": "Cuello", + "shoulder": "Hombro", + "left_arm": "Brazo Izquierdo", + "right_arm": "Brazo Derecho", + "chest": "Pecho", + "waist": "Cintura", + "hip": "Cadera", + "left_thigh": "Muslo Izquierdo", + "right_thigh": "Muslo Derecho", + "left_calf": "Pantorrilla Izquierda", + "right_calf": "Pantorrilla Derecha", + "whr": "Ratio Cintura-Cadera", + "abdomen": "Abdomen", + "unit": { + "kg": "kg", + "percent": "%", + "cm": "cm", + "bpm": "bpm" + }, + "category": { + "measurements": "Mediciones", + "body_composition": "Composición Corporal", + "metabolic_metrics": "Métricas Metabólicas", + "age_metrics": "Métricas de Edad", + "device_information": "Información del Dispositivo", + "miscellaneous": "Misceláneo", + "meta_information": "Meta Información", + "user_profile": "Perfil de Usuario", + "electrical_measurements": "Mediciones Eléctricas", + "cardiovascular_metrics": "Métricas Cardiovasculares", + "other_metrics": "Otras Métricas", + "girth_measurements": "Mediciones de Circunferencia", + "girth_goals": "Objetivos de Circunferencia" + }, + "label": { + "physical_metrics": "Métricas Físicas", + "body_composition": "Composición Corporal", + "metabolic_metrics": "Métricas Metabólicas", + "age_metrics": "Métricas de Edad", + "device_information": "Información del Dispositivo", + "miscellaneous": "Misceláneo", + "meta_information": "Meta Información", + "user_profile": "Perfil de Usuario", + "electrical_measurements": "Mediciones Eléctricas", + "cardiovascular_metrics": "Métricas Cardiovasculares", + "other_metrics": "Otras Métricas", + "girth_measurements": "Mediciones de Circunferencia", + "girth_goals": "Objetivos de Circunferencia" + } + } +} diff --git a/custom_components/renpho/translations/fr.json b/custom_components/renpho/translations/fr.json new file mode 100644 index 0000000..9be1b27 --- /dev/null +++ b/custom_components/renpho/translations/fr.json @@ -0,0 +1,88 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Entrez votre adresse e-mail du compte Renpho Obsolète.", + "password": "Entrez le mot de passe de votre compte Renpho.", + "refresh": "Définissez le taux de rafraîchissement des données en secondes. La valeur par défaut est de 60 secondes.", + "unit_of_measurement": "Choisissez l'unité de mesure pour le poids. Les options sont kilogrammes (kg) ou livres (lbs).", + "proxy": "Facultatif : Spécifiez un serveur proxy à utiliser (par exemple, 'http://127.0.0.1:8080')." + } + } + }, + "error": { + "cannot_connect": "Impossible de se connecter au service", + "invalid_auth": "Authentification invalide", + "unknown": "Une erreur inconnue s'est produite" + }, + "abort": { + "already_configured": "Le périphérique est déjà configuré" + } + }, + "sensor": { + "weight": "Poids", + "bmi": "IMC", + "muscle": "Masse Musculaire", + "bone": "Masse Osseuse", + "waistline": "Tour de Taille", + "hip": "Hanche", + "stature": "Stature", + "bodyfat": "Masse Grasse", + "water": "Contenu en Eau", + "subfat": "Graisse Sous-cutanée", + "visfat": "Graisse Viscérale", + "bmr": "Taux Métabolique de Base", + "protein": "Teneur en Protéines", + "bodyage": "Âge Corporel", + "neck": "Cou", + "shoulder": "Épaule", + "left_arm": "Bras Gauche", + "right_arm": "Bras Droit", + "chest": "Poitrine", + "waist": "Taille", + "hip": "Hanche", + "left_thigh": "Cuisse Gauche", + "right_thigh": "Cuisse Droite", + "left_calf": "Mollet Gauche", + "right_calf": "Mollet Droit", + "whr": "Ratio Taille-Hanche", + "abdomen": "Abdomen", + "unit": { + "kg": "kg", + "percent": "%", + "cm": "cm", + "bpm": "bpm" + }, + "category": { + "measurements": "Mesures", + "body_composition": "Composition Corporelle", + "metabolic_metrics": "Métriques Métaboliques", + "age_metrics": "Métriques d'Âge", + "device_information": "Informations sur le Dispositif", + "miscellaneous": "Divers", + "meta_information": "Méta-informations", + "user_profile": "Profil Utilisateur", + "electrical_measurements": "Mesures Électriques", + "cardiovascular_metrics": "Métriques Cardiovasculaires", + "other_metrics": "Autres Métriques", + "girth_measurements": "Mesures de Circonférence", + "girth_goals": "Objectifs de Circonférence" + }, + "label": { + "physical_metrics": "Métriques Physiques", + "body_composition": "Composition Corporelle", + "metabolic_metrics": "Métriques Métaboliques", + "age_metrics": "Métriques d'Âge", + "device_information": "Informations sur le Dispositif", + "miscellaneous": "Divers", + "meta_information": "Méta-informations", + "user_profile": "Profil Utilisateur", + "electrical_measurements": "Mesures Électriques", + "cardiovascular_metrics": "Métriques Cardiovasculaires", + "other_metrics": "Autres Métriques", + "girth_measurements": "Mesures de Circonférence", + "girth_goals": "Objectifs de Circonférence" + } + } +} diff --git a/custom_components/renpho/translations/ja.json b/custom_components/renpho/translations/ja.json new file mode 100644 index 0000000..eedddd3 --- /dev/null +++ b/custom_components/renpho/translations/ja.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "古いRenphoアカウントのメールアドレスを入力してください。", + "password": "Renphoアカウントのパスワードを入力してください。", + "refresh": "データ更新レートを秒単位で設定します。デフォルトは60秒です。", + "unit_of_measurement": "体重の単位を選択してください。オプションはキログラム(kg)またはポンド(lbs)です。", + "proxy": "オプション:使用するプロキシサーバーを指定します(例:'http://127.0.0.1:8080')。" + } + } + }, + "error": { + "cannot_connect": "サービスに接続できません", + "invalid_auth": "認証が無効です", + "unknown": "不明なエラーが発生しました" + }, + "abort": { + "already_configured": "デバイスは既に設定されています" + } + } +} diff --git a/custom_components/renpho/translations/pt.json b/custom_components/renpho/translations/pt.json new file mode 100644 index 0000000..00f592c --- /dev/null +++ b/custom_components/renpho/translations/pt.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Digite o e-mail da sua conta Renpho desatualizada.", + "password": "Digite a senha da sua conta Renpho.", + "refresh": "Defina a taxa de atualização de dados em segundos. O padrão é 60 segundos.", + "unit_of_measurement": "Escolha a unidade de medida para o peso. As opções são quilogramas (kg) ou libras (lbs).", + "proxy": "Opcional: Especifique um servidor proxy para usar (exemplo: 'http://127.0.0.1:8080')." + } + } + }, + "error": { + "cannot_connect": "Não é possível conectar ao serviço", + "invalid_auth": "Autenticação inválida", + "unknown": "Ocorreu um erro desconhecido" + }, + "abort": { + "already_configured": "Dispositivo já está configurado" + } + } +} diff --git a/custom_components/renpho/translations/ru.json b/custom_components/renpho/translations/ru.json new file mode 100644 index 0000000..ebb23ee --- /dev/null +++ b/custom_components/renpho/translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Введите адрес электронной почты вашей устаревшей учетной записи Renpho.", + "password": "Введите пароль от вашей учетной записи Renpho.", + "refresh": "Установите частоту обновления данных в секундах. По умолчанию 60 секунд.", + "unit_of_measurement": "Выберите единицу измерения веса. Опции: килограммы (кг) или фунты (lbs).", + "proxy": "Необязательно: укажите прокси-сервер для использования (например, 'http://127.0.0.1:8080')." + } + } + }, + "error": { + "cannot_connect": "Невозможно подключиться к сервису", + "invalid_auth": "Неверная аутентификация", + "unknown": "Произошла неизвестная ошибка" + }, + "abort": { + "already_configured": "Устройство уже настроено" + } + } +} diff --git a/custom_components/renpho/translations/zh.json b/custom_components/renpho/translations/zh.json new file mode 100644 index 0000000..41f6c51 --- /dev/null +++ b/custom_components/renpho/translations/zh.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "输入您的Renpho过时账户电子邮件地址。", + "password": "输入您的Renpho账户密码。", + "refresh": "设置数据刷新率,单位为秒。默认值为60秒。", + "unit_of_measurement": "选择重量的计量单位。选项为千克(kg)或磅(lbs)。", + "proxy": "可选:指定要使用的代理服务器(例如,'http://127.0.0.1:8080')。" + } + } + }, + "error": { + "cannot_connect": "无法连接到服务", + "invalid_auth": "认证无效", + "unknown": "发生未知错误" + }, + "abort": { + "already_configured": "设备已配置" + } + } +}