renpho-py 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- renpho/__init__.py +24 -0
- renpho/cli.py +111 -0
- renpho/client.py +398 -0
- renpho/constants.py +52 -0
- renpho/crypto.py +54 -0
- renpho/export.py +95 -0
- renpho/py.typed +0 -0
- renpho_py-1.0.0.dist-info/METADATA +234 -0
- renpho_py-1.0.0.dist-info/RECORD +14 -0
- renpho_py-1.0.0.dist-info/WHEEL +5 -0
- renpho_py-1.0.0.dist-info/entry_points.txt +2 -0
- renpho_py-1.0.0.dist-info/licenses/LICENSE +22 -0
- renpho_py-1.0.0.dist-info/licenses/NOTICE +16 -0
- renpho_py-1.0.0.dist-info/top_level.txt +1 -0
renpho/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""renpho - Unofficial Python client for the Renpho Health API.
|
|
2
|
+
|
|
3
|
+
Pull body composition measurements from Renpho smart scales.
|
|
4
|
+
|
|
5
|
+
Quick start::
|
|
6
|
+
|
|
7
|
+
from renpho import RenphoClient
|
|
8
|
+
|
|
9
|
+
client = RenphoClient("user@example.com", "password")
|
|
10
|
+
client.login()
|
|
11
|
+
measurements = client.get_all_measurements()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .client import RenphoAPIError, RenphoClient
|
|
15
|
+
from .export import format_measurement, format_timestamp, save_csv, save_json
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"RenphoClient",
|
|
19
|
+
"RenphoAPIError",
|
|
20
|
+
"format_measurement",
|
|
21
|
+
"format_timestamp",
|
|
22
|
+
"save_csv",
|
|
23
|
+
"save_json",
|
|
24
|
+
]
|
renpho/cli.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Command-line interface for pulling Renpho scale data."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
load_dotenv()
|
|
11
|
+
except ImportError:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
from .client import RenphoClient
|
|
15
|
+
from .export import format_measurement, save_csv, save_json
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv: list[str] | None = None) -> None:
|
|
19
|
+
"""Entry point for the ``renpho`` CLI command."""
|
|
20
|
+
email = os.getenv("RENPHO_EMAIL", "")
|
|
21
|
+
password = os.getenv("RENPHO_PASSWORD", "")
|
|
22
|
+
debug = os.getenv("RENPHO_DEBUG", "").lower() in ("1", "true", "yes")
|
|
23
|
+
output_dir = Path(os.getenv("RENPHO_OUTPUT_DIR", "renpho_data"))
|
|
24
|
+
|
|
25
|
+
if not email or not password:
|
|
26
|
+
print("Missing credentials!\n")
|
|
27
|
+
print("Create a .env file with:")
|
|
28
|
+
print(" RENPHO_EMAIL=your@email.com")
|
|
29
|
+
print(" RENPHO_PASSWORD=your_plain_text_password")
|
|
30
|
+
print(" RENPHO_DEBUG=1 # optional")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
client = RenphoClient(email, password, debug=debug)
|
|
34
|
+
|
|
35
|
+
# Step 1: Login
|
|
36
|
+
print(f"Logging in as {email}...")
|
|
37
|
+
client.login()
|
|
38
|
+
print(f"Logged in! User ID: {client.user_id}")
|
|
39
|
+
|
|
40
|
+
# Step 2: Device info
|
|
41
|
+
print("Getting device info...")
|
|
42
|
+
device_info = client.get_device_info()
|
|
43
|
+
|
|
44
|
+
if not device_info:
|
|
45
|
+
print("Could not get device info")
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
save_json(device_info, output_dir / "device_info.json")
|
|
49
|
+
|
|
50
|
+
scales = device_info.get("scale", [])
|
|
51
|
+
if not scales:
|
|
52
|
+
print("No scales found in device info")
|
|
53
|
+
print(f" Device info keys: {list(device_info.keys())}")
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
print(f"\nFound {len(scales)} scale table(s):")
|
|
57
|
+
for i, scale in enumerate(scales):
|
|
58
|
+
table = scale.get("tableName", "unknown")
|
|
59
|
+
count = scale.get("count", 0)
|
|
60
|
+
uids = scale.get("userIds", [])
|
|
61
|
+
print(f" [{i}] table={table}, records={count}, users={uids}")
|
|
62
|
+
|
|
63
|
+
# Step 3: Fetch measurements
|
|
64
|
+
all_measurements: list[dict] = []
|
|
65
|
+
for scale in scales:
|
|
66
|
+
table_name = scale.get("tableName")
|
|
67
|
+
count = scale.get("count", 0)
|
|
68
|
+
user_ids = scale.get("userIds", [])
|
|
69
|
+
|
|
70
|
+
if not table_name or count == 0:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
uid = client.user_id
|
|
74
|
+
if user_ids and uid not in user_ids:
|
|
75
|
+
uid = user_ids[0]
|
|
76
|
+
|
|
77
|
+
print(f"Fetching measurements (table: {table_name}, total: {count})...")
|
|
78
|
+
measurements = client.get_measurements(table_name, uid, count)
|
|
79
|
+
all_measurements.extend(measurements)
|
|
80
|
+
|
|
81
|
+
if not all_measurements:
|
|
82
|
+
print("\nNo measurements found.")
|
|
83
|
+
print(" Try setting RENPHO_DEBUG=1 to see API responses.")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# Sort newest first
|
|
87
|
+
all_measurements.sort(
|
|
88
|
+
key=lambda m: m.get("timeStamp", 0) or 0,
|
|
89
|
+
reverse=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
print(f"\nTotal: {len(all_measurements)} measurement(s)")
|
|
93
|
+
|
|
94
|
+
# Display most recent 5
|
|
95
|
+
for i, m in enumerate(all_measurements[:5]):
|
|
96
|
+
print(f"\n{'=' * 55}")
|
|
97
|
+
print(f" Measurement #{i + 1}")
|
|
98
|
+
print(f"{'=' * 55}")
|
|
99
|
+
print(format_measurement(m))
|
|
100
|
+
|
|
101
|
+
if len(all_measurements) > 5:
|
|
102
|
+
print(f"\n ... and {len(all_measurements) - 5} more")
|
|
103
|
+
|
|
104
|
+
# Save data
|
|
105
|
+
save_json(all_measurements, output_dir / "measurements.json")
|
|
106
|
+
save_csv(all_measurements, output_dir / "measurements.csv")
|
|
107
|
+
|
|
108
|
+
if client.user_info:
|
|
109
|
+
save_json(client.user_info, output_dir / "user_profile.json")
|
|
110
|
+
|
|
111
|
+
print(f"\nDone! Data saved to '{output_dir}/' folder.")
|
renpho/client.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Renpho API client for fetching scale measurements."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .constants import (
|
|
9
|
+
API_BASE_URL,
|
|
10
|
+
APP_VERSION,
|
|
11
|
+
BODY_WEIGHT_SCALES,
|
|
12
|
+
ENDPOINTS,
|
|
13
|
+
MEASUREMENT_TABLE_NAMES,
|
|
14
|
+
PLATFORM,
|
|
15
|
+
SUCCESS_CODES,
|
|
16
|
+
)
|
|
17
|
+
from .crypto import (
|
|
18
|
+
decrypt_response,
|
|
19
|
+
encrypt_empty_bytes,
|
|
20
|
+
encrypt_empty_object,
|
|
21
|
+
encrypt_request,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RenphoAPIError(Exception):
|
|
26
|
+
"""Raised when the Renpho API returns an error response."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, context: str, code, msg: str):
|
|
29
|
+
self.context = context
|
|
30
|
+
self.code = code
|
|
31
|
+
self.msg = msg
|
|
32
|
+
super().__init__(f"{context} failed: code={code}, msg={msg}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_response(result: dict, context: str = "API call") -> None:
|
|
36
|
+
"""Raise :class:`RenphoAPIError` if the response indicates failure."""
|
|
37
|
+
code = result.get("code")
|
|
38
|
+
msg = result.get("msg", "")
|
|
39
|
+
if msg.lower() == "success" or code in SUCCESS_CODES:
|
|
40
|
+
return
|
|
41
|
+
raise RenphoAPIError(context, code, msg)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RenphoClient:
|
|
45
|
+
"""Client for the Renpho cloud API.
|
|
46
|
+
|
|
47
|
+
Example::
|
|
48
|
+
|
|
49
|
+
client = RenphoClient("user@example.com", "password")
|
|
50
|
+
client.login()
|
|
51
|
+
measurements = client.get_all_measurements()
|
|
52
|
+
for m in measurements:
|
|
53
|
+
print(m["weight"], m.get("bodyfat"))
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, email: str, password: str, *, debug: bool = False):
|
|
57
|
+
self.email = email
|
|
58
|
+
self.password = password
|
|
59
|
+
self.debug = debug
|
|
60
|
+
self.token: str | None = None
|
|
61
|
+
self.user_id: int | str | None = None
|
|
62
|
+
self.user_info: dict | None = None
|
|
63
|
+
self._session = requests.Session()
|
|
64
|
+
|
|
65
|
+
# ----- internal helpers -----
|
|
66
|
+
|
|
67
|
+
def _post(self, endpoint: str, body: dict, *, auth: bool = True) -> dict:
|
|
68
|
+
"""Make an encrypted POST request to the Renpho API."""
|
|
69
|
+
url = f"{API_BASE_URL}/{endpoint}"
|
|
70
|
+
headers: dict[str, str] = {}
|
|
71
|
+
if auth and self.token:
|
|
72
|
+
headers["token"] = self.token
|
|
73
|
+
headers["userId"] = str(self.user_id)
|
|
74
|
+
headers["appVersion"] = APP_VERSION
|
|
75
|
+
headers["platform"] = PLATFORM
|
|
76
|
+
|
|
77
|
+
if self.debug:
|
|
78
|
+
print(f" POST {url}")
|
|
79
|
+
if auth and self.token:
|
|
80
|
+
print(f" Headers: token={self.token[:20]}..., userId={self.user_id}")
|
|
81
|
+
|
|
82
|
+
resp = self._session.post(url, json=body, headers=headers)
|
|
83
|
+
|
|
84
|
+
if self.debug:
|
|
85
|
+
print(f" Status: {resp.status_code}")
|
|
86
|
+
print(f" Response: {resp.text[:300]}")
|
|
87
|
+
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
return resp.json()
|
|
90
|
+
|
|
91
|
+
# ----- public API -----
|
|
92
|
+
|
|
93
|
+
def login(self) -> dict:
|
|
94
|
+
"""Authenticate and store the session token.
|
|
95
|
+
|
|
96
|
+
Returns the full decrypted login response (includes user profile).
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
RenphoAPIError: If the API rejects the credentials.
|
|
100
|
+
requests.HTTPError: On transport-level failures.
|
|
101
|
+
"""
|
|
102
|
+
login_payload = {
|
|
103
|
+
"questionnaire": {},
|
|
104
|
+
"login": {
|
|
105
|
+
"password": self.password,
|
|
106
|
+
"areaCode": "US",
|
|
107
|
+
"appRevision": APP_VERSION,
|
|
108
|
+
"cellphoneType": "PythonScript",
|
|
109
|
+
"systemType": "11",
|
|
110
|
+
"email": self.email,
|
|
111
|
+
"platform": PLATFORM,
|
|
112
|
+
},
|
|
113
|
+
"bindingList": {
|
|
114
|
+
"deviceTypes": BODY_WEIGHT_SCALES,
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
encrypted_body = encrypt_request(login_payload)
|
|
119
|
+
result = self._post(ENDPOINTS["login"], encrypted_body, auth=False)
|
|
120
|
+
_check_response(result, "Login")
|
|
121
|
+
|
|
122
|
+
user_data = decrypt_response(result["data"])
|
|
123
|
+
|
|
124
|
+
if self.debug:
|
|
125
|
+
print(f" Decrypted login: {json.dumps(user_data, indent=2)[:500]}")
|
|
126
|
+
|
|
127
|
+
login_info = user_data.get("login", {})
|
|
128
|
+
self.token = login_info.get("token")
|
|
129
|
+
self.user_id = login_info.get("id")
|
|
130
|
+
self.user_info = login_info
|
|
131
|
+
|
|
132
|
+
if not self.token:
|
|
133
|
+
raise RenphoAPIError("Login", None, "No token in login response")
|
|
134
|
+
|
|
135
|
+
return user_data
|
|
136
|
+
|
|
137
|
+
def get_device_info(self) -> dict:
|
|
138
|
+
"""Get device info including scale table names and record counts.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Decrypted device info dict (contains ``scale`` list among others).
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
RenphoAPIError: On API-level failure.
|
|
145
|
+
"""
|
|
146
|
+
for attempt, body_fn in enumerate([encrypt_empty_bytes, encrypt_empty_object]):
|
|
147
|
+
encrypted_body = body_fn()
|
|
148
|
+
try:
|
|
149
|
+
result = self._post(ENDPOINTS["device_info"], encrypted_body)
|
|
150
|
+
break
|
|
151
|
+
except requests.exceptions.HTTPError as e:
|
|
152
|
+
if attempt == 0:
|
|
153
|
+
if self.debug:
|
|
154
|
+
print(
|
|
155
|
+
f" Attempt 1 failed ({e}), retrying with empty object..."
|
|
156
|
+
)
|
|
157
|
+
continue
|
|
158
|
+
raise
|
|
159
|
+
|
|
160
|
+
_check_response(result, "GetDeviceInfo")
|
|
161
|
+
data = decrypt_response(result["data"])
|
|
162
|
+
|
|
163
|
+
if self.debug:
|
|
164
|
+
print(f" Device info: {json.dumps(data, indent=2)[:500]}")
|
|
165
|
+
|
|
166
|
+
return data
|
|
167
|
+
|
|
168
|
+
def get_measurements(
|
|
169
|
+
self, table_name: str, user_id, total_count: int, *, page_size: int = 50
|
|
170
|
+
) -> list[dict]:
|
|
171
|
+
"""Fetch measurements from a specific scale table with pagination.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
table_name: Dynamic table name from :meth:`get_device_info`.
|
|
175
|
+
user_id: The user ID to query for.
|
|
176
|
+
total_count: Total records available (from device info).
|
|
177
|
+
page_size: Records per page (default 50).
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
List of measurement dicts.
|
|
181
|
+
"""
|
|
182
|
+
all_measurements: list[dict] = []
|
|
183
|
+
page = 1
|
|
184
|
+
|
|
185
|
+
while len(all_measurements) < total_count:
|
|
186
|
+
request_data = {
|
|
187
|
+
"pageNum": page,
|
|
188
|
+
"pageSize": page_size,
|
|
189
|
+
"userIds": [str(user_id)],
|
|
190
|
+
"tableName": table_name,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if self.debug:
|
|
194
|
+
print(f" Page {page} (got {len(all_measurements)} so far)...")
|
|
195
|
+
|
|
196
|
+
encrypted_body = encrypt_request(request_data)
|
|
197
|
+
result = self._post(ENDPOINTS["measurements"], encrypted_body)
|
|
198
|
+
_check_response(result, f"Measurements page {page}")
|
|
199
|
+
|
|
200
|
+
if not result.get("data"):
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
page_data = decrypt_response(result["data"])
|
|
204
|
+
|
|
205
|
+
if self.debug:
|
|
206
|
+
if isinstance(page_data, list):
|
|
207
|
+
print(f" Got {len(page_data)} records")
|
|
208
|
+
else:
|
|
209
|
+
print(f" Response type: {type(page_data)}")
|
|
210
|
+
|
|
211
|
+
records = self._extract_records(page_data)
|
|
212
|
+
if records is None:
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
all_measurements.extend(records)
|
|
216
|
+
page += 1
|
|
217
|
+
|
|
218
|
+
return all_measurements
|
|
219
|
+
|
|
220
|
+
def get_body_composition_measurements(
|
|
221
|
+
self, table_name: str, user_id, *, page_size: int = 50
|
|
222
|
+
) -> list[dict]:
|
|
223
|
+
"""Fetch body composition measurements using the newer API endpoint.
|
|
224
|
+
|
|
225
|
+
Body composition scales (those with impedance sensors) store data under
|
|
226
|
+
``queryBodyCompositionMeasureData`` rather than ``queryAllMeasureDataList``.
|
|
227
|
+
The server-side count in device info is often reported as 0 for these
|
|
228
|
+
scales even when data exists, so this method paginates until the server
|
|
229
|
+
returns an empty page rather than relying on a total count.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
table_name: Dynamic table name from :meth:`get_device_info`.
|
|
233
|
+
user_id: The user ID to query for.
|
|
234
|
+
page_size: Records per page (default 50).
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
List of measurement dicts.
|
|
238
|
+
"""
|
|
239
|
+
all_measurements: list[dict] = []
|
|
240
|
+
page = 1
|
|
241
|
+
|
|
242
|
+
while True:
|
|
243
|
+
request_data = {
|
|
244
|
+
"pageNum": page,
|
|
245
|
+
"pageSize": page_size,
|
|
246
|
+
"userIds": [str(user_id)],
|
|
247
|
+
"tableName": table_name,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if self.debug:
|
|
251
|
+
print(f" Page {page} (got {len(all_measurements)} so far)...")
|
|
252
|
+
|
|
253
|
+
encrypted_body = encrypt_request(request_data)
|
|
254
|
+
result = self._post(
|
|
255
|
+
ENDPOINTS["body_composition_measurements"], encrypted_body
|
|
256
|
+
)
|
|
257
|
+
_check_response(result, f"BodyCompositionMeasurements page {page}")
|
|
258
|
+
|
|
259
|
+
if not result.get("data"):
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
page_data = decrypt_response(result["data"])
|
|
263
|
+
|
|
264
|
+
if self.debug:
|
|
265
|
+
if isinstance(page_data, list):
|
|
266
|
+
print(f" Got {len(page_data)} records")
|
|
267
|
+
else:
|
|
268
|
+
print(f" Response type: {type(page_data)}")
|
|
269
|
+
|
|
270
|
+
records = self._extract_records(page_data)
|
|
271
|
+
if not records:
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
all_measurements.extend(records)
|
|
275
|
+
if len(records) < page_size:
|
|
276
|
+
break
|
|
277
|
+
page += 1
|
|
278
|
+
|
|
279
|
+
return all_measurements
|
|
280
|
+
|
|
281
|
+
def discover_user_tables(self, user_id) -> list[str]:
|
|
282
|
+
"""Probe all measurement tables for a given user_id and return the ones with data.
|
|
283
|
+
|
|
284
|
+
Body composition scales shard measurements across 16 tables
|
|
285
|
+
(``measurements_info_0`` through ``measurements_info_F``). The server
|
|
286
|
+
only reports the table for the logged-in user via ``device/count``,
|
|
287
|
+
so for any other linked account this method probes each suffix.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
user_id: The user ID to probe for.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of table names that contain at least one record for ``user_id``.
|
|
294
|
+
"""
|
|
295
|
+
found: list[str] = []
|
|
296
|
+
for table in MEASUREMENT_TABLE_NAMES:
|
|
297
|
+
encrypted_body = encrypt_request({
|
|
298
|
+
"pageNum": 1,
|
|
299
|
+
"pageSize": 1,
|
|
300
|
+
"userIds": [str(user_id)],
|
|
301
|
+
"tableName": table,
|
|
302
|
+
})
|
|
303
|
+
result = self._post(ENDPOINTS["body_composition_measurements"], encrypted_body)
|
|
304
|
+
if not result.get("data"):
|
|
305
|
+
continue
|
|
306
|
+
page_data = decrypt_response(result["data"])
|
|
307
|
+
records = self._extract_records(page_data)
|
|
308
|
+
if records:
|
|
309
|
+
found.append(table)
|
|
310
|
+
return found
|
|
311
|
+
|
|
312
|
+
def get_all_measurements(self, extra_user_ids: list | None = None) -> list[dict]:
|
|
313
|
+
"""High-level helper: fetch device info then pull all measurements.
|
|
314
|
+
|
|
315
|
+
Tries the body composition endpoint first (used by impedance scales).
|
|
316
|
+
Falls back to the basic measurements endpoint for weight-only scales.
|
|
317
|
+
The server-side count in device info is unreliable for body composition
|
|
318
|
+
scales (often reports 0), so this method always attempts a fetch.
|
|
319
|
+
|
|
320
|
+
Calls :meth:`login` first if no token is set.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
extra_user_ids: Additional user IDs to fetch measurements for. The
|
|
324
|
+
Renpho API allows a logged-in user to read measurements belonging
|
|
325
|
+
to other linked accounts (e.g. a separate account from before a
|
|
326
|
+
Google SSO migration). Each id is probed against all known
|
|
327
|
+
measurement tables. Pass these when you have multiple Renpho
|
|
328
|
+
accounts associated with the same physical scale.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
List of measurement dicts sorted by timestamp (newest first),
|
|
332
|
+
deduped by record ``id``.
|
|
333
|
+
"""
|
|
334
|
+
if not self.token:
|
|
335
|
+
self.login()
|
|
336
|
+
|
|
337
|
+
device_info = self.get_device_info()
|
|
338
|
+
scales = device_info.get("scale", [])
|
|
339
|
+
|
|
340
|
+
all_measurements: list[dict] = []
|
|
341
|
+
for scale in scales:
|
|
342
|
+
table_name = scale.get("tableName")
|
|
343
|
+
count = scale.get("count", 0)
|
|
344
|
+
user_ids = scale.get("userIds", [])
|
|
345
|
+
|
|
346
|
+
if not table_name:
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
uid = self.user_id
|
|
350
|
+
if user_ids and uid not in user_ids:
|
|
351
|
+
uid = user_ids[0]
|
|
352
|
+
|
|
353
|
+
# Try body composition endpoint first; it handles both newer
|
|
354
|
+
# impedance scales and cases where count is incorrectly zero.
|
|
355
|
+
measurements = self.get_body_composition_measurements(table_name, uid)
|
|
356
|
+
if not measurements and count > 0:
|
|
357
|
+
measurements = self.get_measurements(table_name, uid, count)
|
|
358
|
+
|
|
359
|
+
all_measurements.extend(measurements)
|
|
360
|
+
|
|
361
|
+
for extra_uid in extra_user_ids or []:
|
|
362
|
+
for table in self.discover_user_tables(extra_uid):
|
|
363
|
+
all_measurements.extend(
|
|
364
|
+
self.get_body_composition_measurements(table, extra_uid)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# Dedupe by record id (each measurement is a unique server-side row).
|
|
368
|
+
seen_ids: set = set()
|
|
369
|
+
unique: list[dict] = []
|
|
370
|
+
for m in all_measurements:
|
|
371
|
+
rid = m.get("id")
|
|
372
|
+
if rid is not None and rid in seen_ids:
|
|
373
|
+
continue
|
|
374
|
+
if rid is not None:
|
|
375
|
+
seen_ids.add(rid)
|
|
376
|
+
unique.append(m)
|
|
377
|
+
|
|
378
|
+
unique.sort(
|
|
379
|
+
key=lambda m: m.get("timeStamp", 0) or 0,
|
|
380
|
+
reverse=True,
|
|
381
|
+
)
|
|
382
|
+
return unique
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def _extract_records(page_data) -> list[dict] | None:
|
|
386
|
+
"""Extract measurement records from a page response."""
|
|
387
|
+
if isinstance(page_data, list):
|
|
388
|
+
return page_data if page_data else None
|
|
389
|
+
|
|
390
|
+
if isinstance(page_data, dict):
|
|
391
|
+
for key in ("list", "data", "records", "measurements"):
|
|
392
|
+
if key in page_data and isinstance(page_data[key], list):
|
|
393
|
+
return page_data[key] if page_data[key] else None
|
|
394
|
+
|
|
395
|
+
if "weight" in page_data:
|
|
396
|
+
return [page_data]
|
|
397
|
+
|
|
398
|
+
return None
|
renpho/constants.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Constants and configuration for the Renpho API."""
|
|
2
|
+
|
|
3
|
+
# API connection
|
|
4
|
+
API_BASE_URL = "https://cloud.renpho.com"
|
|
5
|
+
ENCRYPTION_KEY = "ed*wijdi$h6fe3ew" # 16-byte AES-128 key
|
|
6
|
+
APP_VERSION = "6.6.0"
|
|
7
|
+
PLATFORM = "android"
|
|
8
|
+
|
|
9
|
+
# API endpoints (from RenphoApiEndpoints.cs)
|
|
10
|
+
ENDPOINTS = {
|
|
11
|
+
"login": "renpho-aggregation/user/login",
|
|
12
|
+
"token_time": "RenphoHealth/app/sync/getTokenTime",
|
|
13
|
+
"device_info": "renpho-aggregation/device/count",
|
|
14
|
+
"family": "RenphoHealth/centerUser/queryFamilyMemberList",
|
|
15
|
+
"measurements": "RenphoHealth/scale/queryAllMeasureDataList",
|
|
16
|
+
"body_composition_measurements": "RenphoHealth/scale/queryBodyCompositionMeasureData",
|
|
17
|
+
"body_composition_scale_count": "RenphoHealth/scale/bodyCompositionScaleCount",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Body composition scales shard measurements across 16 tables. Server-side
|
|
21
|
+
# discovery only reports the table for the logged-in user, so the only way
|
|
22
|
+
# to find data belonging to other linked accounts is to probe each suffix.
|
|
23
|
+
MEASUREMENT_TABLE_NAMES = [f"measurements_info_{i:X}" for i in range(16)]
|
|
24
|
+
|
|
25
|
+
# Body weight scale device types
|
|
26
|
+
BODY_WEIGHT_SCALES = [
|
|
27
|
+
"01", "02", "03", "04", "05", "06", "07", "08", "09", "0A",
|
|
28
|
+
"0B", "0C", "0D", "0E", "0F", "10", "11", "12", "13", "14",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Measurement display metadata: (api_key, label, unit)
|
|
32
|
+
METRICS = [
|
|
33
|
+
("weight", "Weight", "kg"),
|
|
34
|
+
("bmi", "BMI", ""),
|
|
35
|
+
("bodyfat", "Body Fat", "%"),
|
|
36
|
+
("water", "Body Water", "%"),
|
|
37
|
+
("muscle", "Muscle Mass", "%"),
|
|
38
|
+
("bone", "Bone Mass", "%"),
|
|
39
|
+
("bmr", "BMR", "kcal/day"),
|
|
40
|
+
("visfat", "Visceral Fat", "level"),
|
|
41
|
+
("subfat", "Subcutaneous Fat", "%"),
|
|
42
|
+
("protein", "Protein", "%"),
|
|
43
|
+
("bodyage", "Body Age", "years"),
|
|
44
|
+
("sinew", "Lean Body Mass", "kg"),
|
|
45
|
+
("fatFreeWeight", "Fat Free Weight", "kg"),
|
|
46
|
+
("heartRate", "Heart Rate", "bpm"),
|
|
47
|
+
("cardiacIndex", "Cardiac Index", ""),
|
|
48
|
+
("bodyShape", "Body Shape", ""),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# Success codes returned by the API
|
|
52
|
+
SUCCESS_CODES = {0, "0", 101, "101", 200, "200", 20000, "20000"}
|
renpho/crypto.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""AES-128-ECB encryption utilities for the Renpho API.
|
|
2
|
+
|
|
3
|
+
The Renpho cloud API encrypts all request/response payloads using
|
|
4
|
+
AES-128-ECB with PKCS7 padding, base64-encoded for transport.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from Crypto.Cipher import AES
|
|
11
|
+
from Crypto.Util.Padding import pad, unpad
|
|
12
|
+
|
|
13
|
+
from .constants import ENCRYPTION_KEY
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def aes_encrypt(plaintext: str, key: str = ENCRYPTION_KEY) -> str:
|
|
17
|
+
"""Encrypt a string with AES-128-ECB + PKCS7 padding, return base64."""
|
|
18
|
+
cipher = AES.new(key.encode("utf-8"), AES.MODE_ECB)
|
|
19
|
+
padded = pad(plaintext.encode("utf-8"), AES.block_size)
|
|
20
|
+
encrypted = cipher.encrypt(padded)
|
|
21
|
+
return base64.b64encode(encrypted).decode("utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def aes_decrypt(encrypted_b64: str, key: str = ENCRYPTION_KEY) -> str:
|
|
25
|
+
"""Decrypt a base64 AES-128-ECB + PKCS7 string."""
|
|
26
|
+
cipher = AES.new(key.encode("utf-8"), AES.MODE_ECB)
|
|
27
|
+
encrypted = base64.b64decode(encrypted_b64)
|
|
28
|
+
decrypted = unpad(cipher.decrypt(encrypted), AES.block_size)
|
|
29
|
+
return decrypted.decode("utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def encrypt_request(obj: dict, key: str = ENCRYPTION_KEY) -> dict:
|
|
33
|
+
"""Encrypt a request payload into ``{"encryptData": "..."}`` format."""
|
|
34
|
+
serialized = json.dumps(obj, separators=(",", ":"))
|
|
35
|
+
return {"encryptData": aes_encrypt(serialized, key)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def encrypt_empty_object(key: str = ENCRYPTION_KEY) -> dict:
|
|
39
|
+
"""Encrypt an empty JSON object ``{}``."""
|
|
40
|
+
return encrypt_request({}, key)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def encrypt_empty_bytes(key: str = ENCRYPTION_KEY) -> dict:
|
|
44
|
+
"""Encrypt an empty byte array (used by some endpoints)."""
|
|
45
|
+
cipher = AES.new(key.encode("utf-8"), AES.MODE_ECB)
|
|
46
|
+
padded = pad(b"", AES.block_size)
|
|
47
|
+
encrypted = cipher.encrypt(padded)
|
|
48
|
+
return {"encryptData": base64.b64encode(encrypted).decode("utf-8")}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decrypt_response(encrypted_data: str, key: str = ENCRYPTION_KEY):
|
|
52
|
+
"""Decrypt the ``data`` field from an API response and parse as JSON."""
|
|
53
|
+
decrypted = aes_decrypt(encrypted_data, key)
|
|
54
|
+
return json.loads(decrypted)
|
renpho/export.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Helpers for formatting, displaying, and exporting Renpho measurements."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .constants import METRICS
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_timestamp(ts) -> str:
|
|
11
|
+
"""Convert a Renpho timestamp (seconds or milliseconds) to a readable string."""
|
|
12
|
+
if ts is None:
|
|
13
|
+
return "unknown"
|
|
14
|
+
ts = int(ts)
|
|
15
|
+
if ts > 1e12:
|
|
16
|
+
ts = ts // 1000
|
|
17
|
+
return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def format_measurement(m: dict) -> str:
|
|
21
|
+
"""Return a human-readable string for a single measurement."""
|
|
22
|
+
ts = m.get("timeStamp") or m.get("time_stamp")
|
|
23
|
+
local = m.get("localCreatedAt", "")
|
|
24
|
+
scale = m.get("scaleName", "")
|
|
25
|
+
|
|
26
|
+
lines = [f" Date: {format_timestamp(ts)}"]
|
|
27
|
+
if local:
|
|
28
|
+
lines.append(f" Local time: {local}")
|
|
29
|
+
if scale:
|
|
30
|
+
lines.append(f" Scale: {scale}")
|
|
31
|
+
|
|
32
|
+
for key, label, unit in METRICS:
|
|
33
|
+
value = m.get(key)
|
|
34
|
+
if value is not None and value != 0 and value != 0.0:
|
|
35
|
+
unit_str = f" {unit}" if unit else ""
|
|
36
|
+
lines.append(f" {label:<22} {value}{unit_str}")
|
|
37
|
+
|
|
38
|
+
return "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_json(data, filepath: str | Path) -> Path:
|
|
42
|
+
"""Write *data* as pretty-printed JSON.
|
|
43
|
+
|
|
44
|
+
Parent directories are created automatically.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The resolved :class:`~pathlib.Path` that was written.
|
|
48
|
+
"""
|
|
49
|
+
filepath = Path(filepath)
|
|
50
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
with open(filepath, "w") as f:
|
|
52
|
+
json.dump(data, f, indent=2, default=str)
|
|
53
|
+
return filepath
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def save_csv(measurements: list[dict], filepath: str | Path) -> Path | None:
|
|
57
|
+
"""Write measurements to a CSV file.
|
|
58
|
+
|
|
59
|
+
Columns are ordered with the most useful metrics first, followed by any
|
|
60
|
+
remaining keys in alphabetical order.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The resolved :class:`~pathlib.Path` that was written, or ``None`` if
|
|
64
|
+
*measurements* is empty.
|
|
65
|
+
"""
|
|
66
|
+
if not measurements:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
filepath = Path(filepath)
|
|
70
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
priority = [
|
|
73
|
+
"timeStamp", "localCreatedAt",
|
|
74
|
+
*(key for key, _, _ in METRICS),
|
|
75
|
+
"scaleName", "height", "gender",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
all_keys: set[str] = set()
|
|
79
|
+
for m in measurements:
|
|
80
|
+
all_keys.update(m.keys())
|
|
81
|
+
|
|
82
|
+
columns = [k for k in priority if k in all_keys]
|
|
83
|
+
columns += sorted(k for k in all_keys if k not in columns)
|
|
84
|
+
|
|
85
|
+
with open(filepath, "w") as f:
|
|
86
|
+
f.write(",".join(columns) + "\n")
|
|
87
|
+
for m in measurements:
|
|
88
|
+
row = []
|
|
89
|
+
for k in columns:
|
|
90
|
+
val = m.get(k, "")
|
|
91
|
+
val_str = str(val).replace(",", ";") if val is not None else ""
|
|
92
|
+
row.append(val_str)
|
|
93
|
+
f.write(",".join(row) + "\n")
|
|
94
|
+
|
|
95
|
+
return filepath
|
renpho/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: renpho-py
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Unofficial Renpho Health API client for Python — pull body composition data from Renpho smart scales.
|
|
5
|
+
Author: ChocoTonic
|
|
6
|
+
Maintainer: ChocoTonic
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/ChocoTonic/renpho-py
|
|
9
|
+
Project-URL: Repository, https://github.com/ChocoTonic/renpho-py
|
|
10
|
+
Project-URL: Issues, https://github.com/ChocoTonic/renpho-py/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/ChocoTonic/renpho-py/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: renpho,renpho-api,health,body-composition,smart-scale,api-client,python
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
License-File: NOTICE
|
|
26
|
+
Requires-Dist: requests>=2.28
|
|
27
|
+
Requires-Dist: pycryptodome>=3.15
|
|
28
|
+
Provides-Extra: dotenv
|
|
29
|
+
Requires-Dist: python-dotenv>=1.0; extra == "dotenv"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# renpho-py — Renpho Health API client for Python
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/renpho-py/)
|
|
37
|
+
[](https://github.com/ChocoTonic/renpho-py/actions/workflows/ci.yml)
|
|
38
|
+
[](https://pypi.org/project/renpho-py/)
|
|
39
|
+
|
|
40
|
+
Unofficial **Renpho Health API** client for **Python**. Pull body composition
|
|
41
|
+
measurements from Renpho smart scales programmatically.
|
|
42
|
+
|
|
43
|
+
> **Unofficial.** Not affiliated with, endorsed by, or supported by Renpho. Use
|
|
44
|
+
> at your own risk and in line with Renpho's terms of service.
|
|
45
|
+
|
|
46
|
+
`renpho-py` is an independently maintained continuation of the abandoned
|
|
47
|
+
[`renpho-api`](https://github.com/danvaneijck/renpho-api) (MIT). The import name
|
|
48
|
+
is unchanged, so migrating is a one-line swap — `pip install renpho-py` and your
|
|
49
|
+
existing `from renpho import ...` code keeps working. The underlying API was
|
|
50
|
+
reverse-engineered; protocol details are based on
|
|
51
|
+
[RenphoGarminSync-CLI](https://github.com/forkerer/RenphoGarminSync-CLI).
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install renpho-py
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For `.env` file support (recommended for CLI usage):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install "renpho-py[dotenv]"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
> Migrating from `renpho-api`? `pip uninstall renpho-api && pip install renpho-py`.
|
|
66
|
+
> No code changes — you still `from renpho import RenphoClient`.
|
|
67
|
+
|
|
68
|
+
## CLI Usage
|
|
69
|
+
|
|
70
|
+
1. Create a `.env` file (or export the variables):
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
RENPHO_EMAIL=your@email.com
|
|
74
|
+
RENPHO_PASSWORD=your_plain_text_password
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
2. Run the CLI:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
renpho
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This will log in, discover your scales, fetch all measurements, print the 5 most recent, and save everything to `renpho_data/` as JSON and CSV.
|
|
84
|
+
|
|
85
|
+
### Environment variables
|
|
86
|
+
|
|
87
|
+
| Variable | Required | Description |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `RENPHO_EMAIL` | Yes | Your Renpho account email |
|
|
90
|
+
| `RENPHO_PASSWORD` | Yes | Your Renpho account password |
|
|
91
|
+
| `RENPHO_DEBUG` | No | Set to `1` to print API request/response details |
|
|
92
|
+
| `RENPHO_OUTPUT_DIR` | No | Output directory (default: `renpho_data`) |
|
|
93
|
+
|
|
94
|
+
## Library Usage
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from renpho import RenphoClient
|
|
98
|
+
|
|
99
|
+
client = RenphoClient("user@example.com", "password")
|
|
100
|
+
client.login()
|
|
101
|
+
|
|
102
|
+
# Fetch all measurements in one call
|
|
103
|
+
measurements = client.get_all_measurements()
|
|
104
|
+
|
|
105
|
+
for m in measurements:
|
|
106
|
+
print(m["weight"], m.get("bodyfat"), m.get("muscle"))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Step-by-step control
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from renpho import RenphoClient, save_json, save_csv
|
|
113
|
+
|
|
114
|
+
client = RenphoClient("user@example.com", "password")
|
|
115
|
+
client.login()
|
|
116
|
+
|
|
117
|
+
# Get device/scale info
|
|
118
|
+
device_info = client.get_device_info()
|
|
119
|
+
scales = device_info["scale"]
|
|
120
|
+
|
|
121
|
+
# Fetch from a specific scale table
|
|
122
|
+
# Use get_body_composition_measurements() for scales with impedance sensors
|
|
123
|
+
# (body fat, muscle, etc.) — the server-side count is unreliable for these.
|
|
124
|
+
# Fall back to get_measurements() for weight-only scales.
|
|
125
|
+
table = scales[0]
|
|
126
|
+
measurements = client.get_body_composition_measurements(
|
|
127
|
+
table_name=table["tableName"],
|
|
128
|
+
user_id=client.user_id,
|
|
129
|
+
)
|
|
130
|
+
if not measurements:
|
|
131
|
+
measurements = client.get_measurements(
|
|
132
|
+
table_name=table["tableName"],
|
|
133
|
+
user_id=client.user_id,
|
|
134
|
+
total_count=table["count"],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Export
|
|
138
|
+
save_json(measurements, "my_data.json")
|
|
139
|
+
save_csv(measurements, "my_data.csv")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Multiple Renpho accounts on one email
|
|
143
|
+
|
|
144
|
+
Some users end up with **two Renpho accounts under the same email** — for
|
|
145
|
+
example after the Google SSO migration created an orphan account, or after
|
|
146
|
+
re-registering. Each account has its own user ID and its own measurement
|
|
147
|
+
table, so the default `get_all_measurements()` will only return data from
|
|
148
|
+
the account you log in to.
|
|
149
|
+
|
|
150
|
+
If you know the other account's user ID, pass it in:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
measurements = client.get_all_measurements(
|
|
154
|
+
extra_user_ids=["5975813831868809088"],
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The library will probe every measurement table for that user ID, fetch
|
|
159
|
+
matching records, and dedupe by record `id` so you get a single combined
|
|
160
|
+
timeline.
|
|
161
|
+
|
|
162
|
+
**How to find your other user ID:**
|
|
163
|
+
|
|
164
|
+
Unfortunately there is no first-party API endpoint that lists "all
|
|
165
|
+
accounts associated with this email" — Renpho treats accounts as
|
|
166
|
+
independent even when emails collide. Options:
|
|
167
|
+
|
|
168
|
+
1. **Renpho support** — email them and ask for your user ID(s) on file
|
|
169
|
+
2. **Inspect the iOS / Android app** — sign in to the other account in
|
|
170
|
+
the official app and look in Settings / Account / Help → Feedback
|
|
171
|
+
pages (the user ID is sometimes visible there)
|
|
172
|
+
3. **Capture network traffic** — proxy the official app through
|
|
173
|
+
mitmproxy, sign in, and look at any request body containing
|
|
174
|
+
`userId` (decrypt with the published AES-128 key — see the
|
|
175
|
+
reverse-engineering write-up linked at the top of this README)
|
|
176
|
+
|
|
177
|
+
Once you have the ID, save it alongside your credentials and you won't
|
|
178
|
+
need to discover it again.
|
|
179
|
+
|
|
180
|
+
### Error handling
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from renpho import RenphoClient, RenphoAPIError
|
|
184
|
+
|
|
185
|
+
client = RenphoClient("user@example.com", "wrong_password")
|
|
186
|
+
try:
|
|
187
|
+
client.login()
|
|
188
|
+
except RenphoAPIError as e:
|
|
189
|
+
print(f"API error: {e}")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Available Metrics
|
|
193
|
+
|
|
194
|
+
Each measurement dict can contain these fields (availability depends on your scale model):
|
|
195
|
+
|
|
196
|
+
| Key | Description | Unit |
|
|
197
|
+
| --- | --- | --- |
|
|
198
|
+
| `weight` | Weight | kg |
|
|
199
|
+
| `bmi` | BMI | |
|
|
200
|
+
| `bodyfat` | Body Fat | % |
|
|
201
|
+
| `water` | Body Water | % |
|
|
202
|
+
| `muscle` | Muscle Mass | % |
|
|
203
|
+
| `bone` | Bone Mass | % |
|
|
204
|
+
| `bmr` | Basal Metabolic Rate | kcal/day |
|
|
205
|
+
| `visfat` | Visceral Fat | level |
|
|
206
|
+
| `subfat` | Subcutaneous Fat | % |
|
|
207
|
+
| `protein` | Protein | % |
|
|
208
|
+
| `bodyage` | Body Age | years |
|
|
209
|
+
| `sinew` | Lean Body Mass | kg |
|
|
210
|
+
| `fatFreeWeight` | Fat Free Weight | kg |
|
|
211
|
+
| `heartRate` | Heart Rate | bpm |
|
|
212
|
+
| `cardiacIndex` | Cardiac Index | |
|
|
213
|
+
| `bodyShape` | Body Shape | |
|
|
214
|
+
|
|
215
|
+
## Project Structure
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
renpho-py/
|
|
219
|
+
├── pyproject.toml # Package config & dependencies (dist name: renpho-py)
|
|
220
|
+
├── README.md
|
|
221
|
+
├── CHANGELOG.md
|
|
222
|
+
├── LICENSE # MIT (original + current attribution)
|
|
223
|
+
├── NOTICE
|
|
224
|
+
├── renpho/ # import name — unchanged for drop-in migration
|
|
225
|
+
│ ├── __init__.py # Public API exports
|
|
226
|
+
│ ├── py.typed # PEP 561 typing marker
|
|
227
|
+
│ ├── client.py # RenphoClient class
|
|
228
|
+
│ ├── cli.py # CLI entry point
|
|
229
|
+
│ ├── constants.py # API endpoints, device types, metrics
|
|
230
|
+
│ ├── crypto.py # AES encryption/decryption
|
|
231
|
+
│ └── export.py # JSON/CSV export helpers
|
|
232
|
+
├── tests/ # Unit tests
|
|
233
|
+
└── .github/workflows/ # CI + PyPI release automation (trusted publishing)
|
|
234
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
renpho/__init__.py,sha256=DCTXlSyeSIEHjwy6yTCUS0-h8KXggLUZbnYeqTYn_RQ,577
|
|
2
|
+
renpho/cli.py,sha256=qzKcR-5elrAzqXvie_4Ci6NZfz7lmHqPTjAbslXN8fo,3402
|
|
3
|
+
renpho/client.py,sha256=BzaRHW6t_dyoX0RWfZQxPlAm8YekVQZwvzG-bv4k0kQ,13575
|
|
4
|
+
renpho/constants.py,sha256=3KEupjDJFYu9RsCokV712AovM3uIMY6MZ5f53eaBEhY,1981
|
|
5
|
+
renpho/crypto.py,sha256=bUxDKBvApp2Yecv-uLW_llqAI8tMY727AmNl7PPh1_4,1984
|
|
6
|
+
renpho/export.py,sha256=v-yTgQfuQBVkgvoBm2J0iiPieh8L9HRpz3XBHlu4ZX4,2784
|
|
7
|
+
renpho/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
renpho_py-1.0.0.dist-info/licenses/LICENSE,sha256=hzGmcViLVRuPU3Ee7OISMZ4kRrX6z2hZbZzf18IHaE8,1161
|
|
9
|
+
renpho_py-1.0.0.dist-info/licenses/NOTICE,sha256=doXreO8gsc-WJKAPGw7NEfatr-T48nelRuFSOyyl67w,714
|
|
10
|
+
renpho_py-1.0.0.dist-info/METADATA,sha256=tzIp5M31-M1_y6Jd8WUkO3XsARElLCa0Qer-qfW3Mu4,7969
|
|
11
|
+
renpho_py-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
renpho_py-1.0.0.dist-info/entry_points.txt,sha256=WwzgR408BL15AZ7hsgqvYQovrIpD6y1u2RBnS6VkaLo,43
|
|
13
|
+
renpho_py-1.0.0.dist-info/top_level.txt,sha256=4Fx37UUXPT1K_IYsJnxDaGbvbssufPBZZCpYG-Zw4Bs,7
|
|
14
|
+
renpho_py-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 danvaneijck (original renpho-api)
|
|
4
|
+
Copyright (c) 2026 ChocoTonic (renpho-py and subsequent modifications)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
renpho-py
|
|
2
|
+
========
|
|
3
|
+
|
|
4
|
+
This project began as a copy of the MIT-licensed `renpho-api`
|
|
5
|
+
(https://github.com/danvaneijck/renpho-api) by danvaneijck, and is now
|
|
6
|
+
independently maintained. The original license and copyright are preserved in
|
|
7
|
+
LICENSE.
|
|
8
|
+
|
|
9
|
+
The underlying Renpho cloud API was reverse-engineered; the approach and
|
|
10
|
+
protocol details are based on RenphoGarminSync-CLI
|
|
11
|
+
(https://github.com/forkerer/RenphoGarminSync-CLI).
|
|
12
|
+
|
|
13
|
+
renpho-py is UNOFFICIAL and is not affiliated with, endorsed by, or supported by
|
|
14
|
+
Renpho or any of its affiliates. "Renpho" is a trademark of its respective
|
|
15
|
+
owner and is used here only to describe the API this client targets. Use at
|
|
16
|
+
your own risk and in accordance with Renpho's terms of service.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
renpho
|