homevitals 1.0.0__tar.gz

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.
Files changed (84) hide show
  1. homevitals-1.0.0/LICENSE +22 -0
  2. homevitals-1.0.0/PKG-INFO +133 -0
  3. homevitals-1.0.0/README.md +102 -0
  4. homevitals-1.0.0/homevitals/__init__.py +18 -0
  5. homevitals-1.0.0/homevitals/__main__.py +5 -0
  6. homevitals-1.0.0/homevitals/assets/app_icon.ico +0 -0
  7. homevitals-1.0.0/homevitals/assets/app_icon.png +0 -0
  8. homevitals-1.0.0/homevitals/bp_sync.py +212 -0
  9. homevitals-1.0.0/homevitals/cli/__init__.py +3 -0
  10. homevitals-1.0.0/homevitals/cli/app.py +600 -0
  11. homevitals-1.0.0/homevitals/cli/doctor.py +365 -0
  12. homevitals-1.0.0/homevitals/cli/failure_notify.py +94 -0
  13. homevitals-1.0.0/homevitals/cli/lock.py +100 -0
  14. homevitals-1.0.0/homevitals/cli/maintenance.py +412 -0
  15. homevitals-1.0.0/homevitals/cli/profiles.py +88 -0
  16. homevitals-1.0.0/homevitals/cli/setup.py +405 -0
  17. homevitals-1.0.0/homevitals/cli/shared.py +60 -0
  18. homevitals-1.0.0/homevitals/cli/status.py +310 -0
  19. homevitals-1.0.0/homevitals/cli/updater.py +151 -0
  20. homevitals-1.0.0/homevitals/config.py +404 -0
  21. homevitals-1.0.0/homevitals/credentials.py +546 -0
  22. homevitals-1.0.0/homevitals/eufy_client.py +437 -0
  23. homevitals-1.0.0/homevitals/garmin_auth.py +475 -0
  24. homevitals-1.0.0/homevitals/garmin_client.py +283 -0
  25. homevitals-1.0.0/homevitals/gui.py +1575 -0
  26. homevitals-1.0.0/homevitals/gui_logic.py +1268 -0
  27. homevitals-1.0.0/homevitals/install.py +78 -0
  28. homevitals-1.0.0/homevitals/migrate.py +51 -0
  29. homevitals-1.0.0/homevitals/network.py +38 -0
  30. homevitals-1.0.0/homevitals/omron_client.py +416 -0
  31. homevitals-1.0.0/homevitals/platform_support/__init__.py +74 -0
  32. homevitals-1.0.0/homevitals/platform_support/generic.py +40 -0
  33. homevitals-1.0.0/homevitals/platform_support/macos.py +290 -0
  34. homevitals-1.0.0/homevitals/platform_support/windows.py +336 -0
  35. homevitals-1.0.0/homevitals/prompt.py +70 -0
  36. homevitals-1.0.0/homevitals/reporting.py +46 -0
  37. homevitals-1.0.0/homevitals/state.py +258 -0
  38. homevitals-1.0.0/homevitals/strava_client.py +294 -0
  39. homevitals-1.0.0/homevitals/sync.py +400 -0
  40. homevitals-1.0.0/homevitals/theme.py +271 -0
  41. homevitals-1.0.0/homevitals/transform.py +59 -0
  42. homevitals-1.0.0/homevitals/tray.py +255 -0
  43. homevitals-1.0.0/homevitals/win_appid.py +191 -0
  44. homevitals-1.0.0/homevitals/zwift_client.py +307 -0
  45. homevitals-1.0.0/homevitals.egg-info/PKG-INFO +133 -0
  46. homevitals-1.0.0/homevitals.egg-info/SOURCES.txt +82 -0
  47. homevitals-1.0.0/homevitals.egg-info/dependency_links.txt +1 -0
  48. homevitals-1.0.0/homevitals.egg-info/entry_points.txt +5 -0
  49. homevitals-1.0.0/homevitals.egg-info/requires.txt +8 -0
  50. homevitals-1.0.0/homevitals.egg-info/top_level.txt +1 -0
  51. homevitals-1.0.0/pyproject.toml +80 -0
  52. homevitals-1.0.0/setup.cfg +4 -0
  53. homevitals-1.0.0/tests/test_bp_sync.py +345 -0
  54. homevitals-1.0.0/tests/test_cli.py +2585 -0
  55. homevitals-1.0.0/tests/test_config.py +595 -0
  56. homevitals-1.0.0/tests/test_credentials.py +978 -0
  57. homevitals-1.0.0/tests/test_doctor.py +815 -0
  58. homevitals-1.0.0/tests/test_eufy_client.py +473 -0
  59. homevitals-1.0.0/tests/test_failure_notify.py +110 -0
  60. homevitals-1.0.0/tests/test_garmin_auth.py +577 -0
  61. homevitals-1.0.0/tests/test_garmin_client.py +622 -0
  62. homevitals-1.0.0/tests/test_gui.py +888 -0
  63. homevitals-1.0.0/tests/test_gui_logic.py +1608 -0
  64. homevitals-1.0.0/tests/test_install.py +50 -0
  65. homevitals-1.0.0/tests/test_lock.py +140 -0
  66. homevitals-1.0.0/tests/test_migrate.py +130 -0
  67. homevitals-1.0.0/tests/test_notify.py +68 -0
  68. homevitals-1.0.0/tests/test_omron_client.py +486 -0
  69. homevitals-1.0.0/tests/test_platform_support.py +43 -0
  70. homevitals-1.0.0/tests/test_platform_windows.py +452 -0
  71. homevitals-1.0.0/tests/test_prompt.py +56 -0
  72. homevitals-1.0.0/tests/test_retry.py +107 -0
  73. homevitals-1.0.0/tests/test_safety.py +941 -0
  74. homevitals-1.0.0/tests/test_setup_zwift.py +175 -0
  75. homevitals-1.0.0/tests/test_strava_client.py +237 -0
  76. homevitals-1.0.0/tests/test_summary.py +458 -0
  77. homevitals-1.0.0/tests/test_sync.py +1502 -0
  78. homevitals-1.0.0/tests/test_theme.py +77 -0
  79. homevitals-1.0.0/tests/test_transform.py +102 -0
  80. homevitals-1.0.0/tests/test_tray.py +48 -0
  81. homevitals-1.0.0/tests/test_update_check.py +375 -0
  82. homevitals-1.0.0/tests/test_win_appid.py +102 -0
  83. homevitals-1.0.0/tests/test_zwift_client.py +288 -0
  84. homevitals-1.0.0/tests/test_zwift_sync.py +255 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elias Sturim (eufy-sync)
