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 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
+ [![PyPI](https://img.shields.io/pypi/v/renpho-py)](https://pypi.org/project/renpho-py/)
37
+ [![CI](https://github.com/ChocoTonic/renpho-py/actions/workflows/ci.yml/badge.svg)](https://github.com/ChocoTonic/renpho-py/actions/workflows/ci.yml)
38
+ [![Python](https://img.shields.io/pypi/pyversions/renpho-py)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ renpho = renpho.cli:main
@@ -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