├── .gitignore ├── requirements.txt ├── requirements-dev.txt ├── regionserver.py ├── pyproject.toml ├── README.md ├── utils.py ├── LICENSE ├── omronconnect.py └── omramin.py /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/** 2 | .debug/** 3 | .info/** 4 | **/__pycache__/** 5 | **/.*_cache/** 6 | **/*.egg-info/** 7 | .other/** 8 | dist/** 9 | build/** 10 | .env 11 | config*.json 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bleak==2.0.0 2 | click==8.3.1 3 | garminconnect==0.2.34 4 | httpx[http2,cli,brotli]==0.28.1 5 | inquirer==3.4.1 6 | json5==0.12.1 7 | python-dateutil==2.9.0.post0 8 | pytz==2025.2 9 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | garminconnect 2 | click 3 | brotlicffi 4 | httpx[http2,cli,brotli] 5 | python-dateutil 6 | json5 7 | pytz 8 | inquirer 9 | bleak 10 | 11 | mypy 12 | types-python-dateutil 13 | types-pytz 14 | autopep8 15 | flake8 16 | flake8-pyproject 17 | ruff 18 | pylint 19 | isort 20 | 21 | # pytest 22 | # coverage 23 | pytest-cov 24 | 25 | twine 26 | build 27 | -------------------------------------------------------------------------------- /regionserver.py: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | 3 | import typing as T # isort: split 4 | 5 | ######################################################################################################################## 6 | 7 | 8 | def get_server_for_region(region: str) -> T.Optional[str]: 9 | servers = { 10 | "ASIA/PACIFIC": "https://data-sg.omronconnect.com", 11 | "EUROPE": "https://oi-api.ohiomron.eu", 12 | "NORTH AMERICA": "https://oi-api.ohiomron.com", 13 | } 14 | 15 | return servers.get(region.upper(), None) 16 | 17 | 18 | def get_server_for_country_code(country_code: str) -> T.Optional[str]: 19 | # Define the mapping of regions to countries and their codes 20 | regions = { 21 | "ASIA/PACIFIC": [ 22 | "AF", 23 | "AU", 24 | "BD", 25 | "BN", 26 | "BT", 27 | "KH", 28 | "CN", 29 | "FJ", 30 | "HK", 31 | "IN", 32 | "ID", 33 | # "JP", 34 | "KR", 35 | "LA", 36 | "MY", 37 | "MN", 38 | "MM", 39 | "NP", 40 | "NZ", 41 | "PK", 42 | "PG", 43 | "PH", 44 | "SG", 45 | "LK", 46 | "TW", 47 | "TH", 48 | "TL", 49 | "VN", 50 | ], 51 | "EUROPE": [ 52 | "AL", 53 | "AD", 54 | "AT", 55 | "BY", 56 | "BE", 57 | "BA", 58 | "BG", 59 | "HR", 60 | "CZ", 61 | "DK", 62 | "EE", 63 | "FI", 64 | "FR", 65 | "DE", 66 | "GR", 67 | "HU", 68 | "IS", 69 | "IE", 70 | "IT", 71 | "LV", 72 | "LI", 73 | "LT", 74 | "LU", 75 | "MT", 76 | "MC", 77 | "ME", 78 | "NL", 79 | "MK", 80 | "NO", 81 | "PL", 82 | "PT", 83 | "RO", 84 | "RU", 85 | "SM", 86 | "RS", 87 | "SK", 88 | "SI", 89 | "ES", 90 | "SE", 91 | "CH", 92 | "UA", 93 | "GB", 94 | "VA", 95 | ], 96 | "NORTH AMERICA": ["CA", "MX", "US", "BZ", "CR", "SV", "GT", "HN", "NI", "PA"], 97 | "SOUTH AMERICA": ["AR", "BO", "BR", "CL", "CO", "EC", "GY", "PY", "PE", "SR", "UY", "VE"], 98 | "AFRICA": [ 99 | "DZ", 100 | "AO", 101 | "BJ", 102 | "BW", 103 | "BF", 104 | "BI", 105 | "CM", 106 | "CV", 107 | "CF", 108 | "TD", 109 | "KM", 110 | "CI", 111 | "CD", 112 | "DJ", 113 | "EG", 114 | "GQ", 115 | "ER", 116 | "ET", 117 | "GA", 118 | "GM", 119 | "GH", 120 | "GN", 121 | "GW", 122 | "KE", 123 | "LS", 124 | "LR", 125 | "LY", 126 | "MG", 127 | "MW", 128 | "ML", 129 | "MR", 130 | "MA", 131 | "MZ", 132 | "NA", 133 | "NE", 134 | "NG", 135 | "RW", 136 | "SN", 137 | "SC", 138 | "SL", 139 | "SO", 140 | "ZA", 141 | "SS", 142 | "SD", 143 | "SZ", 144 | "TZ", 145 | "TG", 146 | "TN", 147 | "UG", 148 | "ZM", 149 | "ZW", 150 | ], 151 | "MIDDLE EAST": ["BH", "CY", "IR", "IQ", "IL", "JO", "KW", "LB", "OM", "PS", "QA", "SA", "SY", "TR", "AE", "YE"], 152 | } 153 | 154 | country_code = country_code.upper() 155 | if country_code == "JP": 156 | return "https://oi-api.ohiomron.jp" 157 | 158 | for region, codes in regions.items(): 159 | if country_code in codes: 160 | return get_server_for_region(region) 161 | 162 | return None 163 | 164 | 165 | ######################################################################################################################## 166 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | authors = [ 3 | {name = "bugficks"}, 4 | ] 5 | classifiers = [ 6 | "Programming Language :: Python :: 3", 7 | "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", 8 | "Operating System :: MacOS :: MacOS X", 9 | "Operating System :: Microsoft :: Windows", 10 | "Operating System :: POSIX :: Linux", 11 | ] 12 | dependencies = [ 13 | "bleak>=0.22.3", 14 | "click>=8.1.7", 15 | "garminconnect>=0.2.25", 16 | "httpx[http2,cli,brotli]>=0.28.1", 17 | "inquirer>=3.4.0", 18 | "json5>=0.10.0", 19 | "python-dateutil>=2.9.0.post0", 20 | "pytz>=2025.1", 21 | ] 22 | description = "Sync blood pressure and weight measurements from OMRON connect to Garmin Connect" 23 | dynamic = ["version"] 24 | keywords = ["garmin connect", "garmin", "omron", "omron connect", "sync"] 25 | license = {file = "LICENSE"} 26 | name = "omramin" 27 | readme = "README.md" 28 | requires-python = ">=3.11" 29 | #version = "0.1.1" 30 | 31 | [project.urls] 32 | "Bug Tracker" = "https://github.com/bugficks/omramin/issues" 33 | #"Changelog" = "https://github.com/bugficks/omramin/blob/master/CHANGELOG.md" 34 | #"Documentation" = "https://github.com/bugficks/omramin/blob/master/README.md" 35 | "Homepage" = "https://github.com/bugficks/omramin" 36 | 37 | [build-system] 38 | build-backend = "setuptools.build_meta" 39 | requires = ["setuptools>=64"] #, "setuptools-scm > 8"] 40 | 41 | [tool.setuptools.dynamic] 42 | version = {attr = "omramin.__version__"} 43 | 44 | [tool.setuptools_scm] 45 | 46 | [tool.setuptools.packages.find] 47 | exclude = [] # exclude packages matching these glob patterns (empty by default) 48 | include = ["omramin"] # package names should match these glob patterns (["*"] by default) 49 | namespaces = false # to disable scanning PEP 420 namespaces (true by default) 50 | where = ["."] # list of folders that contain the packages (["."] by default) 51 | 52 | [project.scripts] 53 | omramin = "omramin:cli" 54 | 55 | [tool.setuptools] 56 | py-modules = ["omramin", "omronconnect", "utils", "regionserver"] 57 | 58 | # [tool.distutils.egg_info] 59 | # egg_base = "build" 60 | 61 | ######################################################################################################################## 62 | 63 | [tool.pylint.main] 64 | # make pylint ignore tool.ruff settings 65 | disable = ["unrecognized-option"] 66 | ignore = [ 67 | ".git", 68 | ".*_cache", 69 | "__pycache__", 70 | ".venv", 71 | ".info", 72 | ".other", 73 | ".debug", 74 | ".vscode", 75 | "pyproject.toml", 76 | ] 77 | 78 | [tool.pylint.messages_control] 79 | disable = [ 80 | "C0103", # doesn't conform to snake_case naming style (invalid-name) 81 | "C0114", # missing-module-docstring 82 | "C0115", # missing-class-docstring 83 | "C0116", # missing-function-docstring 84 | "W1203", # Use lazy % formatting in logging functions (logging-fstring-interpolation) 85 | ] 86 | 87 | [tool.ruff] 88 | exclude = [ 89 | ".git", 90 | ".*_cache", 91 | "__pycache__", 92 | ".venv", 93 | ".info", 94 | ".other", 95 | ".debug", 96 | ".vscode", 97 | ] 98 | indent-width = 4 99 | line-length = 120 100 | 101 | [tool.mypy] 102 | exclude = [ 103 | ".git", 104 | ".*_cache", 105 | "__pycache__", 106 | ".venv", 107 | ".info", 108 | ".other", 109 | ".debug", 110 | ".vscode", 111 | ] 112 | 113 | [[tool.mypy.overrides]] 114 | ignore_missing_imports = true 115 | module = [ 116 | "pytz", 117 | "garminconnect", 118 | "inquirer", 119 | "python-dateutil", 120 | "dateutil", 121 | "dateutil.parser", 122 | ] 123 | 124 | [tool.flake8] 125 | exclude = [ 126 | ".git", 127 | ".*_cache", 128 | "__pycache__", 129 | ".venv", 130 | ".info", 131 | ".other", 132 | ".debug", 133 | ".vscode", 134 | ] 135 | extend-ignore = [] 136 | # E402 - module level import not at top of file 137 | count = true 138 | ignore = "E402" 139 | max-complexity = 15 140 | max-line-length = 120 141 | 142 | [tool.isort] 143 | force_grid_wrap = 0 144 | include_trailing_comma = true 145 | line_length = 120 146 | multi_line_output = 3 147 | skip_gitignore = true 148 | skip_glob = [ 149 | ".git", 150 | ".*_cache", 151 | "__pycache__", 152 | ".venv", 153 | ".info", 154 | ".other", 155 | ".debug", 156 | ".vscode", 157 | ] 158 | use_parentheses = true 159 | 160 | [tool.black] 161 | line-length = 120 162 | 163 | [tool.pylint.logging] 164 | # The type of string formatting that logging methods do. `old` means using % 165 | # formatting, `new` is for `{}` formatting. 166 | logging-format-style = "new" 167 | 168 | [tool.pylint.format] 169 | indent-width = 4 170 | max-line-length = 120 171 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # omramin 2 | 3 | Sync blood pressure and weight measurements from _OMRON connect_ to _Garmin Connect_. 4 | 5 | OMRON Connect utilizes two distinct API versions: 6 | 7 | API v1: Primarily used in the Asia/Pacific region 8 | API v2: Implemented in other global regions 9 | 10 | Note: The current implementation has been tested mostly with API v1. Feedback and testing for API v2 integration are welcomed. 11 | For testing and development purposes, it is strongly recommended to use a secondary Garmin Connect account. 12 | 13 | ## Features 14 | 15 | Supports weight and blood pressure measurements. 16 | 17 | ## Table of Contents 18 | 19 | - [Installation](#installation) 20 | - [Updating](#update) 21 | - [Usage](#usage) 22 | - [Adding a Device](#adding-a-device) 23 | - [Synchronizing to Garmin Connect](#synchronizing-to-garmin-connect) 24 | - [Commands](#commands) 25 | - [Related Projects](#related-projects) 26 | - [Contributing](#contributing) 27 | - [License](#license) 28 | 29 | ### Installation 30 | 31 | 1. Clone the repository: 32 | 33 | ``` 34 | git clone https://github.com/bugficks/omramin.git 35 | cd omramin 36 | ``` 37 | 38 | 2. Create and activate a virtual environment: 39 | 40 | ``` 41 | python -m venv .venv 42 | source .venv/bin/activate # On Windows, use `.venv\Scripts\activate` 43 | ``` 44 | 45 | 3. Install the required dependencies: 46 | 47 | ``` 48 | pip install -Ue . 49 | ``` 50 | 51 | ### Update 52 | 53 | ``` 54 | git pull 55 | source .venv/bin/activate # On Windows, use `.venv\Scripts\activate` 56 | pip install -Ue . 57 | ``` 58 | 59 | ## Usage 60 | 61 | ### Adding a device: 62 | 63 | Adding a device requires the MAC address of the device. 64 | 65 | #### Interactively: 66 | 67 | To add a new OMRON device, ensure it's in pairing mode and run: 68 | 69 | ```sh 70 | omramin add 71 | ``` 72 | 73 | #### By MAC address: 74 | 75 | If MAC address is known run e.g.: 76 | 77 | ```sh 78 | omramin add -m 00:11:22:33:44:55 79 | omramin add -m 00:11:22:33:44:55 --category scale --name "My Scale" --user 3 80 | ``` 81 | 82 | ### Synchronizing to Garmin Connect: 83 | 84 | To sync data from your OMRON device to Garmin Connect: 85 | 86 | ```sh 87 | omramin sync --days 1 88 | ``` 89 | 90 | This will synchronize data for the today and yesterday. Adjust the --days parameter as needed. 91 | If this is first time you will be asked to enter login information for both _Garmin Connect_ and _OMRON connect_. 92 | 93 | ```log 94 | [2024-11-14 08:04:20] [I] Garmin login 95 | [?] > Enter email: user@garmin.connect 96 | [?] > Enter password: ******************* 97 | [?] > Is this a Chinese account? (y/N): 98 | [?] > Enter MFA/2FA code: xxxxxx 99 | [2024-11-14 08:04:58] [I] Logged in to Garmin Connect 100 | [2024-11-14 08:04:59] [I] Omron login 101 | [?] > Enter email: user@omron.connect 102 | [?] > Enter password: ******************** 103 | [?] > Enter country code (e.g. 'US'): XX 104 | [2024-11-14 08:05:31] [I] Logged in to OMRON connect 105 | [2024-11-14 08:05:31] [I] Start synchronizing device 'Scale HBF-702T' from 2024-11-13T00:00:00 to 2024-11-14T23:59:59 106 | [2024-11-14 08:05:31] [I] Downloaded 2 entries from 'OMRON connect' for 'Scale HBF-702T' 107 | [2024-11-14 08:05:32] [I] Downloaded 1 weigh-ins from 'Garmin Connect' 108 | [2024-11-14 08:05:32] [I] + '2024-11-14T07:56:33+07:00' adding weigh-in: xy.z kg 109 | [2024-11-14 08:05:32] [I] - '2024-11-13T07:36:01+07:00' weigh-in already exists 110 | [2024-11-14 08:05:32] [I] Device 'Scale HBF-702T' successfully synced. 111 | [2024-11-14 08:05:32] [I] Start synchronizing device 'BPM HEM-7600T' from 2024-11-13T00:00:00 to 2024-11-14T23:59:59 112 | [2024-11-14 08:05:32] [I] Downloaded 4 entries from 'OMRON connect' for 'BPM HEM-7600T' 113 | [2024-11-14 08:05:32] [I] Downloaded 3 bpm measurements from 'Garmin Connect' 114 | [2024-11-14 08:05:32] [I] + '2024-11-14T07:58:23+07:00' adding blood pressure (xxx/yy mmHg, zz bpm) 115 | [2024-11-14 08:05:33] [I] - '2024-11-13T19:57:30+07:00' blood pressure already exists 116 | [2024-11-14 08:05:33] [I] - '2024-11-13T15:05:18+07:00' blood pressure already exists 117 | [2024-11-14 08:05:33] [I] - '2024-11-13T07:46:41+07:00' blood pressure already exists 118 | [2024-11-14 08:05:33] [I] Device 'BPM HEM-7600T' successfully synced. 119 | ``` 120 | 121 | ### Commands 122 | 123 | | Command | Description | 124 | | ------- | ------------------------------------------------- | 125 | | add | Add new OMRON device | 126 | | config | Configure a device by name or MAC address | 127 | | export | Export device measurements to CSV or JSON format. | 128 | | list | List all configured devices | 129 | | remove | Remove a device by name or MAC address | 130 | | sync | Sync device(s) to Garmin Connect | 131 | 132 | For more details on each command, use: 133 | 134 | ```sh 135 | omramin [COMMAND] --help 136 | ``` 137 | 138 | ## Related Projects 139 | 140 | - [export2garmin](https://github.com/RobertWojtowicz/export2garmin): A project that allows automatic synchronization of data from Mi Body Composition Scale 2 and Omron blood pressure monitors to Garmin Connect. 141 | 142 | ## Contributing 143 | 144 | Contributions are welcome! Please feel free to submit a Pull Request. 145 | 146 | ## License 147 | 148 | This project is licensed under the GPLv2 License. 149 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | 3 | import typing as T # isort: split 4 | 5 | import collections.abc 6 | import dataclasses 7 | import json 8 | import pathlib 9 | import re 10 | import types 11 | from copy import deepcopy 12 | from datetime import date, datetime, time, timedelta, timezone, tzinfo 13 | from difflib import SequenceMatcher 14 | from functools import reduce 15 | 16 | import json5 17 | from dateutil.parser import parse as dateutil_parse 18 | 19 | ######################################################################################################################## 20 | 21 | KeyType = T.TypeVar("KeyType") 22 | ValueType = T.TypeVar("ValueType") 23 | 24 | ######################################################################################################################## 25 | 26 | # match case insensitive UUIDs with or without dashes 27 | RX_UUID = re.compile(r"([0-9a-f]{32}|[0-9a-f-]{36})\Z", re.I) 28 | RX_MACADDR = re.compile(r"^([0-9a-f]{2}[:-]){5}([0-9a-f]{2})$", re.I) 29 | 30 | ######################################################################################################################## 31 | 32 | 33 | # https://stackoverflow.com/a/17388505 34 | def strsimilar(a: str, b: str) -> float: 35 | return SequenceMatcher(None, a, b).ratio() 36 | 37 | 38 | def sum_dict_value(d: T.Dict[T.Any, T.Any], key) -> T.Any: 39 | return reduce(lambda a, b: a + b, map(lambda o: o[key], d)) 40 | 41 | 42 | ######################################################################################################################## 43 | 44 | 45 | def is_valid_macaddr(macaddr: str) -> bool: 46 | return bool(RX_MACADDR.match(macaddr)) 47 | 48 | 49 | ######################################################################################################################## 50 | 51 | 52 | class DataclassBase: 53 | # e.g. 54 | # class SomeEnum(DataclassBase, StrEnum): 55 | # ... 56 | # @dataclass(kw_only=True) 57 | # class SomeClass(DataclassBase): 58 | # ... 59 | 60 | def to_dict(self) -> T.Dict[T.Any, T.Any]: 61 | if dataclasses.is_dataclass(self): 62 | return dataclasses.asdict(self) 63 | 64 | if isinstance(self, types.SimpleNamespace): 65 | return self.__dict__.copy() 66 | 67 | raise TypeError(f"'{self}' is not a dataclass or SimpleNamespace") 68 | 69 | def to_json(self) -> str: 70 | return json_beautify(self.to_dict()) 71 | 72 | def keys(self): 73 | return self.__dict__.keys() 74 | 75 | def items(self): 76 | return self.__dict__.items() 77 | 78 | @classmethod 79 | def from_dict(cls, obj): 80 | """Ignore extra keys/fields when creating dataclass from dict""" 81 | fieldnames = [f.name for f in dataclasses.fields(cls)] 82 | # https://stackoverflow.com/a/55096964 83 | return cls(**{k: v for k, v in obj.items() if k in fieldnames}) 84 | 85 | 86 | class SimpleNamespaceEx(types.SimpleNamespace, DataclassBase): 87 | pass 88 | 89 | 90 | ######################################################################################################################## 91 | 92 | 93 | # https://stackoverflow.com/a/51286749 94 | class EnhancedJSONEncoder(json.JSONEncoder): 95 | def default(self, o: T.Any) -> T.Any: 96 | if dataclasses.is_dataclass(o): 97 | return dataclasses.asdict(o) # type: ignore[call-overload,arg-type] 98 | if isinstance(o, types.SimpleNamespace): 99 | return o.__dict__ 100 | if isinstance(o, tzinfo): 101 | return str(o) 102 | return super().default(o) 103 | 104 | 105 | def json_beautify(obj: T.Any) -> str: 106 | return json.dumps(obj, indent=4, sort_keys=True, cls=EnhancedJSONEncoder) 107 | 108 | 109 | def json_print(obj) -> None: 110 | print(json_beautify(obj)) 111 | 112 | 113 | def json_save(fname: T.Union[pathlib.Path, str], obj: T.Any) -> None: 114 | with open(fname, "w", encoding="utf-8") as f: 115 | json.dump(obj, f, indent=4, sort_keys=False, cls=EnhancedJSONEncoder) 116 | 117 | 118 | def json_load(fname: T.Union[pathlib.Path, str], object_hook=None) -> T.Any: 119 | with open(fname, "r", encoding="utf-8") as f: 120 | return json5.load(f, object_hook=object_hook) 121 | # return json.load(f, object_hook=lambda d: types.SimpleNamespace(**d)) 122 | 123 | 124 | def json_load_file(f: T.IO, object_hook=None) -> T.Any: 125 | f.seek(0) 126 | return json5.load(f, object_hook=object_hook) 127 | 128 | 129 | def json_save_file(f: T.IO, obj: T.Any) -> None: 130 | f.seek(0) 131 | f.truncate() 132 | json.dump(obj, f, indent=4, sort_keys=False, cls=EnhancedJSONEncoder) 133 | 134 | 135 | ######################################################################################################################## 136 | 137 | 138 | def utcnow() -> datetime: 139 | return datetime.now(tz=timezone.utc) 140 | 141 | 142 | def utcfromtimestamp(ts: float) -> datetime: 143 | return datetime.fromtimestamp(ts, tz=timezone.utc) 144 | 145 | 146 | def utcdatetimefromstr(dt: str) -> datetime: 147 | return dateutil_parse(dt).astimezone(timezone.utc) 148 | 149 | 150 | def utcdatefromstr(dt: str) -> date: 151 | return utcdatetimefromstr(dt).date() 152 | 153 | 154 | def utctimefromstr(dt: str) -> time: 155 | return utcdatetimefromstr(dt).time() 156 | 157 | 158 | def utcfromtimestamp_isoformat(timestamp, timespec="seconds") -> str: 159 | return utcfromtimestamp(timestamp).isoformat(timespec=timespec).replace("+00:00", "Z") 160 | 161 | 162 | # https://codeigo.com/python/remove-seconds-from-datetime/ 163 | def minuteround(dt: datetime) -> datetime: 164 | # Round to the nearest minute. If second<30 set it to zero and leave minutes 165 | # unchanges. Otherwise set seconds to zero and increase minutes by 1. 166 | return dt.replace(second=0, microsecond=0, hour=dt.hour) + timedelta(minutes=dt.second // 30) 167 | 168 | 169 | # https://stackoverflow.com/a/1060330 170 | def daterange(start_date: date, end_date: date) -> T.Generator[date, None, None]: 171 | for n in range(int((end_date - start_date).days)): 172 | yield start_date + timedelta(n) 173 | 174 | 175 | def datefromdatetime(dt: datetime) -> date: 176 | return datetime.combine(dt.date(), datetime.min.time()) 177 | 178 | 179 | ######################################################################################################################## 180 | 181 | 182 | # https://stackoverflow.com/a/3233356 183 | def deep_update(d: T.Dict[KeyType, T.Any], u: T.Dict[KeyType, T.Any], *, existing=True) -> T.Dict[KeyType, T.Any]: 184 | r = d.copy() 185 | for k, v in u.items(): 186 | if existing and k not in r: 187 | continue 188 | # pylint: disable-next=no-member 189 | if isinstance(v, collections.abc.Mapping) and isinstance(v, dict): 190 | r[k] = deep_update(r.get(k, type(r)()), v) 191 | else: 192 | r[k] = v 193 | return r 194 | 195 | 196 | # https://stackoverflow.com/a/43228384 197 | def deep_merge(d: T.Dict[KeyType, T.Any], u: T.Dict[KeyType, T.Any], *, existing=True) -> T.Dict[KeyType, T.Any]: 198 | """Return a new dictionary by merging two dictionaries recursively.""" 199 | 200 | r = deepcopy(d) 201 | 202 | for k, v in u.items(): 203 | if existing and k not in d: 204 | continue 205 | # pylint: disable-next=no-member 206 | if isinstance(v, collections.abc.Mapping) and isinstance(v, dict): 207 | r[k] = deep_merge(r.get(k, type(r)()), v) 208 | else: 209 | r[k] = deepcopy(u[k]) 210 | 211 | return r 212 | 213 | 214 | ######################################################################################################################## 215 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /omronconnect.py: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | 3 | import typing as T # isort: split 4 | 5 | import datetime 6 | import enum 7 | import hashlib 8 | import json 9 | import logging 10 | from abc import ABC, abstractmethod 11 | from dataclasses import dataclass 12 | 13 | import httpx 14 | import pytz 15 | 16 | import utils as U 17 | 18 | ######################################################################################################################## 19 | 20 | L = logging.getLogger("omron") 21 | 22 | _debugSaveResponse = False 23 | 24 | ######################################################################################################################## 25 | 26 | 27 | class Gender(enum.IntEnum): 28 | MALE = 1 29 | FEMALE = 2 30 | 31 | 32 | class VolumeUnit(enum.IntEnum): 33 | MGDL = 24577 34 | MMOLL = 24593 35 | 36 | 37 | class LengthUnit(enum.IntEnum): 38 | CM = 4098 39 | INCH = 4113 40 | KM = 4099 41 | MILE = 4112 42 | 43 | 44 | class WeightUnit(enum.IntEnum): 45 | G = 8192 46 | KG = 8195 47 | LB = 8208 48 | ST = 8224 49 | 50 | 51 | class ValueUnit(enum.IntEnum): 52 | STEPS = 61536 53 | BPM = 61600 54 | PERCENTAGE = 61584 55 | KCAL = 16387 56 | 57 | 58 | class BPUnit(enum.IntEnum): 59 | MMHG = 20496 60 | KPA = 20483 61 | 62 | 63 | class TemperatureUnit(enum.IntEnum): 64 | CELSIUS = 12288 65 | FAHRENHEIT = 12304 66 | 67 | 68 | ######################################################################################################################## 69 | 70 | 71 | class ValueType(enum.StrEnum): 72 | MMHG_MAX_FIGURE = "1" # ("%1$,3.0f", 1, 20496, R.string.msg0000808, R.string.msg0020959), 73 | KPA_MAX_FIGURE = "1" # ("%.1f", 1, 20483, R.string.msg0000809, R.string.msg0020993), 74 | MMHG_MIN_FIGURE = "2" # ("%1$,3.0f", 2, 20496, R.string.msg0000808, R.string.msg0020959), 75 | KPA_MIN_FIGURE = "2" # ("%.1f", 2, 20483, R.string.msg0000809, R.string.msg0020993), 76 | BPM_FIGURE = "3" # ("%1$,3.0f", 3, 61600, R.string.msg0000815, R.string.msg0020960), 77 | ATTITUDE_FIGURE = "4" # ("%.0f", 4, -1, -1, -1), 78 | ROOM_TEMPERATURE_FIGURE = "5" # ("%.0f", 5, -1, R.string.msg0000823, R.string.msg0020972), 79 | ARRHYTHMIA_FLAG_FIGURE = "6" # ("%.0f", 6, -1, -1, -1), 80 | BODY_MOTION_FLAG_FIGURE = "7" # ("%.0f", 7, -1, -1, -1), 81 | POSTURE_GUIDE = "20" # ("%.0f", 20, -1, -1, -1), 82 | KEEP_UP_CHECK_FIGURE = "8" # ("%.0f", 8, -1, -1, -1), 83 | PULSE_QUIET_CHECK_FIGURE = "9" # ("%.0f", 9, -1, -1, -1), 84 | CONTINUOUS_MEASUREMENT_COUNT_FIGURE = "10" # ("%1$,3.0f", 10, -1, R.string.msg0000821, R.string.msg0000821), 85 | ARTIFACT_COUNT_FIGURE = "11" # ("%1$,3.0f", 11, -1, R.string.msg0000821, R.string.msg0000821), 86 | IRREGULAR_PULSE_COUNT_FIGURE = "37" # ("%1$,3.0f", 37, -1, R.string.msg0020505, R.string.msg0020505), 87 | MEASUREMENT_MODE_FIGURE = "38" # ("%.0f", 38, -1, -1, -1), 88 | NOCTURNAL_ERROR_CODE_FIGURE = "41" # ("%.0f", 41, -1, -1, -1), 89 | NNOCTURNAL_ERROR_CODE_DISPLAY_FIGURE = "45" # ("%.0f", 45, -1, -1, -1), 90 | KG_FIGURE = "257" # ("%.2f", 257, 8195, R.string.msg0000803, R.string.msg0020986), 91 | KG_SKELETAL_MUSCLE_MASS_FIGURE = "294" # ("%.1f", 294, 8195, R.string.msg0000803, R.string.msg0020986), 92 | KG_BODY_FAT_MASS_FIGURE = "295" # ("%.1f", 295, 8195, R.string.msg0000803, R.string.msg0020986), 93 | LB_FIGURE = "257" # ("%.1f", 257, 8208, R.string.msg0000804, R.string.msg0020994), 94 | ST_FIGURE = "257" # ("%.0f", 257, 8224, R.string.msg0000805, R.string.msg0020995), 95 | BODY_FAT_PER_FIGURE = "259" # ("%.1f", 259, 61584, R.string.msg0000817, R.string.msg0000817), 96 | VISCERAL_FAT_FIGURE = "264" # ("%1$,3.0f", 264, -1, R.string.msg0000816, R.string.msg0020987), 97 | VISCERAL_FAT_FIGURE_702T = "264" # ("%1$,3.1f", 264, -1, R.string.msg0000816, R.string.msg0020987), 98 | RATE_SKELETAL_MUSCLE_FIGURE = "261" # ("%.1f", 261, 61584, R.string.msg0000817, R.string.msg0000817), 99 | RATE_SKELETAL_MUSCLE_BOTH_ARMS_FIGURE = "275" # ("%.1f", 275, 61584, R.string.msg0000817, R.string.msg0000817), 100 | RATE_SKELETAL_MUSCLE_BODY_TRUNK_FIGURE = "277" # ("%.1f", 277, 61584, R.string.msg0000817, R.string.msg0000817), 101 | RATE_SKELETAL_MUSCLE_BOTH_LEGS_FIGURE = "279" # ("%.1f", 279, 61584, R.string.msg0000817, R.string.msg0000817), 102 | RATE_SUBCUTANEOUS_FAT_FIGURE = "281" # ("%.1f", 281, 61584, R.string.msg0000817, R.string.msg0000817), 103 | RATE_SUBCUTANEOUS_FAT_BOTH_ARMS_FIGURE = "283" # ("%.1f", 283, 61584, R.string.msg0000817, R.string.msg0000817), 104 | RATE_SUBCUTANEOUS_FAT_BODY_TRUNK_FIGURE = "285" # ("%.1f", 285, 61584, R.string.msg0000817, R.string.msg0000817), 105 | RATE_SUBCUTANEOUS_FAT_BOTH_LEGS_FIGURE = "287" # ("%.1f", 287, 61584, R.string.msg0000817, R.string.msg0000817), 106 | BIOLOGICAL_AGE_FIGURE = "263" # ("%1$,3.0f", 263, 61568, R.string.msg0000822, R.string.msg0020989), 107 | BASAL_METABOLISM_FIGURE = "260" # ("%1$,3.0f", 260, 16387, R.string.msg0000824, R.string.msg0020988), 108 | BMI_FIGURE = "262" # ("%.1f", 262, -1, -1, -1), 109 | BLE_BMI_FIGURE = "292" # ("%.1f", 292, -1, -1, -1), 110 | VISCERAL_FAT_CHECK_FIGURE = "265" # ("%.0f", 265, -1, -1, -1), 111 | RATE_SKELETAL_MUSCLE_CHECK_FIGURE = "266" # ("%.0f", 266, -1, -1, -1), 112 | RATE_SKELETAL_MUSCLE_BOTH_ARMS_CHECK_FIGURE = "276" # ("%.0f", 276, -1, -1, -1), 113 | RATE_SKELETAL_MUSCLE_BODY_TRUNK_CHECK_FIGURE = "278" # ("%.0f", 278, -1, -1, -1), 114 | RATE_SKELETAL_MUSCLE_BOTH_LEGS_CHECK_FIGURE = "280" # ("%.0f", 280, -1, -1, -1), 115 | RATE_SUBCUTANEOUS_FAT_CHECK_FIGURE = "282" # ("%.0f", 282, -1, -1, -1), 116 | RATE_SUBCUTANEOUS_FAT_BOTH_ARMS_CHECK_FIGURE = "284" # ("%.0f", 284, -1, -1, -1), 117 | RATE_SUBCUTANEOUS_FAT_BODY_TRUNK_CHECK_FIGURE = "286" # ("%.0f", 286, -1, -1, -1), 118 | RATE_SUBCUTANEOUS_FAT_BOTH_LEGS_CHECK_FIGURE = "288" # ("%.0f", 288, -1, -1, -1), 119 | IMPEDANCE_FIGURE = "267" # ("%.0f", 267, -1, -1, -1), 120 | WEIGHT_FFM_FIGURE = "268" # ("%.0f", 268, -1, -1, -1), 121 | AVERAGE_WEIGHT_FIGURE = "269" # ("%.0f", 269, -1, -1, -1), 122 | AVERAGE_WEIGHT_FFM_FIGURE = "270" # ("%.0f", 270, -1, -1, -1), 123 | MMOLL_FIGURE = "2305" # ("%.1f", 2305, 24593, R.string.msg0000811, R.string.msg0020975), 124 | MGDL_FIGURE = "2305" # ("%.0f", 2305, 24577, R.string.msg0000810, R.string.msg0020976), 125 | MEAL_FIGURE = "2306" # ("%.0f", 2306, -1, -1, -1), 126 | TYPE_FIGURE = "2307" # ("%.0f", 2307, -1, -1, -1), 127 | SAMPLE_LOCATION_FIGURE = "2308" # ("%.0f", 2308, -1, -1, -1), 128 | HIGH_LOW_DETECTION_FIGURE = "2309" # ("%.0f", 2309, -1, -1, -1), 129 | STEPS_FIGURE = "513" # ("%1$,3.0f", 513, 61536, R.string.msg0000833, R.string.msg0020991), 130 | TIGHTLY_STEPS = "514" # ("%1$,3.0f", 514, 61536, R.string.msg0000833, R.string.msg0020991), 131 | STAIR_UP_STEPS = "518" # ("%1$,3.0f", 518, 61536, R.string.msg0000833, R.string.msg0020991), 132 | BRISK_STEPS = "516" # ("%1$,3.0f", 516, 61536, R.string.msg0000833, R.string.msg0020991), 133 | KCAL_WALKING = "545" # ("%1$,3.0f", 545, 16387, R.string.msg0000824, R.string.msg0020988), 134 | KCAL_ACTIVITY = "546" # ("%1$,3.0f", 546, 16387, R.string.msg0000824, R.string.msg0020988), 135 | KCAL_FAT_BURNED = "579" # ("%.1f", 579, 8192, R.string.msg0000852, R.string.msg0020990), 136 | KCAL_ALLDAY = "548" # ("%1$,3.0f", 548, 16387, R.string.msg0000824, R.string.msg0020988), 137 | KM_FIGURE = "3" # ("%.1f", 3, 4099, R.string.msg0000801, R.string.msg0020992), 138 | KM_DISTANCE = "576" # ("%.1f", 576, 4099, R.string.msg0000801, R.string.msg0020992), 139 | TIME_SLEEP_START = "1025" # ("%d", 1025, 0, -1, -1), 140 | TIME_SLEEP_ONSET = "1026" # ("%d", 1026, 0, -1, -1), 141 | TIME_SLEEP_WAKEUP = "1027" # ("%d", 1027, 0, -1, -1), 142 | TIME_SLEEPING = "1028" # ("%d", 1028, 61488, R.string.msg0000866, R.string.msg0000866), 143 | TIME_SLEEPING_EFFICIENCY = "1029" # ("%.1f", 1029, 61584, R.string.msg0000817, R.string.msg0000817), 144 | TIME_SLEEP_AROUSAL = "1030" # ("%d", 1030, 61504, R.string.msg0000867, R.string.msg0000867), 145 | TEMPERATURE_BASAL = "1281" # ("%.2f", 1281, 12288, R.string.msg0000823, R.string.msg0020972), 146 | FAHRENHEIT_TEMPERATURE_BASAL = "1281" # ("%.2f", 1281, 12304, R.string.msg0000829, R.string.msg0020996), 147 | THERMOMETER_TEMPERATURE = "4866" # ("%.1f", 4866, 12288, R.string.msg0000823, R.string.msg0020972), 148 | FAHRENHEIT_THERMOMETER_TEMPERATURE = "4866" # ("%.1f", 4866, 12304, R.string.msg0000829, R.string.msg0020996), 149 | THERMOMETER_MEASUREMENT_MODE_PREDICTED = "4869" # ("%.0f", 4869, -1, -1, -1), 150 | THERMOMETER_MEASUREMENT_MODE_MEASURED = "4870" # ("%.0f", 4870, -1, -1, -1), 151 | MENSTRUATION_RECORD = "61442" # ("%.0f", 61442, -1, -1, -1), 152 | MILE_FIGURE = "576" # ("%.1f", 576, 4112, R.string.msg0000802, R.string.msg0020997), 153 | KCAL_DAY = "544" # ("%1$,3.0f", 544, 16387, R.string.msg0000824, R.string.msg0020988), 154 | KCAL_FIGURE = "3" # ("%1$,3.0f", 3, 16387, R.string.msg0000824, R.string.msg0020988), 155 | EVENT_RECORD = "61441" # ("%d", 61441, 0, -1, -1), 156 | MMHG_MEAN_ARTERIAL_PRESSURE_FIGURE = "16" # ("%1$,3.0f", 16, 20496, R.string.msg0000808, R.string.msg0020959), 157 | KPA_MEAN_ARTERIAL_PRESSURE_FIGURE = "16" # ("%.1f", 16, 20483, R.string.msg0000809, R.string.msg0020993), 158 | AFIB_DETECT_FIGURE = "35" # ("%.1f", 35, -1, -1, -1), 159 | AFIB_MODE_FIGURE = "39" # ("%.1f", 39, -1, -1, -1), 160 | ECG_BPM_FIGURE = "4143" # ("%1$,3.0f", 4143, 61600, R.string.msg0000815, R.string.msg0020960), 161 | SPO2_OXYGEN_SATURATION = "1537" # ("%.0f", 1537, 61584, R.string.msg0000817, R.string.msg0000817), 162 | SPO2_PULSE_RATE = "1538" # ("%.0f", 1538, 61600, R.string.msg0000815, R.string.msg0020960), 163 | THERMOMETER_TEMPERATURE_TYPE = "4871" # ("%.0f", 4871, -1, -1, -1) 164 | 165 | 166 | class DeviceCategory(enum.StrEnum): 167 | BPM = "0" 168 | SCALE = "1" 169 | # ACTIVITY = "2" 170 | # THERMOMETER = "3" 171 | # PULSE_OXIMETER = "4" 172 | 173 | 174 | @dataclass(frozen=True, kw_only=False) 175 | class BodyIndexList: 176 | value: int 177 | subtype: int 178 | unknown1: int 179 | measurementId: int 180 | 181 | def __post_init__(self): 182 | for field in ["value", "subtype", "unknown1", "measurementId"]: 183 | object.__setattr__(self, field, int(getattr(self, field))) 184 | 185 | 186 | ######################################################################################################################## 187 | 188 | 189 | @dataclass(frozen=True, kw_only=True) 190 | class BPMeasurement: 191 | systolic: int 192 | diastolic: int 193 | pulse: int 194 | measurementDate: int 195 | timeZone: datetime.tzinfo 196 | irregularHB: bool = False 197 | movementDetect: bool = False 198 | cuffWrapDetect: bool = True 199 | notes: str = "" 200 | 201 | def __post_init__(self): 202 | for field in ["systolic", "diastolic", "pulse", "measurementDate"]: 203 | object.__setattr__(self, field, int(getattr(self, field))) 204 | for field in ["irregularHB", "movementDetect", "cuffWrapDetect"]: 205 | object.__setattr__(self, field, bool(getattr(self, field))) 206 | if not isinstance(self.timeZone, datetime.tzinfo): 207 | object.__setattr__(self, "timeZone", pytz.timezone(self.timeZone)) 208 | 209 | 210 | @dataclass(frozen=True, kw_only=True) 211 | class WeightMeasurement: 212 | weight: float 213 | measurementDate: int 214 | timeZone: datetime.tzinfo 215 | bmiValue: float = -1.0 216 | bodyFatPercentage: float = -1.0 217 | restingMetabolism: float = -1.0 218 | skeletalMusclePercentage: float = -1.0 219 | visceralFatLevel: float = -1.0 220 | metabolicAge: int = -1 221 | notes: str = "" 222 | 223 | def __post_init__(self): 224 | for field in [ 225 | "weight", 226 | "bmiValue", 227 | "bodyFatPercentage", 228 | "restingMetabolism", 229 | "skeletalMusclePercentage", 230 | "visceralFatLevel", 231 | "metabolicAge", 232 | ]: 233 | object.__setattr__(self, field, float(getattr(self, field))) 234 | for field in ["measurementDate", "metabolicAge"]: 235 | object.__setattr__(self, field, int(getattr(self, field))) 236 | if not isinstance(self.timeZone, datetime.tzinfo): 237 | object.__setattr__(self, "timeZone", pytz.timezone(self.timeZone)) 238 | 239 | 240 | MeasurementTypes = T.Union[BPMeasurement, WeightMeasurement] 241 | 242 | ######################################################################################################################## 243 | 244 | 245 | def ble_mac_to_serial(mac: str) -> str: 246 | # e.g. 11:22:33:44:55:66 to 665544feff332211 247 | values = mac.split(":") 248 | serial = "".join(values[5:2:-1] + ["fe", "ff"] + values[2::-1]) 249 | return serial.lower() 250 | 251 | 252 | def convert_weight_to_kg(weight: T.Union[int, float], unit: int) -> float: 253 | if unit == WeightUnit.G: 254 | return weight / 1000 255 | if unit == WeightUnit.LB: 256 | return weight * 0.45359237 257 | if unit == WeightUnit.ST: 258 | return weight * 6.35029318 259 | 260 | return weight 261 | 262 | 263 | ######################################################################################################################## 264 | 265 | 266 | @dataclass(frozen=True, kw_only=True) 267 | class OmronDevice: 268 | name: str 269 | macaddr: str 270 | category: DeviceCategory 271 | user: int = 1 272 | enabled: bool = True 273 | 274 | def __post_init__(self): 275 | if not isinstance(self.category, DeviceCategory): 276 | try: 277 | object.__setattr__(self, "category", DeviceCategory.__members__[self.category.upper()]) 278 | 279 | except KeyError as exc: 280 | object.__setattr__(self, "enabled", False) 281 | raise ValueError(f"Device '{self.name}' has invalid category: '{self.category}'") from exc 282 | 283 | @property 284 | def serial(self) -> str: 285 | return ble_mac_to_serial(self.macaddr) 286 | 287 | 288 | ######################################################################################################################## 289 | 290 | 291 | def _http_add_checksum(request: httpx.Request): 292 | if request.method in ["POST", "DELETE"] and request.content: 293 | request.headers["Checksum"] = hashlib.sha256(request.content).hexdigest() 294 | 295 | 296 | ######################################################################################################################## 297 | 298 | 299 | class OmronConnect(ABC): 300 | @abstractmethod 301 | def login(self, email: str, password: str, country: str) -> T.Optional[str]: 302 | raise NotImplementedError 303 | 304 | @abstractmethod 305 | def refresh_oauth2(self, refresh_token: str, **kwargs: T.Any) -> T.Optional[str]: 306 | raise NotImplementedError 307 | 308 | @abstractmethod 309 | def get_user(self) -> T.Dict[str, T.Any]: 310 | raise NotImplementedError 311 | 312 | @abstractmethod 313 | def get_measurements( 314 | self, device: OmronDevice, searchDateFrom: int = 0, searchDateTo: int = 0 315 | ) -> T.List[MeasurementTypes]: 316 | raise NotImplementedError 317 | 318 | 319 | ######################################################################################################################## 320 | 321 | 322 | class OmronConnect1(OmronConnect): 323 | _APP_ID = "lou30y2xfa9f" 324 | _API_KEY = "392a4bdff8af4141944d30ca8e3cc860" 325 | _OGSC_APP_VERSION = "010.003.00001" 326 | _OGSC_SDK_VERSION = "000.101" 327 | 328 | _APP_URL = f"/api/apps/{_APP_ID}/server-code" 329 | 330 | _USER_AGENT = f"OmronConnect/{_OGSC_APP_VERSION}.001 CFNetwork/1335.0.3.4 Darwin/21.6.0)" 331 | 332 | _client = httpx.Client( 333 | headers={ 334 | "user-agent": _USER_AGENT, 335 | "X-OGSC-SDK-Version": _OGSC_SDK_VERSION, 336 | "X-OGSC-App-Version": _OGSC_APP_VERSION, 337 | "X-Kii-AppID": _APP_ID, 338 | "X-Kii-AppKey": _API_KEY, 339 | }, 340 | ) 341 | 342 | def __init__(self, server: str): 343 | self._server = server 344 | self._headers: T.Dict[str, str] = {} 345 | 346 | def login(self, email: str, password: str, country: str = "") -> T.Optional[str]: 347 | authData = { 348 | "username": email, 349 | "password": password, 350 | } 351 | r = self._client.post(f"{self._server}/api/oauth2/token", json=authData, headers=self._headers) 352 | r.raise_for_status() 353 | 354 | resp = r.json() 355 | try: 356 | access_token = resp["access_token"] 357 | refresh_token = resp["refresh_token"] 358 | self._headers["authorization"] = f"Bearer {access_token}" 359 | return refresh_token 360 | 361 | except KeyError: 362 | pass 363 | 364 | return None 365 | 366 | def refresh_oauth2(self, refresh_token: str, **kwargs: T.Any) -> T.Optional[str]: 367 | data = { 368 | "grant_type": "refresh_token", 369 | "refresh_token": refresh_token, 370 | } 371 | r = self._client.post(f"{self._server}/api/oauth2/token", json=data, headers=self._headers) 372 | r.raise_for_status() 373 | 374 | resp = r.json() 375 | try: 376 | access_token = resp["access_token"] 377 | refresh_token = resp["refresh_token"] 378 | self._headers["authorization"] = f"Bearer {access_token}" 379 | return refresh_token 380 | 381 | except KeyError: 382 | pass 383 | 384 | return None 385 | 386 | def get_user(self) -> T.Dict[str, T.Any]: 387 | r = self._client.get(f"{self._server}{self._APP_URL}/users/me", headers=self._headers) 388 | r.raise_for_status() 389 | return r.json() 390 | 391 | # utc timestamps 392 | def get_measurements( 393 | self, device: OmronDevice, searchDateFrom: int = 0, searchDateTo: int = 0 394 | ) -> T.List[MeasurementTypes]: 395 | data = { 396 | "containCorrectedDataFlag": 1, 397 | "containAllDataTypeFlag": 1, 398 | "deviceCategory": device.category, 399 | "deviceSerialID": device.serial, 400 | "userNumberInDevice": int(device.user), 401 | "searchDateFrom": searchDateFrom if searchDateFrom >= 0 else 0, 402 | "searchDateTo": int(U.utcnow().timestamp() * 1000) if searchDateTo <= 0 else searchDateTo, 403 | # "deviceModel": "OSG", 404 | } 405 | 406 | r = self._client.post( 407 | f"{self._server}{self._APP_URL}/versions/current/measureData", json=data, headers=self._headers 408 | ) 409 | r.raise_for_status() 410 | 411 | resp = r.json() 412 | L.debug(resp) 413 | 414 | returnedValue = resp["returnedValue"] 415 | try: 416 | if isinstance(returnedValue, list): 417 | returnedValue = returnedValue[0] 418 | if "errorCode" in returnedValue: 419 | L.error(f"get_measurements() -> {returnedValue}") 420 | return [] 421 | 422 | except KeyError: 423 | pass 424 | 425 | if not returnedValue: 426 | return [] 427 | 428 | if _debugSaveResponse: 429 | fname = f".debug/{data['searchDateTo']}_{device.category.name}_{device.serial}_{device.user}.json" 430 | U.json_save(fname, returnedValue) 431 | 432 | measurements: T.List[MeasurementTypes] = [] 433 | devCat = DeviceCategory(returnedValue["deviceCategory"]) 434 | deviceModelList = returnedValue["deviceModelList"] 435 | if deviceModelList is None: 436 | return measurements 437 | 438 | for devModel in deviceModelList: 439 | measurements.extend(self._process_device_model(devModel, device, devCat)) 440 | 441 | return measurements 442 | 443 | def _process_device_model( 444 | self, devModel: T.Dict[str, T.Any], device: OmronDevice, devCat: DeviceCategory 445 | ) -> T.List[MeasurementTypes]: 446 | measurements: T.List[MeasurementTypes] = [] 447 | deviceModel = devModel["deviceModel"] 448 | deviceSerialIDList = devModel["deviceSerialIDList"] 449 | for dev in deviceSerialIDList: 450 | deviceSerialID = dev["deviceSerialID"] 451 | user = dev["userNumberInDevice"] 452 | L.debug(f" - deviceModel: {deviceModel} category: {devCat.name} serial: {deviceSerialID} user: {user}") 453 | 454 | if deviceSerialID != device.serial: 455 | continue 456 | 457 | if device.category == DeviceCategory.BPM: 458 | measurements.extend(self._process_bpm_measurements(dev)) 459 | elif device.category == DeviceCategory.SCALE: 460 | measurements.extend(self._process_scale_measurements(dev)) 461 | break 462 | 463 | return measurements 464 | 465 | def _process_bpm_measurements(self, dev: T.Dict[str, T.Any]) -> T.List[BPMeasurement]: 466 | measurements: T.List[BPMeasurement] = [] 467 | for m in dev["measureList"]: 468 | bodyIndexList = {k: BodyIndexList(*v) for k, v in m["bodyIndexList"].items()} 469 | systolic = bodyIndexList[ValueType.MMHG_MAX_FIGURE].value 470 | diastolic = bodyIndexList[ValueType.MMHG_MIN_FIGURE].value 471 | pulse = bodyIndexList[ValueType.BPM_FIGURE].value 472 | bodymotion = bodyIndexList[ValueType.BODY_MOTION_FLAG_FIGURE].value 473 | irregHB = bodyIndexList[ValueType.ARRHYTHMIA_FLAG_FIGURE].value 474 | cuffWrapGuid = bodyIndexList[ValueType.KEEP_UP_CHECK_FIGURE].value 475 | timeZone = pytz.timezone(m["timeZone"]) 476 | 477 | bp = BPMeasurement( 478 | systolic=systolic, 479 | diastolic=diastolic, 480 | pulse=pulse, 481 | measurementDate=m["measureDateTo"], 482 | timeZone=timeZone, 483 | irregularHB=irregHB != 0, 484 | movementDetect=bodymotion != 0, 485 | cuffWrapDetect=cuffWrapGuid != 0, 486 | ) 487 | measurements.append(bp) 488 | return measurements 489 | 490 | def _process_scale_measurements(self, dev: T.Dict[str, T.Any]) -> T.List[WeightMeasurement]: 491 | measurements: T.List[WeightMeasurement] = [] 492 | for m in dev["measureList"]: 493 | bodyIndexList = {k: BodyIndexList(*v) for k, v in m["bodyIndexList"].items()} 494 | weight = bodyIndexList[ValueType.KG_FIGURE].value / 100 495 | weightUnit = bodyIndexList[ValueType.KG_FIGURE].subtype 496 | weight = convert_weight_to_kg(weight, weightUnit) 497 | bodyFatPercentage = bodyIndexList[ValueType.BODY_FAT_PER_FIGURE].value / 10 498 | sceletalMusclePercentage = bodyIndexList[ValueType.RATE_SKELETAL_MUSCLE_FIGURE].value / 10 499 | basal_met = bodyIndexList[ValueType.BASAL_METABOLISM_FIGURE].value 500 | metabolic_age = bodyIndexList[ValueType.BIOLOGICAL_AGE_FIGURE].value 501 | visceral_fat_rating = bodyIndexList[ValueType.VISCERAL_FAT_FIGURE].value / 10 502 | bmi = bodyIndexList[ValueType.BMI_FIGURE].value / 10 503 | timeZone = pytz.timezone(m["timeZone"]) 504 | 505 | wm = WeightMeasurement( 506 | weight=weight, 507 | measurementDate=m["measureDateTo"], 508 | timeZone=timeZone, 509 | bmiValue=bmi, 510 | bodyFatPercentage=bodyFatPercentage, 511 | restingMetabolism=basal_met, 512 | skeletalMusclePercentage=sceletalMusclePercentage, 513 | visceralFatLevel=visceral_fat_rating, 514 | metabolicAge=metabolic_age, 515 | ) 516 | measurements.append(wm) 517 | return measurements 518 | 519 | 520 | class OmronConnect2(OmronConnect): 521 | _APP_NAME = "OCM" 522 | _APP_URL = "/app" 523 | _APP_VERSION = "7.20.0" 524 | _USER_AGENT = "Foresight/{_APP_VERSION} (com.omronhealthcare.omronconnect; build:37; iOS 15.8.3) Alamofire/5.9.1" 525 | 526 | # monkey-patch httpx so checksum(req.content) works with omron servers. 527 | # pylint: disable=protected-access 528 | httpx._content.json_dumps = lambda obj, **kw: json.dumps(obj, **{**kw, "separators": (",", ":")}) 529 | 530 | _client = httpx.Client( 531 | event_hooks={ 532 | "request": [_http_add_checksum], 533 | }, 534 | headers={ 535 | "user-agent": _USER_AGENT, 536 | }, 537 | ) 538 | 539 | def __init__(self, server: str): 540 | self._server = server 541 | self._headers: T.Dict[str, str] = {} 542 | self._email: str = "" 543 | 544 | def login(self, email: str, password: str, country: str) -> T.Optional[str]: 545 | data = { 546 | "emailAddress": email, 547 | "password": password, 548 | "country": country, 549 | "app": self._APP_NAME, 550 | } 551 | r = self._client.post(f"{self._server}{self._APP_URL}/login", json=data) 552 | r.raise_for_status() 553 | 554 | resp = r.json() 555 | try: 556 | accessToken = resp["accessToken"] 557 | refreshToken = resp["refreshToken"] 558 | self._headers["authorization"] = f"{accessToken}" 559 | self._email = email 560 | return refreshToken 561 | 562 | except KeyError: 563 | pass 564 | 565 | return None 566 | 567 | def refresh_oauth2(self, refresh_token: str, **kwargs: T.Any) -> T.Optional[str]: 568 | data = { 569 | "app": self._APP_NAME, 570 | "emailAddress": kwargs.get("email", self._email), 571 | "refreshToken": refresh_token, 572 | } 573 | 574 | r = self._client.post(f"{self._server}{self._APP_URL}/login", json=data, headers=self._headers) 575 | r.raise_for_status() 576 | 577 | resp = r.json() 578 | try: 579 | accessToken = resp["accessToken"] 580 | refreshToken = resp["refreshToken"] 581 | self._headers["authorization"] = f"{accessToken}" 582 | return refreshToken 583 | 584 | except KeyError: 585 | pass 586 | 587 | return None 588 | 589 | def get_user(self) -> T.Dict[str, T.Any]: 590 | r = self._client.get(f"{self._server}{self._APP_URL}/user?app={self._APP_NAME}", headers=self._headers) 591 | r.raise_for_status() 592 | resp = r.json() 593 | 594 | return resp["data"] 595 | 596 | def get_bp_measurements( 597 | self, nextpaginationKey: int = 0, lastSyncedTime: int = 0, phoneIdentifier: str = "" 598 | ) -> T.List[T.Dict[str, T.Any]]: 599 | _lastSyncedTime = "" if lastSyncedTime <= 0 else lastSyncedTime 600 | r = self._client.get( 601 | f"{self._server}{self._APP_URL}/v2/sync/bp?nextpaginationKey={nextpaginationKey}" 602 | f"&lastSyncedTime={_lastSyncedTime}&phoneIdentifier={phoneIdentifier}", 603 | headers=self._headers, 604 | ) 605 | r.raise_for_status() 606 | resp = r.json() 607 | 608 | if _debugSaveResponse: 609 | fname = f".debug/{lastSyncedTime}_bpm_v2.json" 610 | U.json_save(fname, resp) 611 | 612 | return resp["data"] 613 | 614 | def get_weighins( 615 | self, nextpaginationKey: int = 0, lastSyncedTime: int = 0, phoneIdentifier: str = "" 616 | ) -> T.List[T.Dict[str, T.Any]]: 617 | _lastSyncedTime = "" if lastSyncedTime <= 0 else lastSyncedTime 618 | r = self._client.get( 619 | f"{self._server}{self._APP_URL}/v2/sync/weight?nextpaginationKey={nextpaginationKey}" 620 | f"&lastSyncedTime={_lastSyncedTime}&phoneIdentifier={phoneIdentifier}", 621 | headers=self._headers, 622 | ) 623 | r.raise_for_status() 624 | resp = r.json() 625 | 626 | if _debugSaveResponse: 627 | fname = f".debug/{lastSyncedTime}_weight_v2.json" 628 | U.json_save(fname, resp) 629 | 630 | return resp["data"] 631 | 632 | def get_measurements( 633 | self, device: OmronDevice, searchDateFrom: int = 0, searchDateTo: int = 0 634 | ) -> T.List[MeasurementTypes]: 635 | 636 | user = int(device.user) 637 | 638 | def filter_measurements(data) -> T.List[MeasurementTypes]: 639 | r: T.List[MeasurementTypes] = [] 640 | for m in data: 641 | userNumberInDevice = int(m["userNumberInDevice"]) 642 | if user >= 0 and userNumberInDevice != user: 643 | L.debug(f"skipping user: {user} != {userNumberInDevice}") 644 | continue 645 | 646 | measurementDate = int(m["measurementDate"]) 647 | if searchDateTo > 0 and measurementDate > searchDateTo: 648 | L.debug(f"skipping date: {measurementDate} > {searchDateTo}") 649 | continue 650 | 651 | if int(m["isManualEntry"]): 652 | L.debug("skipping manual entry") 653 | continue 654 | 655 | if device.category == DeviceCategory.BPM: 656 | 657 | # timezone(timedelta(seconds=int(m["timeZone"]))) 658 | 659 | bpm = BPMeasurement( 660 | systolic=m["systolic"], 661 | diastolic=m["diastolic"], 662 | pulse=m["pulse"], 663 | measurementDate=measurementDate, 664 | timeZone=pytz.FixedOffset(int(m["timeZone"]) // 60), 665 | irregularHB=int(m["irregularHB"]) != 0, 666 | movementDetect=int(m["movementDetect"]) != 0, 667 | cuffWrapDetect=int(m["cuffWrapDetect"]) != 0, 668 | notes=m.get("notes", ""), 669 | ) 670 | r.append(bpm) 671 | 672 | elif device.category == DeviceCategory.SCALE: 673 | weight = float(m["weight"]) 674 | weightInLbs = float(m["weightInLbs"]) 675 | if weight <= 0 and weightInLbs > 0: 676 | weight = weightInLbs * 0.453592 677 | 678 | wm = WeightMeasurement( 679 | weight=weight, 680 | measurementDate=measurementDate, 681 | timeZone=pytz.FixedOffset(int(m["timeZone"]) // 60), 682 | bmiValue=m["bmiValue"], 683 | bodyFatPercentage=m["bodyFatPercentage"], 684 | restingMetabolism=m["restingMetabolism"], 685 | skeletalMusclePercentage=m["skeletalMusclePercentage"], 686 | visceralFatLevel=m["visceralFatLevel"], 687 | notes=m.get("notes", ""), 688 | ) 689 | r.append(wm) 690 | return r 691 | 692 | data = None 693 | if device.category == DeviceCategory.BPM: 694 | data = self.get_bp_measurements(lastSyncedTime=searchDateFrom) 695 | elif device.category == DeviceCategory.SCALE: 696 | data = self.get_weighins(lastSyncedTime=searchDateFrom) 697 | 698 | return filter_measurements(data) if data else [] 699 | 700 | 701 | ######################################################################################################################## 702 | 703 | 704 | def get_omron_connect(server: str) -> OmronConnect: 705 | if "data-sg.omronconnect.com" in server: 706 | return OmronConnect1(server) 707 | return OmronConnect2(server) 708 | 709 | 710 | ######################################################################################################################## 711 | -------------------------------------------------------------------------------- /omramin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | ######################################################################################################################## 3 | 4 | import typing as T # isort: split 5 | 6 | import asyncio 7 | import binascii 8 | import csv 9 | import dataclasses 10 | import logging 11 | import logging.config 12 | import os 13 | import pathlib 14 | import platform 15 | from datetime import datetime, timedelta 16 | from httpx import HTTPStatusError 17 | 18 | import bleak 19 | import click 20 | import garminconnect as GC 21 | import garth 22 | import inquirer 23 | 24 | import omronconnect as OC 25 | import utils as U 26 | from regionserver import get_server_for_country_code 27 | 28 | ######################################################################################################################## 29 | 30 | __version__ = "0.1.1" 31 | 32 | ######################################################################################################################## 33 | 34 | 35 | class Options: 36 | def __init__(self): 37 | self.write_to_garmin = True 38 | self.overwrite = False 39 | self.ble_filter = "BLEsmart_" 40 | 41 | 42 | ######################################################################################################################## 43 | 44 | PATH_DEFAULT_CONFIG = "~/.omramin/config.json" 45 | 46 | DEFAULT_CONFIG = { 47 | "garmin": {}, 48 | "omron": { 49 | "server": "", 50 | "devices": [], 51 | }, 52 | } 53 | 54 | LOGGING_CONFIG = { 55 | "version": 1, 56 | "handlers": { 57 | "default": { 58 | "class": "logging.StreamHandler", 59 | "formatter": "default", 60 | "stream": "ext://sys.stderr", 61 | }, 62 | "http": { 63 | "class": "logging.StreamHandler", 64 | "formatter": "http", 65 | "stream": "ext://sys.stderr", 66 | }, 67 | }, 68 | "formatters": { 69 | "default": { 70 | "format": "[%(asctime)s] [%(levelname).1s] %(message)s", 71 | "datefmt": "%Y-%m-%d %H:%M:%S", 72 | }, 73 | "http": { 74 | "format": "[%(asctime)s] [%(levelname).1s] (%(name)s) - %(message)s", 75 | "datefmt": "%Y-%m-%d %H:%M:%S", 76 | }, 77 | }, 78 | "loggers": { 79 | "": { 80 | "handlers": ["default"], 81 | "level": logging.INFO, 82 | "formatter": "root", 83 | }, 84 | "omron": { 85 | "handlers": ["default"], 86 | "level": logging.INFO, 87 | "formatter": "root", 88 | }, 89 | "httpx": { 90 | "handlers": ["http"], 91 | "level": logging.WARNING, 92 | }, 93 | "httpcore": { 94 | "handlers": ["http"], 95 | "level": logging.WARNING, 96 | }, 97 | }, 98 | } 99 | 100 | ######################################################################################################################## 101 | 102 | _E = os.environ.get 103 | 104 | logging.config.dictConfig(LOGGING_CONFIG) 105 | L = logging.getLogger("") 106 | # L.setLevel(logging.DEBUG) 107 | 108 | 109 | ######################################################################################################################## 110 | class LoginError(Exception): 111 | pass 112 | 113 | 114 | def garmin_login(_config: str) -> T.Optional[GC.Garmin]: 115 | """Login to Garmin Connect""" 116 | 117 | try: 118 | config = U.json_load(_config) 119 | 120 | except FileNotFoundError: 121 | L.error(f"Config file '{_config}' not found.") 122 | return None 123 | 124 | gcCfg = config["garmin"] 125 | 126 | def get_mfa(): 127 | return inquirer.text(message="> Enter MFA/2FA code") 128 | 129 | logged_in = False 130 | try: 131 | tokendata = gcCfg.get("tokendata", "") 132 | if not tokendata: 133 | raise FileNotFoundError 134 | 135 | email = gcCfg["email"] 136 | is_cn = gcCfg["is_cn"] 137 | gc = GC.Garmin(email=email, is_cn=is_cn, prompt_mfa=get_mfa) 138 | 139 | logged_in = gc.login(tokendata) 140 | if not logged_in: 141 | raise FileNotFoundError 142 | 143 | except (FileNotFoundError, binascii.Error): 144 | L.info("Garmin login") 145 | questions = [ 146 | inquirer.Text( 147 | name="email", 148 | message="> Enter email", 149 | validate=lambda _, x: x != "", 150 | ), 151 | inquirer.Password( 152 | name="password", 153 | message="> Enter password", 154 | validate=lambda _, x: x != "", 155 | ), 156 | inquirer.Confirm( 157 | "is_cn", 158 | message="> Is this a Chinese account?", 159 | default=False, 160 | ), 161 | ] 162 | answers = inquirer.prompt(questions) 163 | if not answers: 164 | # pylint: disable-next=raise-missing-from 165 | raise LoginError("Invalid input") 166 | 167 | email = answers["email"] 168 | password = answers["password"] 169 | is_cn = answers["is_cn"] 170 | 171 | gc = GC.Garmin(email=email, password=password, is_cn=is_cn, prompt_mfa=get_mfa) 172 | logged_in = gc.login() 173 | if logged_in: 174 | gcCfg["email"] = email 175 | gcCfg["is_cn"] = is_cn 176 | gcCfg["tokendata"] = gc.garth.dumps() 177 | 178 | try: 179 | U.json_save(_config, config) 180 | 181 | except (OSError, IOError, ValueError) as e: 182 | L.error(f"Failed to save configuration: {e}") 183 | 184 | except garth.exc.GarthHTTPError: 185 | L.error("Failed to login to Garmin Connect", exc_info=True) 186 | return None 187 | 188 | if not logged_in: 189 | L.error("Failed to login to Garmin Connect") 190 | return None 191 | 192 | L.info("Logged in to Garmin Connect") 193 | return gc 194 | 195 | 196 | def omron_login(_config: str) -> T.Optional[OC.OmronConnect]: 197 | """Login to OMRON connect""" 198 | 199 | try: 200 | config = U.json_load(_config) 201 | 202 | except FileNotFoundError: 203 | L.error(f"Config file '{_config}' not found.") 204 | return None 205 | 206 | ocCfg = config["omron"] 207 | 208 | refreshToken = None 209 | try: 210 | server = ocCfg.get("server", "") 211 | tokendata = ocCfg.get("tokendata", "") 212 | email = ocCfg.get("email", "") 213 | if not tokendata: 214 | raise FileNotFoundError 215 | 216 | oc = OC.get_omron_connect(server) 217 | # OmronConnect2 requires email for token refresh 218 | refreshToken = oc.refresh_oauth2(tokendata, email=email) 219 | if not refreshToken: 220 | raise FileNotFoundError 221 | 222 | # Save the new refresh token (OMRON rotates tokens on each refresh) 223 | if refreshToken != tokendata: 224 | ocCfg["tokendata"] = refreshToken 225 | try: 226 | U.json_save(_config, config) 227 | L.debug("OMRON refresh token updated and saved") 228 | except (OSError, IOError, ValueError) as e: 229 | L.warning(f"Failed to save refreshed token: {e}") 230 | 231 | except (HTTPStatusError, FileNotFoundError) as e: 232 | if isinstance(e, HTTPStatusError): 233 | L.error(f"Failed to login to OMRON connect: '{e.response.reason_phrase}: {e.response.status_code}'") 234 | if e.response.status_code != 403: 235 | raise 236 | 237 | L.info("Omron login") 238 | questions = [ 239 | inquirer.Text( 240 | name="email", 241 | message="> Enter email", 242 | validate=lambda _, x: x != "", 243 | ), 244 | inquirer.Password( 245 | name="password", 246 | message="> Enter password", 247 | validate=lambda _, x: x != "", 248 | ), 249 | inquirer.Text( 250 | "country", 251 | message="> Enter country code (e.g. 'US')", 252 | validate=lambda _, x: get_server_for_country_code(x), 253 | ), 254 | ] 255 | answers = inquirer.prompt(questions) 256 | if not answers: 257 | # pylint: disable-next=raise-missing-from 258 | raise LoginError("Invalid input") 259 | 260 | email = answers["email"] 261 | password = answers["password"] 262 | country = answers["country"] 263 | server = get_server_for_country_code(country) 264 | 265 | oc = OC.get_omron_connect(server) 266 | refreshToken = oc.login(email=email, password=password, country=country) 267 | if refreshToken: 268 | tokendata = refreshToken 269 | ocCfg["email"] = email 270 | ocCfg["server"] = server 271 | ocCfg["tokendata"] = tokendata 272 | ocCfg["country"] = country 273 | 274 | try: 275 | U.json_save(_config, config) 276 | 277 | except (OSError, IOError, ValueError) as e: 278 | L.error(f"Failed to save configuration: {e}") 279 | 280 | if refreshToken: 281 | L.info("Logged in to OMRON connect") 282 | return oc 283 | 284 | L.error("Failed to login to OMRON connect") 285 | return None 286 | 287 | 288 | def omron_ble_scan(macAddrsExistig: T.List[str], opts: Options) -> T.List[str]: 289 | """Scan for Omron devices in pairing mode""" 290 | 291 | devsFound = {} 292 | 293 | async def scan(): 294 | L.info("Scanning for Omron devices in pairing mode ...") 295 | L.info("Press Ctrl+C to stop scanning") 296 | while True: 297 | devices = await bleak.BleakScanner.discover(return_adv=True, timeout=1) 298 | devices = list(sorted(devices.items(), key=lambda x: x[1][1].rssi, reverse=True)) 299 | for macAddr, (bleDev, advData) in devices: 300 | devName = (bleDev.name or "").strip() 301 | 302 | # Extract MAC address from device name on MacOS 303 | if platform.system() == "Darwin" and devName.upper().startswith("BLESMART_"): 304 | try: 305 | _mac_len = 12 306 | _mac_str = devName[-_mac_len:].upper() 307 | # Validate it's actually hex before parsing 308 | if len(_mac_str) == _mac_len and all(c in "0123456789ABCDEF" for c in _mac_str): 309 | macAddr = ":".join(_mac_str[i : i + 2] for i in range(0, _mac_len, 2)) 310 | else: 311 | continue 312 | except (IndexError, ValueError): 313 | continue 314 | 315 | if macAddr in devsFound: 316 | continue 317 | 318 | if macAddr in macAddrsExistig: 319 | continue 320 | 321 | if opts.ble_filter and not devName.upper().startswith(opts.ble_filter.upper()): 322 | continue 323 | 324 | serial = OC.ble_mac_to_serial(macAddr) 325 | devsFound[macAddr] = serial 326 | L.info(f"+ {macAddr} {bleDev.name} {serial} {advData.rssi}") 327 | 328 | try: 329 | asyncio.run(scan()) 330 | 331 | except bleak.exc.BleakError as e: 332 | L.error(f"Bleak error: {e}") 333 | 334 | except KeyboardInterrupt: 335 | pass 336 | 337 | return list(devsFound.keys()) 338 | 339 | 340 | DeviceType = T.Dict[str, T.Any] 341 | 342 | 343 | def device_new( 344 | *, 345 | macaddr: str, 346 | name: T.Optional[str], 347 | category: T.Optional[OC.DeviceCategory], 348 | user: T.Optional[int], 349 | enabled: T.Optional[bool], 350 | ) -> T.Optional[DeviceType]: 351 | 352 | questions = [] 353 | if name is None: 354 | questions.append( 355 | inquirer.Text( 356 | name="name", 357 | message="Name of the device", 358 | default="", 359 | ) 360 | ) 361 | if category is None: 362 | questions.append( 363 | inquirer.List( 364 | "category", 365 | message="Type of the device", 366 | choices=list(OC.DeviceCategory.__members__.keys()), 367 | default="SCALE", 368 | ) 369 | ) 370 | 371 | if user is None: 372 | questions.append( 373 | inquirer.List( 374 | "user", 375 | message="User number on the device", 376 | default=1, 377 | choices=[1, 2, 3, 4], 378 | ) 379 | ) 380 | if enabled is None: 381 | questions.append( 382 | inquirer.List( 383 | name="enabled", 384 | message="Enable device", 385 | default=True, 386 | choices=[True, False], 387 | ) 388 | ) 389 | 390 | device = { 391 | "macaddr": macaddr, 392 | "name": name, 393 | "category": category, 394 | "user": user, 395 | "enabled": enabled, 396 | } 397 | 398 | if questions: 399 | answers = inquirer.prompt(questions) 400 | if not answers: 401 | return None 402 | 403 | device.update(answers) 404 | 405 | return device 406 | 407 | 408 | def device_edit(device: DeviceType) -> bool: 409 | questions = [ 410 | inquirer.Text( 411 | name="name", 412 | message="Name of the device", 413 | default=device.get("name", ""), 414 | ), 415 | inquirer.List( 416 | "category", 417 | message="Type of the device", 418 | choices=["SCALE", "BPM"], 419 | default=device.get("category", "SCALE"), 420 | ), 421 | inquirer.List( 422 | "user", 423 | message="User number on the device", 424 | default=device.get("user", 1), 425 | choices=[1, 2, 3, 4], 426 | ), 427 | inquirer.List( 428 | name="enabled", 429 | message="Enable device", 430 | default=device.get("enabled", True), 431 | choices=[True, False], 432 | ), 433 | ] 434 | 435 | answers = inquirer.prompt(questions) 436 | if not answers: 437 | return False 438 | 439 | device["name"] = answers["name"] or OC.ble_mac_to_serial(device["macaddr"]) 440 | device["category"] = answers["category"] 441 | device["user"] = answers["user"] 442 | device["enabled"] = answers["enabled"] 443 | 444 | return True 445 | 446 | 447 | def omron_sync_device_to_garmin( 448 | oc: OC.OmronConnect, gc: GC.Garmin, ocDev: OC.OmronDevice, startLocal: int, endLocal: int, opts: Options 449 | ) -> None: 450 | if endLocal - startLocal <= 0: 451 | L.info("Invalid date range") 452 | return 453 | 454 | startdateStr = datetime.fromtimestamp(startLocal).date().isoformat() 455 | enddateStr = datetime.fromtimestamp(endLocal).date().isoformat() 456 | 457 | L.info(f"Start synchronizing device '{ocDev.name}' from {startdateStr} to {enddateStr}") 458 | 459 | measurements = oc.get_measurements(ocDev, searchDateFrom=int(startLocal * 1000), searchDateTo=int(endLocal * 1000)) 460 | if not measurements: 461 | L.info("No new measurements") 462 | return 463 | 464 | L.info(f"Downloaded {len(measurements)} entries from 'OMRON connect' for '{ocDev.name}'") 465 | 466 | # get measurements from Garmin Connect for the same date range 467 | if ocDev.category == OC.DeviceCategory.SCALE: 468 | gcData = garmin_get_weighins(gc, startdateStr, enddateStr) 469 | sync_scale_measurements(gc, gcData, measurements, opts) 470 | elif ocDev.category == OC.DeviceCategory.BPM: 471 | gcData = garmin_get_bp_measurements(gc, startdateStr, enddateStr) 472 | sync_bp_measurements(gc, gcData, measurements, opts) 473 | 474 | 475 | def sync_scale_measurements( 476 | gc: GC.Garmin, gcData: T.Dict[str, T.Any], measurements: T.List[OC.MeasurementTypes], opts: Options 477 | ): 478 | for measurement in measurements: 479 | tz = measurement.timeZone 480 | ts = measurement.measurementDate / 1000 481 | dtUTC = U.utcfromtimestamp(ts) 482 | dtLocal = datetime.fromtimestamp(ts, tz=tz) 483 | 484 | datetimeStr = dtLocal.isoformat(timespec="seconds") 485 | dateStr = dtLocal.date().isoformat() 486 | lookup = f"{dtUTC.date().isoformat()}:{dtUTC.timestamp()}" 487 | 488 | if lookup in gcData.values(): 489 | if opts.overwrite: 490 | L.warning(f" ! '{datetimeStr}': removing weigh-in") 491 | for samplePk, val in gcData.items(): 492 | if val == lookup and opts.write_to_garmin: 493 | gc.delete_weigh_in(weight_pk=samplePk, cdate=dateStr) 494 | else: 495 | L.info(f" - '{datetimeStr}' weigh-in already exists") 496 | continue 497 | 498 | wm = T.cast(OC.WeightMeasurement, measurement) 499 | 500 | L.info(f" + '{datetimeStr}' adding weigh-in: {wm.weight} kg ") 501 | if opts.write_to_garmin: 502 | gc.add_body_composition( 503 | timestamp=datetimeStr, 504 | weight=wm.weight, 505 | percent_fat=wm.bodyFatPercentage if wm.bodyFatPercentage > 0 else None, 506 | percent_hydration=None, 507 | visceral_fat_mass=None, 508 | bone_mass=None, 509 | muscle_mass=( 510 | (wm.skeletalMusclePercentage * wm.weight) / 100 if wm.skeletalMusclePercentage > 0 else None 511 | ), 512 | basal_met=wm.restingMetabolism if wm.restingMetabolism > 0 else None, 513 | active_met=None, 514 | physique_rating=None, 515 | metabolic_age=wm.metabolicAge if wm.metabolicAge > 0 else None, 516 | visceral_fat_rating=wm.visceralFatLevel if wm.visceralFatLevel > 0 else None, 517 | bmi=wm.bmiValue, 518 | ) 519 | 520 | 521 | def sync_bp_measurements( 522 | gc: GC.Garmin, gcData: T.Dict[str, T.Any], measurements: T.List[OC.MeasurementTypes], opts: Options 523 | ): 524 | for measurement in measurements: 525 | tz = measurement.timeZone 526 | ts = measurement.measurementDate / 1000 527 | dtUTC = U.utcfromtimestamp(ts) 528 | dtLocal = datetime.fromtimestamp(ts, tz=tz) 529 | 530 | datetimeStr = dtLocal.isoformat(timespec="seconds") 531 | dateStr = dtLocal.date().isoformat() 532 | lookup = f"{dtUTC.date().isoformat()}:{dtUTC.timestamp()}" 533 | 534 | if lookup in gcData.values(): 535 | if opts.overwrite: 536 | L.warning(f" ! '{datetimeStr}': removing blood pressure measurement") 537 | for version, val in gcData.items(): 538 | if val == lookup and opts.write_to_garmin: 539 | gc.delete_blood_pressure(version=version, cdate=dateStr) 540 | else: 541 | L.info(f" - '{datetimeStr}' blood pressure already exists") 542 | continue 543 | 544 | bpm = T.cast(OC.BPMeasurement, measurement) 545 | 546 | notes = bpm.notes 547 | if bpm.movementDetect: 548 | notes = f"{notes}, Body Movement detected" 549 | if bpm.irregularHB: 550 | notes = f"{notes}, Irregular heartbeat detected" 551 | if not bpm.cuffWrapDetect: 552 | notes = f"{notes}, Cuff wrap error" 553 | if notes: 554 | notes = notes.lstrip(", ") 555 | 556 | L.info(f" + '{datetimeStr}' adding blood pressure ({bpm.systolic}/{bpm.diastolic} mmHg, {bpm.pulse} bpm)") 557 | 558 | if opts.write_to_garmin: 559 | gc.set_blood_pressure( 560 | timestamp=datetimeStr, systolic=bpm.systolic, diastolic=bpm.diastolic, pulse=bpm.pulse, notes=notes 561 | ) 562 | 563 | 564 | def garmin_get_bp_measurements(gc: GC.Garmin, startdate: str, enddate: str): 565 | # search dates are in local time 566 | gcData = gc.get_blood_pressure(startdate=startdate, enddate=enddate) 567 | 568 | # reduce to list of measurements 569 | _gcMeasurements = [metric for x in gcData["measurementSummaries"] for metric in x["measurements"]] 570 | 571 | # map of garmin-key:omron-key 572 | gcMeasurements = {} 573 | for metric in _gcMeasurements: 574 | # use UTC for comparison 575 | dtUTC = datetime.fromisoformat(f"{metric['measurementTimestampGMT']}Z") 576 | gcMeasurements[metric["version"]] = f"{dtUTC.date().isoformat()}:{dtUTC.timestamp()}" 577 | 578 | L.info(f"Downloaded {len(gcMeasurements)} bpm measurements from 'Garmin Connect'") 579 | return gcMeasurements 580 | 581 | 582 | def garmin_get_weighins(gc: GC.Garmin, startdate: str, enddate: str): 583 | # search dates are in local time 584 | gcData = gc.get_weigh_ins(startdate=startdate, enddate=enddate) 585 | 586 | # reduce to list of allWeightMetrics 587 | _gcWeighins = [metric for x in gcData["dailyWeightSummaries"] for metric in x["allWeightMetrics"]] 588 | 589 | # map of garmin-key:omron-key 590 | gcWeighins = {} 591 | for metric in _gcWeighins: 592 | # use UTC for comparison 593 | dtUTC = U.utcfromtimestamp(int(metric["timestampGMT"]) / 1000) 594 | gcWeighins[metric["samplePk"]] = f"{dtUTC.date().isoformat()}:{dtUTC.timestamp()}" 595 | 596 | L.info(f"Downloaded {len(gcWeighins)} weigh-ins from 'Garmin Connect'") 597 | 598 | return gcWeighins 599 | 600 | 601 | ######################################################################################################################## 602 | class DateRangeException(Exception): 603 | pass 604 | 605 | 606 | def calculate_date_range(days: int) -> T.Tuple[int, int]: 607 | days = max(days, 0) 608 | today = datetime.combine(datetime.today().date(), datetime.max.time()) 609 | start = today - timedelta(days=days) 610 | start = datetime.combine(start, datetime.min.time()) 611 | startLocal = start.timestamp() 612 | endLocal = today.timestamp() 613 | if endLocal - startLocal <= 0: 614 | raise DateRangeException() 615 | 616 | return int(startLocal), int(endLocal) 617 | 618 | 619 | def filter_devices( 620 | devices: T.List[T.Dict[str, T.Any]], 621 | *, 622 | devnames: T.Optional[T.List[str]] = None, 623 | category: T.Optional[OC.DeviceCategory] = None, 624 | ) -> T.List[T.Dict[str, T.Any]]: 625 | devices = [d for d in devices if d["enabled"]] 626 | if category: 627 | devices = [d for d in devices if d["category"] == category.name] 628 | if devnames: 629 | devices = [d for d in devices if d["name"] in devnames or d["macaddr"] in devnames] 630 | return devices 631 | 632 | 633 | ######################################################################################################################## 634 | class CommonCommand(click.Command): 635 | """Common options for all commands""" 636 | 637 | def __init__(self, *args, **kwargs): 638 | super().__init__(*args, **kwargs) 639 | 640 | self._defaultconfig = PATH_DEFAULT_CONFIG 641 | 642 | for p in [_E("OMRAMIN_CONFIG", PATH_DEFAULT_CONFIG), "./config.json"]: 643 | if pathlib.Path(p).expanduser().resolve().exists(): 644 | self._defaultconfig = p 645 | 646 | self.params[0:0] = [ 647 | click.Option( 648 | ("--config", "_config"), 649 | type=click.Path(writable=True, dir_okay=False), 650 | default=pathlib.Path(self._defaultconfig).expanduser().resolve(), 651 | show_default=True, 652 | help="Config file", 653 | ), 654 | ] 655 | 656 | 657 | @click.group() 658 | @click.version_option(__version__) 659 | def cli(): 660 | """Sync data from 'OMRON connect' to 'Garmin Connect'""" 661 | 662 | 663 | ######################################################################################################################## 664 | 665 | 666 | @cli.command(name="list", cls=CommonCommand) 667 | def list_devices(_config: str): 668 | """List all configured devices.""" 669 | 670 | try: 671 | config = U.json_load(_config) 672 | 673 | except FileNotFoundError: 674 | L.error(f"Config file '{_config}' not found.") 675 | return 676 | 677 | devices = config.get("omron", {}).get("devices", []) 678 | if not devices: 679 | L.info("No devices configured.") 680 | return 681 | 682 | for device in devices: 683 | L.info("-" * 40) 684 | L.info(f"Name:{' ':<8}{device.get('name', 'Unknown')}") 685 | L.info(f"MAC Address:{' ':<1}{device.get('macaddr', 'Unknown')}") 686 | L.info(f"Category:{' ':<4}{device.get('category', 'Unknown')}") 687 | L.info(f"User:{' ':<8}{device.get('user', 'Unknown')}") 688 | L.info(f"Enabled:{' ':<5}{device.get('enabled', 'Unknown')}") 689 | 690 | if devices: 691 | L.info("-" * 40) 692 | 693 | 694 | ######################################################################################################################## 695 | 696 | 697 | @cli.command(name="add", cls=CommonCommand) 698 | @click.option( 699 | "--macaddr", 700 | "-m", 701 | required=False, 702 | help="MAC address of the device to add. If not provided, scan for new devices.", 703 | ) 704 | @click.option( 705 | "--name", 706 | "-n", 707 | required=False, 708 | help="Name of the device to add. If not provided, the serial number will be used.", 709 | ) 710 | @click.option( 711 | "--category", 712 | "-c", 713 | required=False, 714 | type=click.Choice(list(OC.DeviceCategory.__members__.keys()), case_sensitive=False), 715 | help="Category of the device (SCALE or BPM).", 716 | ) 717 | @click.option( 718 | "--user", 719 | "-u", 720 | required=False, 721 | type=click.INT, 722 | default=1, 723 | show_default=True, 724 | help="User number on the device (1-4).", 725 | ) 726 | @click.option("--ble-filter", help="BLE device name filter", default=Options().ble_filter, show_default=True) 727 | def add_device( 728 | macaddr: T.Optional[str], 729 | name: T.Optional[str], 730 | category: T.Optional[OC.DeviceCategory], 731 | user: T.Optional[int], 732 | ble_filter: T.Optional[str], 733 | _config: str, 734 | ): 735 | """Add a new Omron device to the configuration. 736 | 737 | This function allows adding a new Omron device either by providing a MAC address directly 738 | or by scanning for available devices. 739 | 740 | \b 741 | Examples: 742 | # Scan and select device interactively 743 | python omramin.py add 744 | \b 745 | # Add device by MAC address 746 | python omramin.py add -m 00:11:22:33:44:55 747 | python omramin.py add -m 00:11:22:33:44:55 -c scale -n "My Scale" -u 3 748 | 749 | 750 | """ 751 | opts = Options() 752 | opts.ble_filter = ble_filter 753 | 754 | try: 755 | config = U.json_load(_config) 756 | 757 | except FileNotFoundError: 758 | config = DEFAULT_CONFIG 759 | 760 | devices = config.get("omron", {}).get("devices", []) 761 | 762 | if not macaddr: 763 | macAddrs = [d["macaddr"] for d in devices] 764 | bleDevices = omron_ble_scan(macAddrs, opts) 765 | if not bleDevices: 766 | L.info("No devices found.") 767 | return 768 | 769 | # make sure we don't add the same device twice 770 | tmp = bleDevices.copy() 771 | for scanned in bleDevices: 772 | if any(d["macaddr"] == scanned for d in devices): 773 | tmp.remove(scanned) 774 | bleDevices = tmp 775 | 776 | if not bleDevices: 777 | L.info("No new devices found.") 778 | return 779 | 780 | macaddr = inquirer.list_input("Select device", choices=sorted(bleDevices)) 781 | 782 | if macaddr: 783 | if not U.is_valid_macaddr(macaddr): 784 | L.error(f"Invalid MAC address: {macaddr}") 785 | return 786 | 787 | if macaddr in [d["macaddr"] for d in devices]: 788 | L.info(f"Device '{macaddr}' already exists.") 789 | return 790 | 791 | if device := device_new(macaddr=macaddr, name=name, category=category, user=user, enabled=True): 792 | config["omron"]["devices"].append(device) 793 | try: 794 | U.json_save(_config, config) 795 | L.info("Device(s) added successfully.") 796 | 797 | except (OSError, IOError, ValueError) as e: 798 | L.error(f"Failed to save configuration: {e}") 799 | 800 | 801 | ######################################################################################################################## 802 | 803 | 804 | @cli.command(name="config", cls=CommonCommand) 805 | @click.argument("devname", required=True, type=str, nargs=1) 806 | def edit_device(devname: str, _config: str): 807 | """Edit device configuration.""" 808 | 809 | try: 810 | config = U.json_load(_config) 811 | 812 | except FileNotFoundError: 813 | L.error(f"Config file '{_config}' not found.") 814 | return 815 | 816 | devices = config.get("omron", {}).get("devices", []) 817 | if not devices: 818 | L.info("No devices configured.") 819 | return 820 | 821 | if not devname: 822 | macaddrs = [d["macaddr"] for d in devices] 823 | devname = inquirer.list_input("Select device to configure", choices=sorted(macaddrs)) 824 | 825 | device = next((d for d in devices if d.get("name") == devname or d.get("macaddr") == devname), None) 826 | if not device: 827 | L.info(f"No device found with identifier: '{devname}'") 828 | return 829 | 830 | if device_edit(device): 831 | try: 832 | U.json_save(_config, config) 833 | L.info(f"Device '{devname}' configured successfully.") 834 | 835 | except (OSError, IOError, ValueError) as e: 836 | L.error(f"Failed to save configuration: {e}") 837 | 838 | 839 | ######################################################################################################################## 840 | 841 | 842 | @cli.command(name="remove", cls=CommonCommand) 843 | @click.argument("devname", required=True, type=str, nargs=1) 844 | def remove_device(devname: str, _config: str): 845 | """Remove a device by name or MAC address.""" 846 | 847 | try: 848 | config = U.json_load(_config) 849 | except FileNotFoundError: 850 | L.error(f"Config file '{_config}' not found.") 851 | return 852 | 853 | devices = config.get("omron", {}).get("devices", []) 854 | 855 | if not devname: 856 | macaddrs = [d["macaddr"] for d in devices] 857 | devname = inquirer.list_input("Select device to remove", choices=sorted(macaddrs)) 858 | 859 | device = next((d for d in devices if d.get("name") == devname or d.get("macaddr") == devname), None) 860 | 861 | if not device: 862 | L.info(f"No device found with identifier: {devname}") 863 | return 864 | 865 | devices.remove(device) 866 | try: 867 | U.json_save(_config, config) 868 | L.info(f"Device '{devname}' removed successfully.") 869 | 870 | except (OSError, IOError, ValueError) as e: 871 | L.error(f"Failed to save configuration: {e}") 872 | 873 | 874 | ######################################################################################################################## 875 | 876 | 877 | @cli.command(name="sync", cls=CommonCommand) 878 | @click.argument("devnames", required=False, nargs=-1) 879 | @click.option( 880 | "--category", 881 | "-c", 882 | "_category", 883 | required=False, 884 | type=click.Choice(list(OC.DeviceCategory.__members__.keys()), case_sensitive=False), 885 | ) 886 | @click.option("--days", default=0, show_default=True, type=click.INT, help="Number of days to sync from today.") 887 | @click.option( 888 | "--overwrite", is_flag=True, default=Options().overwrite, show_default=True, help="Overwrite existing measurements." 889 | ) 890 | @click.option( 891 | "--no-write", 892 | is_flag=True, 893 | default=not Options().write_to_garmin, 894 | show_default=True, 895 | help="Do not write to Garmin Connect.", 896 | ) 897 | def sync_device( 898 | devnames: T.List[str], 899 | _category: T.Optional[str], 900 | days: int, 901 | overwrite: bool, 902 | no_write: bool, 903 | _config: str, 904 | ): 905 | """Sync DEVNAMES... to Garmin Connect. 906 | 907 | \b 908 | DEVNAMES: List of Names or MAC addresses for the device to sync. [default: ALL] 909 | 910 | \b 911 | Examples: 912 | # Sync all devices for the last 7 days 913 | python omramin.py sync --days 7 914 | \b 915 | # Sync a specific device for the last 1 day 916 | python omramin.py sync "my scale" --days 1 917 | or 918 | python omramin.py sync 00:11:22:33:44:55 "my scale" --days 1 919 | """ 920 | 921 | opts = Options() 922 | opts.overwrite = overwrite 923 | opts.write_to_garmin = not no_write 924 | 925 | try: 926 | config = U.json_load(_config) 927 | 928 | except FileNotFoundError: 929 | L.error(f"Config file '{_config}' not found.") 930 | return 931 | 932 | category = OC.DeviceCategory[_category] if _category else None 933 | 934 | devices = config.get("omron", {}).get("devices", []) 935 | if not devices: 936 | L.info("No devices configured.") 937 | return 938 | 939 | try: 940 | startLocal, endLocal = calculate_date_range(days) 941 | except DateRangeException: 942 | L.info("Invalid date range") 943 | return 944 | 945 | # filter devices by enabled, category and name/mac address 946 | devices = filter_devices(devices, devnames=devnames, category=category) 947 | if not devices: 948 | L.info("No matching devices found") 949 | return 950 | 951 | try: 952 | gc = garmin_login(_config) 953 | except LoginError: 954 | L.info("Failed to login to Garmin Connect.") 955 | return 956 | 957 | try: 958 | oc = omron_login(_config) 959 | except LoginError: 960 | L.info("Failed to login to OMRON connect.") 961 | return 962 | 963 | if not oc or not gc: 964 | L.info("Failed to login to OMRON connect or Garmin Connect.") 965 | return 966 | 967 | for device in devices: 968 | ocDev = OC.OmronDevice(**device) 969 | omron_sync_device_to_garmin(oc, gc, ocDev, startLocal, endLocal, opts=opts) 970 | L.info(f"Device '{device['name']}' successfully synced.") 971 | 972 | 973 | ######################################################################################################################## 974 | 975 | 976 | @cli.command(name="export", cls=CommonCommand) 977 | @click.argument("devnames", required=False, nargs=-1) 978 | @click.option( 979 | "--category", 980 | "-c", 981 | "_category", 982 | required=True, 983 | type=click.Choice(list(OC.DeviceCategory.__members__.keys()), case_sensitive=False), 984 | ) 985 | @click.option("--days", default=0, show_default=True, type=click.INT, help="Number of days to sync from today.") 986 | @click.option( 987 | "--format", 988 | "_format", 989 | type=click.Choice(["csv", "json"], case_sensitive=False), 990 | default="csv", 991 | help="Output format", 992 | ) 993 | @click.option("--output", "-o", type=click.Path(), help="Output file path") 994 | def export_measurements( 995 | devnames: T.Optional[T.List[str]], 996 | _category: str, 997 | days: int, 998 | _format: T.Optional[str], 999 | output: T.Optional[str], 1000 | _config: str, 1001 | ): 1002 | """Export device measurements to CSV or JSON format.""" 1003 | 1004 | config = U.json_load(_config) 1005 | devices = config.get("omron", {}).get("devices", []) 1006 | category = OC.DeviceCategory[_category] 1007 | 1008 | try: 1009 | startLocal, endLocal = calculate_date_range(days) 1010 | except DateRangeException: 1011 | L.info("Invalid date range") 1012 | return 1013 | 1014 | # filter devices by enabled, category and name/mac address 1015 | devices = filter_devices(devices, devnames=devnames) 1016 | if not devices: 1017 | L.info("No matching devices found") 1018 | return 1019 | 1020 | startdateStr = datetime.fromtimestamp(startLocal).date().isoformat() 1021 | enddateStr = datetime.fromtimestamp(endLocal).date().isoformat() 1022 | 1023 | try: 1024 | oc = omron_login(_config) 1025 | except LoginError: 1026 | L.info("Failed to login to OMRON connect.") 1027 | return 1028 | 1029 | if not oc: 1030 | return 1031 | 1032 | exportdata = {} 1033 | for device in devices: 1034 | ocDev = OC.OmronDevice(**device) 1035 | L.info(f"Exporting device '{ocDev.name}' from {startdateStr} to {enddateStr}") 1036 | 1037 | measurements = oc.get_measurements( 1038 | ocDev, searchDateFrom=int(startLocal * 1000), searchDateTo=int(endLocal * 1000) 1039 | ) 1040 | if measurements: 1041 | exportdata[ocDev] = measurements 1042 | 1043 | if not exportdata: 1044 | L.info("No measurements found") 1045 | return 1046 | 1047 | if not output: 1048 | output = ( 1049 | f"omron_{category.name}_{datetime.fromtimestamp(startLocal).date()}_" 1050 | f"{datetime.fromtimestamp(endLocal).date()}.{_format}" 1051 | ) 1052 | 1053 | if _format == "json": 1054 | export_json(output, exportdata) 1055 | 1056 | else: 1057 | export_csv(output, exportdata) 1058 | 1059 | L.info(f"Exported {len(exportdata)} measurements to {output}") 1060 | 1061 | 1062 | def export_csv(output: str, exportdata: T.Dict[OC.OmronDevice, T.List[OC.MeasurementTypes]]) -> None: 1063 | with open(output, "w", newline="\n", encoding="utf-8") as f: 1064 | writer = None 1065 | for ocDev, measurements in exportdata.items(): 1066 | for m in measurements: 1067 | dt = datetime.fromtimestamp(m.measurementDate / 1000, tz=m.timeZone) 1068 | row = { 1069 | "timestamp": dt.isoformat(), 1070 | "deviceName": ocDev.name, 1071 | "deviceCategory": ocDev.category.name, 1072 | } 1073 | row.update(dataclasses.asdict(m)) 1074 | 1075 | if writer is None: 1076 | writer = csv.DictWriter( 1077 | f, fieldnames=row.keys(), quotechar='"', quoting=csv.QUOTE_ALL, lineterminator="\n" 1078 | ) 1079 | writer.writeheader() 1080 | writer.writerow(row) 1081 | 1082 | 1083 | def export_json(output: str, exportdata: T.Dict[OC.OmronDevice, T.List[OC.MeasurementTypes]]) -> None: 1084 | data = [] 1085 | for ocDev, measurements in exportdata.items(): 1086 | for m in measurements: 1087 | dt = datetime.fromtimestamp(m.measurementDate / 1000, tz=m.timeZone) 1088 | entry = { 1089 | "timestamp": dt.isoformat(), 1090 | "deviceName": ocDev.name, 1091 | "deviceCategory": ocDev.category.name, 1092 | } 1093 | entry.update(dataclasses.asdict(m)) 1094 | data.append(entry) 1095 | 1096 | U.json_save(output, data) 1097 | 1098 | 1099 | ######################################################################################################################## 1100 | 1101 | if __name__ == "__main__": 1102 | pathlib.Path("~/.omramin").expanduser().resolve().mkdir(parents=True, exist_ok=True) 1103 | 1104 | cli() 1105 | 1106 | ######################################################################################################################## 1107 | --------------------------------------------------------------------------------