4
+ Copyright (c) 2026 Christopher Smith (HomeVitals)
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,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: homevitals
3
+ Version: 1.0.0
4
+ Summary: Sync your household's smart scale and blood pressure readings to each person's own Garmin Connect account. Works for Garmin Connect.
5
+ Author: Christopher Smith
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/smithbuilt/homevitals
8
+ Project-URL: Repository, https://github.com/smithbuilt/homevitals
9
+ Project-URL: Issues, https://github.com/smithbuilt/homevitals/issues
10
+ Project-URL: Changelog, https://github.com/smithbuilt/homevitals/blob/main/CHANGELOG.md
11
+ Project-URL: Based on, https://github.com/sturimcode/eufy-sync
12
+ Keywords: garmin connect,eufy,eufylife,smart scale,omron,omron connect,blood pressure,body composition,weight sync,household,family
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Requires-Python: >=3.12
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: httpx>=0.27.0
24
+ Requires-Dist: keyring>=25.0.0
25
+ Requires-Dist: garminconnect>=0.3.10
26
+ Requires-Dist: curl_cffi>=0.15.0
27
+ Requires-Dist: pyyaml>=6.0
28
+ Provides-Extra: browser
29
+ Requires-Dist: playwright>=1.40.0; extra == "browser"
30
+ Dynamic: license-file
31
+
32
+ # HomeVitals
33
+
34
+ **Works for Garmin Connect.** Sync a household's smart scale and blood pressure readings to *each person's own* Garmin Connect account.
35
+
36
+ [![Tests](https://github.com/smithbuilt/homevitals/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/smithbuilt/homevitals/actions/workflows/test.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/homevitals)](https://pypi.org/project/homevitals/)
38
+ ![License](https://img.shields.io/badge/license-MIT-green)
39
+
40
+ ![The HomeVitals window during a sync](docs/screenshots/main-window.png)
41
+
42
+ Garmin's own scales and monitors sync by themselves. Many homes use a **Eufy smart scale** and an **OMRON blood pressure monitor** instead, shared by everyone in the house. HomeVitals moves each person's readings into their own Garmin Connect account:
43
+
44
+ - **Weigh-ins** from the Eufy scale (EufyLife cloud): weight and full body composition.
45
+ - **Blood pressure readings** from the OMRON monitor, through the OMRON connect app: systolic, diastolic and pulse, with "irregular heartbeat detected" or "body movement detected" in the reading's note when the monitor flagged it.
46
+
47
+ Each person keeps their own accounts. Weigh-ins only go to the person whose scale profile they are, and scale profiles not linked to anyone (the kids', a guest's) are never sent anywhere.
48
+
49
+ > HomeVitals is not affiliated with, endorsed by or supported by Garmin, Eufy/Anker or OMRON. It uses the same connections their apps use, which aren't public APIs and can change without notice. It is not a medical device: always check readings on the monitor or in the manufacturer's app.
50
+
51
+ ## What's been tested
52
+
53
+ | | |
54
+ |---|---|
55
+ | Windows 11 | Tested daily: window, tray icon, automatic sync, shortcuts |
56
+ | macOS, Linux | Not tested. The command line came from [eufy-sync](https://github.com/sturimcode/eufy-sync), which supports them; the window's tray icon and taskbar parts are Windows-only |
57
+ | Scales | A Eufy Wi-Fi smart scale through the EufyLife app's cloud |
58
+ | Blood pressure | OMRON M7 Intelli IT (HEM-7361T) with the OMRON connect app (EU server). Other monitors that sync to OMRON connect should work; tell us if yours does |
59
+ | People | Two adults, each with their own Garmin and OMRON connect accounts, sharing one Eufy scale that also has unlinked kids' profiles |
60
+
61
+ ## Install
62
+
63
+ You need Python 3.12+ and [uv](https://docs.astral.sh/uv/) (or pipx).
64
+
65
+ ```
66
+ uv tool install homevitals
67
+ homevitals-gui
68
+ ```
69
+
70
+ The window opens. Click **Add desktop shortcut** to open it from the desktop from then on.
71
+
72
+ Update later with `uv tool upgrade homevitals`. HomeVitals never updates itself.
73
+
74
+ ## Setting up your household
75
+
76
+ ![Adding a person](docs/screenshots/person-window.png)
77
+
78
+ Click **Add person**, type a name, and connect what that person uses. Each box connects on its own, checked with a real login before anything is saved, so you can add Garmin today and the scale next week. Nothing is required except a name.
79
+
80
+ - **Garmin:** the person's own Garmin Connect account. Garmin may email a security code; a box asks for it.
81
+ - **Scale (Eufy):** the Eufy account the person's weigh-ins go to, then pick their profile on the scale (shown by last weight and date). Profiles already linked to someone are greyed out. By default each person uses their own Eufy account, so nobody sees anyone else's weight; if two people really share one Eufy login, each types it in their own box.
82
+ - **Blood pressure (OMRON connect):** the person's OMRON connect email and password, and **the country the OMRON connect account was created in**. See the OMRON note below.
83
+
84
+ Then click **Sync now**, and tick **Automatic sync (every 4 hours)**. While a sync runs, each person's row shows ⋯ waiting, a turning *syncing* symbol, then ✓ 2 new, ✓ up to date or ✗ failed.
85
+
86
+ **Fix problems** checks every login and setting and shows a button to fix what it can. Closing the window keeps HomeVitals running by the clock; right-click its icon there for **Quit**.
87
+
88
+ ## Things worth knowing
89
+
90
+ **The phone apps have to see the readings first.** The scale's weigh-ins reach Eufy's cloud only after the Eufy app has picked them up, and the monitor's readings reach OMRON's cloud only after the OMRON connect app has pulled them off the monitor. If a sync says "nothing new", open the right app on the phone, wait a moment and sync again.
91
+
92
+ **OMRON's country matters as much as the password.** Each country uses its own OMRON server, so the country in HomeVitals must be the one chosen when the OMRON connect account was created. A wrong country fails exactly like a wrong password, and the window says so. Some regions in the OMRON connect app keep readings on the phone only, with no cloud at all (Qatar is one); readings from such an account can't be synced, so create the account in a country with cloud sync.
93
+
94
+ **Older readings.** The first sync brings in the last 7 days of weigh-ins and 30 days of blood pressure. To bring in more blood pressure history (up to 10 years, or what the monitor and app hold), open the person and click **Bring in older readings**. Nothing is ever uploaded twice: HomeVitals remembers what it sent and checks what Garmin already has.
95
+
96
+ **Numbers are never changed.** Readings go to Garmin exactly as measured. One that Garmin won't accept is skipped and reported, never rounded.
97
+
98
+ ## Privacy and security
99
+
100
+ - Everything runs on your own computer. HomeVitals talks only to Eufy, OMRON and Garmin, with the accounts you give it.
101
+ - Passwords are kept in the system's own password store (Windows Credential Manager). They never go into the settings file, the log or the window.
102
+ - Weights and blood pressure numbers never appear in the log file, the window's messages or notifications; only counts do.
103
+ - Settings and sync history live in `~/.homevitals` (on Windows `C:\Users\<you>\.homevitals`). The log file `sync.log` there is safe to share when asking for help.
104
+
105
+ ## Advanced settings
106
+
107
+ Most people never need these. They go in a person's `omron:` part of `~/.homevitals/config.yaml` (quit the window first):
108
+
109
+ - `user_number`: send only that user's readings from the monitor. Leave it out and every reading in the person's OMRON connect account syncs, which is right when each person has their own account. Set it (1 to 99, the user number the monitor shows) when one account holds several people's readings.
110
+ - `server`: pin the OMRON server (`eu`, `na`, or a full `https://` address) if a correct login keeps failing.
111
+
112
+ ```yaml
113
+ users:
114
+ - name: Sam
115
+ omron:
116
+ email: sam@example.com
117
+ country: GB
118
+ user_number: 2
119
+ ```
120
+
121
+ The command line is still there for scripts and servers: `homevitals --help`, `homevitals --doctor`.
122
+
123
+ ## Coming from eufy-sync or an earlier HomeVitals name
124
+
125
+ HomeVitals started as a fork of [eufy-sync](https://github.com/sturimcode/eufy-sync), which syncs one person's Eufy scale. If you used eufy-sync (settings in `~/.garmin-sync`), the first start copies your settings, sync history and saved passwords over; the originals are left as they were.
126
+
127
+ ## Credits and licence
128
+
129
+ MIT licence. Based on [eufy-sync](https://github.com/sturimcode/eufy-sync) by Elias Sturim (MIT): the Eufy and Garmin parts, the command line and the automatic sync come from there.
130
+
131
+ The OMRON connect part was written from scratch, using two open-source projects as references for how OMRON's servers behave: [omramin](https://github.com/bugficks/omramin) by bugficks and [omron-connect-mcp](https://github.com/aircon-chen/omron-connect-mcp) by aircon-chen (both GPL-2.0). No code from either is included.
132
+
133
+ Garmin and Garmin Connect are trademarks of Garmin Ltd. or its subsidiaries; Eufy is a trademark of Anker Innovations; OMRON and OMRON connect are trademarks of OMRON Corporation. They're named here only to say what HomeVitals works with.
@@ -0,0 +1,102 @@
1
+ # HomeVitals
2
+
3
+ **Works for Garmin Connect.** Sync a household's smart scale and blood pressure readings to *each person's own* Garmin Connect account.
4
+
5
+ [![Tests](https://github.com/smithbuilt/homevitals/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/smithbuilt/homevitals/actions/workflows/test.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/homevitals)](https://pypi.org/project/homevitals/)
7
+ ![License](https://img.shields.io/badge/license-MIT-green)
8
+
9
+ ![The HomeVitals window during a sync](docs/screenshots/main-window.png)
10
+
11
+ Garmin's own scales and monitors sync by themselves. Many homes use a **Eufy smart scale** and an **OMRON blood pressure monitor** instead, shared by everyone in the house. HomeVitals moves each person's readings into their own Garmin Connect account:
12
+
13
+ - **Weigh-ins** from the Eufy scale (EufyLife cloud): weight and full body composition.
14
+ - **Blood pressure readings** from the OMRON monitor, through the OMRON connect app: systolic, diastolic and pulse, with "irregular heartbeat detected" or "body movement detected" in the reading's note when the monitor flagged it.
15
+
16
+ Each person keeps their own accounts. Weigh-ins only go to the person whose scale profile they are, and scale profiles not linked to anyone (the kids', a guest's) are never sent anywhere.
17
+
18
+ > HomeVitals is not affiliated with, endorsed by or supported by Garmin, Eufy/Anker or OMRON. It uses the same connections their apps use, which aren't public APIs and can change without notice. It is not a medical device: always check readings on the monitor or in the manufacturer's app.
19
+
20
+ ## What's been tested
21
+
22
+ | | |
23
+ |---|---|
24
+ | Windows 11 | Tested daily: window, tray icon, automatic sync, shortcuts |
25
+ | macOS, Linux | Not tested. The command line came from [eufy-sync](https://github.com/sturimcode/eufy-sync), which supports them; the window's tray icon and taskbar parts are Windows-only |
26
+ | Scales | A Eufy Wi-Fi smart scale through the EufyLife app's cloud |
27
+ | Blood pressure | OMRON M7 Intelli IT (HEM-7361T) with the OMRON connect app (EU server). Other monitors that sync to OMRON connect should work; tell us if yours does |
28
+ | People | Two adults, each with their own Garmin and OMRON connect accounts, sharing one Eufy scale that also has unlinked kids' profiles |
29
+
30
+ ## Install
31
+
32
+ You need Python 3.12+ and [uv](https://docs.astral.sh/uv/) (or pipx).
33
+
34
+ ```
35
+ uv tool install homevitals
36
+ homevitals-gui
37
+ ```
38
+
39
+ The window opens. Click **Add desktop shortcut** to open it from the desktop from then on.
40
+
41
+ Update later with `uv tool upgrade homevitals`. HomeVitals never updates itself.
42
+
43
+ ## Setting up your household
44
+
45
+ ![Adding a person](docs/screenshots/person-window.png)
46
+
47
+ Click **Add person**, type a name, and connect what that person uses. Each box connects on its own, checked with a real login before anything is saved, so you can add Garmin today and the scale next week. Nothing is required except a name.
48
+
49
+ - **Garmin:** the person's own Garmin Connect account. Garmin may email a security code; a box asks for it.
50
+ - **Scale (Eufy):** the Eufy account the person's weigh-ins go to, then pick their profile on the scale (shown by last weight and date). Profiles already linked to someone are greyed out. By default each person uses their own Eufy account, so nobody sees anyone else's weight; if two people really share one Eufy login, each types it in their own box.
51
+ - **Blood pressure (OMRON connect):** the person's OMRON connect email and password, and **the country the OMRON connect account was created in**. See the OMRON note below.
52
+
53
+ Then click **Sync now**, and tick **Automatic sync (every 4 hours)**. While a sync runs, each person's row shows ⋯ waiting, a turning *syncing* symbol, then ✓ 2 new, ✓ up to date or ✗ failed.
54
+
55
+ **Fix problems** checks every login and setting and shows a button to fix what it can. Closing the window keeps HomeVitals running by the clock; right-click its icon there for **Quit**.
56
+
57
+ ## Things worth knowing
58
+
59
+ **The phone apps have to see the readings first.** The scale's weigh-ins reach Eufy's cloud only after the Eufy app has picked them up, and the monitor's readings reach OMRON's cloud only after the OMRON connect app has pulled them off the monitor. If a sync says "nothing new", open the right app on the phone, wait a moment and sync again.
60
+
61
+ **OMRON's country matters as much as the password.** Each country uses its own OMRON server, so the country in HomeVitals must be the one chosen when the OMRON connect account was created. A wrong country fails exactly like a wrong password, and the window says so. Some regions in the OMRON connect app keep readings on the phone only, with no cloud at all (Qatar is one); readings from such an account can't be synced, so create the account in a country with cloud sync.
62
+
63
+ **Older readings.** The first sync brings in the last 7 days of weigh-ins and 30 days of blood pressure. To bring in more blood pressure history (up to 10 years, or what the monitor and app hold), open the person and click **Bring in older readings**. Nothing is ever uploaded twice: HomeVitals remembers what it sent and checks what Garmin already has.
64
+
65
+ **Numbers are never changed.** Readings go to Garmin exactly as measured. One that Garmin won't accept is skipped and reported, never rounded.
66
+
67
+ ## Privacy and security
68
+
69
+ - Everything runs on your own computer. HomeVitals talks only to Eufy, OMRON and Garmin, with the accounts you give it.
70
+ - Passwords are kept in the system's own password store (Windows Credential Manager). They never go into the settings file, the log or the window.
71
+ - Weights and blood pressure numbers never appear in the log file, the window's messages or notifications; only counts do.
72
+ - Settings and sync history live in `~/.homevitals` (on Windows `C:\Users\<you>\.homevitals`). The log file `sync.log` there is safe to share when asking for help.
73
+
74
+ ## Advanced settings
75
+
76
+ Most people never need these. They go in a person's `omron:` part of `~/.homevitals/config.yaml` (quit the window first):
77
+
78
+ - `user_number`: send only that user's readings from the monitor. Leave it out and every reading in the person's OMRON connect account syncs, which is right when each person has their own account. Set it (1 to 99, the user number the monitor shows) when one account holds several people's readings.
79
+ - `server`: pin the OMRON server (`eu`, `na`, or a full `https://` address) if a correct login keeps failing.
80
+
81
+ ```yaml
82
+ users:
83
+ - name: Sam
84
+ omron:
85
+ email: sam@example.com
86
+ country: GB
87
+ user_number: 2
88
+ ```
89
+
90
+ The command line is still there for scripts and servers: `homevitals --help`, `homevitals --doctor`.
91
+
92
+ ## Coming from eufy-sync or an earlier HomeVitals name
93
+
94
+ HomeVitals started as a fork of [eufy-sync](https://github.com/sturimcode/eufy-sync), which syncs one person's Eufy scale. If you used eufy-sync (settings in `~/.garmin-sync`), the first start copies your settings, sync history and saved passwords over; the originals are left as they were.
95
+
96
+ ## Credits and licence
97
+
98
+ MIT licence. Based on [eufy-sync](https://github.com/sturimcode/eufy-sync) by Elias Sturim (MIT): the Eufy and Garmin parts, the command line and the automatic sync come from there.
99
+
100
+ The OMRON connect part was written from scratch, using two open-source projects as references for how OMRON's servers behave: [omramin](https://github.com/bugficks/omramin) by bugficks and [omron-connect-mcp](https://github.com/aircon-chen/omron-connect-mcp) by aircon-chen (both GPL-2.0). No code from either is included.
101
+
102
+ Garmin and Garmin Connect are trademarks of Garmin Ltd. or its subsidiaries; Eufy is a trademark of Anker Innovations; OMRON and OMRON connect are trademarks of OMRON Corporation. They're named here only to say what HomeVitals works with.
@@ -0,0 +1,18 @@
1
+ """Sync Eufy smart scale body composition data to Garmin Connect and Strava."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ # Public API for programmatic use
6
+ from homevitals.eufy_client import EufyClient, EufyMeasurement
7
+ from homevitals.garmin_auth import GarminAuth
8
+ from homevitals.strava_client import StravaClient
9
+ from homevitals.transform import GarminBodyComposition, transform
10
+
11
+ __all__ = [
12
+ "GarminAuth",
13
+ "EufyClient",
14
+ "EufyMeasurement",
15
+ "GarminBodyComposition",
16
+ "StravaClient",
17
+ "transform",
18
+ ]
@@ -0,0 +1,5 @@
1
+ """python -m homevitals runs the command-line tool (the GUI's Sync now uses this)."""
2
+ from homevitals.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,212 @@
1
+ """The Omron -> Garmin blood pressure step of a sync run.
2
+
3
+ Runs after the scale step, only for a person with an omron section, and fails
4
+ on its own: a problem here never stops the scale sync, and the other way
5
+ round (cli/app.py gives each step its own try/except).
6
+
7
+ Health data rules: values go to Garmin exactly as OMRON reported them; a
8
+ reading Garmin would refuse is skipped and counted, never rounded or
9
+ clamped. Logs and results carry counts only, never a reading's values.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import time
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timedelta, timezone
18
+ from typing import TypeVar
19
+
20
+ from garminconnect import (
21
+ GarminConnectAuthenticationError,
22
+ GarminConnectConnectionError,
23
+ GarminConnectTooManyRequestsError,
24
+ )
25
+
26
+ from homevitals import state as state_module
27
+ from homevitals.config import UserConfig
28
+ from homevitals.garmin_client import GarminClient
29
+ from homevitals.network import is_transient_network_error
30
+ from homevitals.omron_client import BloodPressureReading, OmronClient, OmronError
31
+ from homevitals.state import SyncState
32
+ from homevitals.sync import MAX_RETRIES, RETRY_BASE_DELAY, PermanentSyncError, _is_permanent
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ T = TypeVar("T")
37
+
38
+ BP_TARGET = state_module.BP_TARGET
39
+ FIRST_SYNC_BACKFILL_DAYS = 30
40
+ REFETCH_OVERLAP_DAYS = 7
41
+ GARMIN_SAME_TIME_TOLERANCE_SECONDS = 2
42
+ # Mirrors garminconnect 0.3.17 set_blood_pressure's own range checks.
43
+ GARMIN_BP_LIMITS = {"systolic": (70, 260), "diastolic": (40, 150), "pulse": (20, 250)}
44
+ NOTES_DEVICE = "Omron M7"
45
+ SKIPPED_OUT_OF_RANGE_RESPONSE = '{"skipped": "outside_garmin_range"}'
46
+
47
+
48
+ @dataclass
49
+ class BpSyncResult:
50
+ """Counts for one person's run. Holds no readings, so its repr is safe to log."""
51
+
52
+ fetched: int = 0
53
+ uploaded: int = 0 # in a dry run: would have uploaded
54
+ skipped_synced: int = 0
55
+ skipped_in_garmin: int = 0
56
+ skipped_out_of_range: int = 0
57
+ error: str | None = None # plain text (safe_error_text) when an upload failed
58
+
59
+
60
+ def _in_range(value, limits: tuple[int, int]) -> bool:
61
+ return isinstance(value, int) and not isinstance(value, bool) and limits[0] <= value <= limits[1]
62
+
63
+
64
+ def garmin_accepts(reading: BloodPressureReading) -> bool:
65
+ return (_in_range(reading.systolic, GARMIN_BP_LIMITS["systolic"])
66
+ and _in_range(reading.diastolic, GARMIN_BP_LIMITS["diastolic"])
67
+ and (reading.pulse is None or _in_range(reading.pulse, GARMIN_BP_LIMITS["pulse"])))
68
+
69
+
70
+ def notes_for(reading: BloodPressureReading) -> str:
71
+ notes = NOTES_DEVICE
72
+ if reading.irregular_heartbeat:
73
+ notes += "; irregular heartbeat detected"
74
+ if reading.body_movement:
75
+ notes += "; body movement detected"
76
+ return notes
77
+
78
+
79
+ def window_start(state: SyncState, user_name: str, backfill_days: int | None, now: datetime) -> datetime:
80
+ """How far back to ask OMRON for readings.
81
+
82
+ The first time, 30 days. After that, a week before the newest reading
83
+ already synced: a reading taken on Monday but only moved to the phone on
84
+ Thursday must not hide behind Tuesday's. The state database removes the overlap.
85
+ """
86
+ if backfill_days:
87
+ return now - timedelta(days=backfill_days)
88
+ latest = state.get_latest_sync_timestamp(user_name, BP_TARGET)
89
+ if latest is None:
90
+ logger.info("No prior blood pressure syncs for %s, backfilling %d days", user_name, FIRST_SYNC_BACKFILL_DAYS)
91
+ return now - timedelta(days=FIRST_SYNC_BACKFILL_DAYS)
92
+ return datetime.fromtimestamp(latest, timezone.utc) - timedelta(days=REFETCH_OVERLAP_DAYS)
93
+
94
+
95
+ def safe_error_text(exc: BaseException) -> str:
96
+ """A failure as text that never carries a reading's values or a server's reply."""
97
+ if isinstance(exc, (PermanentSyncError, OmronError)):
98
+ return str(exc) # our own messages: hosts, statuses and commands only
99
+ if isinstance(exc, GarminConnectTooManyRequestsError):
100
+ return "Garmin is asking us to slow down (HTTP 429). Wait an hour and sync again."
101
+ if isinstance(exc, GarminConnectAuthenticationError):
102
+ return "Garmin session expired. Run: homevitals --reauth garmin"
103
+ if isinstance(exc, GarminConnectConnectionError):
104
+ # Garmin appends the server's detail after " - "; it can echo the values we sent.
105
+ return f"Garmin upload failed ({str(exc).split(' - ', 1)[0]})"
106
+ if is_transient_network_error(str(exc)):
107
+ return str(exc)
108
+ if isinstance(exc, ValueError):
109
+ text = str(exc)
110
+ # Our own setup errors keep their wording; anything else came from the Garmin library's range check.
111
+ if "OMRON connect accounts created in" in text or "timezone-aware" in text:
112
+ return text
113
+ return "Garmin rejected a value (outside its accepted range)."
114
+ return f"Unexpected error ({type(exc).__name__}). Details are in the log file."
115
+
116
+
117
+ def _retry_quietly(fn: Callable[[], T], description: str) -> T:
118
+ """sync._retry, but its log lines carry safe_error_text instead of the raw error."""
119
+ for attempt in range(MAX_RETRIES):
120
+ try:
121
+ return fn()
122
+ except Exception as e:
123
+ if _is_permanent(e) or isinstance(e, ValueError) or attempt == MAX_RETRIES - 1:
124
+ raise
125
+ delay = RETRY_BASE_DELAY * (2 ** attempt)
126
+ logger.warning("%s failed (attempt %d/%d): %s. Retrying in %ds...",
127
+ description, attempt + 1, MAX_RETRIES, safe_error_text(e), delay)
128
+ time.sleep(delay)
129
+ raise AssertionError("unreachable")
130
+
131
+
132
+ def _record(state: SyncState, user_name: str, reading: BloodPressureReading, response: str | None) -> None:
133
+ state.record_sync(
134
+ user_name, reading.reading_id, reading.timestamp.astimezone(timezone.utc).isoformat(), None,
135
+ datetime.now(timezone.utc).isoformat(), target=BP_TARGET, response=response,
136
+ )
137
+
138
+
139
+ def sync_blood_pressure(user: UserConfig, state: SyncState, *, headless: bool = False, dry_run: bool = False,
140
+ backfill_days: int | None = None, now: datetime | None = None) -> BpSyncResult | None:
141
+ """Move this person's new OMRON connect readings to their own Garmin account.
142
+
143
+ None when the person has no omron section. Login and fetch failures
144
+ raise (the caller reports them); a failed upload stops the loop and is
145
+ returned in result.error with the counts so far.
146
+ """
147
+ if user.omron is None:
148
+ return None
149
+ if user.garmin is None:
150
+ raise PermanentSyncError(f"Blood pressure for {user.name} has nowhere to go: add a garmin section.")
151
+
152
+ now = now or datetime.now(timezone.utc)
153
+ since = window_start(state, user.name, backfill_days, now)
154
+ result = BpSyncResult()
155
+ omron = OmronClient(user.omron)
156
+ garmin = GarminClient(user.garmin)
157
+ try:
158
+ omron.authenticate()
159
+ readings = _retry_quietly(lambda: omron.fetch_readings(since), "OMRON connect fetch")
160
+ result.fetched = len(readings)
161
+ candidates = sorted((r for r in readings if not state.is_synced(user.name, r.reading_id, BP_TARGET)),
162
+ key=lambda r: r.timestamp)
163
+ result.skipped_synced = result.fetched - len(candidates)
164
+ if not candidates:
165
+ logger.info("No new blood pressure readings for %s", user.name)
166
+ return result
167
+
168
+ garmin.authenticate(allow_interactive=not headless)
169
+ # One day of padding each side: a reading's own local date can differ from this machine's.
170
+ existing = [] if dry_run else garmin.blood_pressure_instants(since.astimezone().date() - timedelta(days=1),
171
+ now.astimezone().date() + timedelta(days=1))
172
+ for reading in candidates:
173
+ if not garmin_accepts(reading):
174
+ result.skipped_out_of_range += 1
175
+ if not dry_run:
176
+ _record(state, user.name, reading, SKIPPED_OUT_OF_RANGE_RESPONSE)
177
+ continue
178
+ if any(abs((reading.timestamp - inst).total_seconds()) <= GARMIN_SAME_TIME_TOLERANCE_SECONDS
179
+ for inst in existing):
180
+ result.skipped_in_garmin += 1
181
+ if not dry_run:
182
+ _record(state, user.name, reading, state_module.SKIPPED_IN_GARMIN_RESPONSE)
183
+ continue
184
+ if dry_run:
185
+ result.uploaded += 1
186
+ continue
187
+ try:
188
+ _retry_quietly(lambda r=reading: garmin.upload_blood_pressure(r, notes_for(r)),
189
+ "Garmin blood pressure upload")
190
+ except ValueError:
191
+ # The library's own range check; belt and braces. Never adjusted.
192
+ result.skipped_out_of_range += 1
193
+ _record(state, user.name, reading, SKIPPED_OUT_OF_RANGE_RESPONSE)
194
+ continue
195
+ except Exception as e:
196
+ result.error = safe_error_text(e)
197
+ logger.error("Blood pressure upload failed for %s: %s", user.name, result.error)
198
+ break
199
+ _record(state, user.name, reading, None)
200
+ result.uploaded += 1
201
+ time.sleep(1)
202
+
203
+ logger.info("Blood pressure for %s: %d uploaded, %d already synced, %d already in Garmin, "
204
+ "%d outside Garmin's range", user.name, result.uploaded, result.skipped_synced,
205
+ result.skipped_in_garmin, result.skipped_out_of_range)
206
+ return result
207
+ finally:
208
+ for client in (omron, garmin):
209
+ try:
210
+ client.close()
211
+ except Exception:
212
+ logger.debug("Closing a client failed", exc_info=True)
@@ -0,0 +1,3 @@
1
+ from homevitals.cli.app import main
2
+
3
+ __all__ = ["main"]