omron-connect-api 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ increment_version() {
5
+ local delimiter=.
6
+ local array=($(echo "$1" | tr $delimiter '\n'))
7
+ array[$2]=$((array[$2]+1))
8
+ if [ $2 -lt 2 ]; then array[2]=0; fi
9
+ if [ $2 -lt 1 ]; then array[1]=0; fi
10
+ echo $(local IFS=$delimiter ; echo "${array[*]}")
11
+ }
12
+
13
+ apply_bump() {
14
+ if [ "$1" == "patch" ]; then
15
+ increment_version "$2" 2
16
+ elif [ "$1" == "minor" ]; then
17
+ increment_version "$2" 1
18
+ elif [ "$1" == "major" ]; then
19
+ increment_version "$2" 0
20
+ fi
21
+ }
22
+
23
+ change_pyproject() {
24
+ sed -i '' "s/${1}/${2}/g" pyproject.toml
25
+ }
26
+
27
+ git_tag () {
28
+ git commit -am "${1}"
29
+ git tag "${1}" -am "${1}"
30
+ }
31
+
32
+ BUMP="$1"
33
+ CURRENT="$(grep 'version' pyproject.toml | cut -d\ -f3 | tr -d '"')"
34
+ NEXT="$(apply_bump "$BUMP" "$CURRENT")"
35
+
36
+ echo "$BUMP - from $CURRENT to $NEXT"
37
+ change_pyproject "$CURRENT" "$NEXT"
38
+ git_tag "$NEXT"
@@ -0,0 +1,55 @@
1
+ name: Build, test and publish
2
+ on: push
3
+
4
+ jobs:
5
+ build:
6
+ runs-on: ubuntu-latest
7
+ steps:
8
+ - name: Checkout
9
+ uses: actions/checkout@v4
10
+
11
+ - name: Set up Python
12
+ uses: actions/setup-python@v5
13
+ with:
14
+ python-version: '3.13'
15
+ cache: 'pip'
16
+
17
+ - name: Install dependencies
18
+ run: pip install -r requirements.txt
19
+
20
+ - name: Check lint
21
+ run: ruff check --output-format=github
22
+
23
+ - name: Test
24
+ run: python3 -m unittest
25
+
26
+ - name: Build
27
+ run: python3 -m build
28
+
29
+ - name: Keep artifacts
30
+ uses: actions/upload-artifact@v4
31
+ with:
32
+ name: python-package-distributions
33
+ path: dist/
34
+
35
+ publish-to-pypi:
36
+ if: startsWith(github.ref, 'refs/tags/') # publish on tag pushes
37
+ needs:
38
+ - build
39
+ runs-on: ubuntu-latest
40
+
41
+ environment:
42
+ name: release
43
+ url: https://pypi.org/p/omron-connect-api
44
+
45
+ permissions:
46
+ id-token: write
47
+
48
+ steps:
49
+ - name: Download artifacts
50
+ uses: actions/download-artifact@v4
51
+ with:
52
+ name: python-package-distributions
53
+ path: dist/
54
+ - name: Publish
55
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,4 @@
1
+ .venv
2
+
3
+ .coverage
4
+ htmlcov/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 happydev.ca
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,20 @@
1
+ .PHONY : venv test coverage build release
2
+
3
+ venv:
4
+ python3 -m venv .venv && \
5
+ source .venv/bin/activate && \
6
+ pip install -r requirements.txt
7
+
8
+ test:
9
+ ruff check && \
10
+ python3 -m unittest
11
+
12
+ coverage:
13
+ coverage run --branch -m unittest
14
+ coverage html
15
+
16
+ build:
17
+ python3 -m build
18
+
19
+ release:
20
+ .github/release.sh ${bump}
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: omron-connect-api
3
+ Version: 0.0.3
4
+ Summary: Library to communicate with OMRON connect API
5
+ Project-URL: Homepage, https://github.com/happydev-ca/omron-connect-api
6
+ Project-URL: Issues, https://github.com/happydev-ca/omron-connect-api/issues
7
+ Author-email: Francois Perron <fperron@gmail.com>
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Home Automation
14
+ Requires-Python: >=3.13
15
+ Requires-Dist: aiohttp
16
+ Description-Content-Type: text/markdown
17
+
18
+ [![Build, test and publish](https://github.com/happydev-ca/omron-connect-api/actions/workflows/publish.yml/badge.svg)](https://github.com/happydev-ca/omron-connect-api/actions/workflows/publish.yml)
19
+
20
+ # omron-connect-api
21
+
22
+ Library to communicate with OMRON connect API.
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ import aiohttp
28
+ import asyncio
29
+ import os
30
+
31
+ from omron_connect_api import OmronConnectApi
32
+
33
+
34
+ async def run():
35
+ async with aiohttp.ClientSession() as session:
36
+ api = OmronConnectApi(os.environ['EMAIL'], os.environ['PASSWORD'], session)
37
+ blood_pressure_readings = await api.async_get_blood_pressure_readings()
38
+ print(blood_pressure_readings)
39
+
40
+
41
+ asyncio.run(run())
42
+ ```
43
+
44
+ ### Build and test locally
45
+
46
+ ```shell
47
+ make venv
48
+ source .venv/bin/activate
49
+ make test
50
+ make build
51
+ ```
52
+
53
+ ### Release version
54
+
55
+ ```shell
56
+ make release bump=patch|minor|major
57
+ ```
@@ -0,0 +1,40 @@
1
+ [![Build, test and publish](https://github.com/happydev-ca/omron-connect-api/actions/workflows/publish.yml/badge.svg)](https://github.com/happydev-ca/omron-connect-api/actions/workflows/publish.yml)
2
+
3
+ # omron-connect-api
4
+
5
+ Library to communicate with OMRON connect API.
6
+
7
+ ## Usage
8
+
9
+ ```python
10
+ import aiohttp
11
+ import asyncio
12
+ import os
13
+
14
+ from omron_connect_api import OmronConnectApi
15
+
16
+
17
+ async def run():
18
+ async with aiohttp.ClientSession() as session:
19
+ api = OmronConnectApi(os.environ['EMAIL'], os.environ['PASSWORD'], session)
20
+ blood_pressure_readings = await api.async_get_blood_pressure_readings()
21
+ print(blood_pressure_readings)
22
+
23
+
24
+ asyncio.run(run())
25
+ ```
26
+
27
+ ### Build and test locally
28
+
29
+ ```shell
30
+ make venv
31
+ source .venv/bin/activate
32
+ make test
33
+ make build
34
+ ```
35
+
36
+ ### Release version
37
+
38
+ ```shell
39
+ make release bump=patch|minor|major
40
+ ```
@@ -0,0 +1,15 @@
1
+ import aiohttp
2
+ import asyncio
3
+ import os
4
+
5
+ from omron_connect_api import OmronConnectApi
6
+
7
+
8
+ async def run():
9
+ async with aiohttp.ClientSession() as session:
10
+ api = OmronConnectApi(os.environ['EMAIL'], os.environ['PASSWORD'], session)
11
+ blood_pressure_readings = await api.async_get_blood_pressure_readings()
12
+ print(blood_pressure_readings)
13
+
14
+
15
+ asyncio.run(run())
@@ -0,0 +1,5 @@
1
+ from .blood_pressure.blood_pressure_reading import BloodPressureReading
2
+ from .omron_connect_api_errors import OmronConnectAPIError
3
+ from .omron_connect_api import OmronConnectApi
4
+
5
+ __all__ = ['BloodPressureReading', 'OmronConnectAPIError', 'OmronConnectApi']
@@ -0,0 +1,17 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from typing import TypeAlias
4
+
5
+ mmHg: TypeAlias = int
6
+ bpm: TypeAlias = int
7
+ count: TypeAlias = int
8
+
9
+
10
+ @dataclass()
11
+ class BloodPressureReading:
12
+ measurement_date: datetime
13
+ diastolic: mmHg
14
+ systolic: mmHg
15
+ pulse: bpm
16
+ irregular_heart_beats: count
17
+ movements: count
@@ -0,0 +1,18 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Any, Dict
3
+
4
+ from .. import BloodPressureReading
5
+
6
+
7
+ class BloodPressureReadingResponse:
8
+
9
+ @classmethod
10
+ def from_json(cls, data: Dict[str, Any]) -> BloodPressureReading:
11
+ return BloodPressureReading(
12
+ measurement_date=datetime.fromtimestamp(int(data['measurementDate'])/1000, timezone.utc),
13
+ diastolic=data['diastolic'],
14
+ systolic=data['systolic'],
15
+ pulse=data['pulse'],
16
+ irregular_heart_beats=int(data['countIrregularHeartBeat']),
17
+ movements=data['movementDetect']
18
+ )
@@ -0,0 +1,68 @@
1
+ from datetime import datetime, timedelta
2
+ from logging import getLogger, Logger
3
+ from typing import List
4
+
5
+ import aiohttp
6
+
7
+ from . import OmronConnectAPIError, BloodPressureReading
8
+ from .blood_pressure.blood_pressure_reading_response import BloodPressureReadingResponse
9
+
10
+ LOGGER: Logger = getLogger(__package__)
11
+
12
+
13
+ class OmronConnectApi:
14
+ base_url = 'https://oi-api.ohiomron.com'
15
+ TOKEN_EXPIRED = timedelta(seconds=1)
16
+
17
+ def __init__(self, email_address: str, password: str, session: aiohttp.ClientSession):
18
+ self.email_address = email_address
19
+ self.password = password
20
+ self.session = session
21
+ self.headers = {'Content-Type': 'application/json'}
22
+ self.expires_at = datetime.now() - self.TOKEN_EXPIRED
23
+ self.last_synced_time = 0
24
+
25
+ async def async_authenticate(self) -> None:
26
+ if datetime.now() < self.expires_at:
27
+ return
28
+
29
+ url = '/app/login'
30
+ json = {'app': 'OCM', 'country': 'CA', 'emailAddress': self.email_address, 'password': self.password}
31
+ async with await self._post(url, json) as response:
32
+ body = await response.json()
33
+ self.headers['Authorization'] = body['accessToken']
34
+ self.expires_at = datetime.now() + timedelta(seconds=body['expiresIn'])
35
+
36
+ async def async_get_blood_pressure_readings(self) -> List[BloodPressureReading]:
37
+ await self.async_authenticate()
38
+
39
+ url = f'/app/v2/sync/bp?nextpaginationKey=0&lastSyncedTime={self.last_synced_time}&phoneIdentifier='
40
+ async with await self._get(url) as response:
41
+ json = await response.json()
42
+ self.last_synced_time = int(json['lastSyncedTime'])
43
+ return [BloodPressureReadingResponse.from_json(reading) for reading in json['data']]
44
+
45
+ async def _post(self, url: str, json: dict) -> aiohttp.ClientResponse:
46
+ response = await self.session.post(url=f'{self.base_url}{url}', headers=self.headers, json=json)
47
+ await self._log(url, response, self.headers, json)
48
+ await self._raise_on_error(response)
49
+ return response
50
+
51
+ async def _get(self, url: str) -> aiohttp.ClientResponse:
52
+ response = await self.session.get(f'{self.base_url}{url}', headers=self.headers)
53
+ await self._log(url, response, self.headers)
54
+ await self._raise_on_error(response)
55
+ return response
56
+
57
+ async def _raise_on_error(self, response: aiohttp.ClientResponse) -> None:
58
+ body = await response.json()
59
+ if body['success']:
60
+ return
61
+ self.expires_at = datetime.now() - self.TOKEN_EXPIRED
62
+ self.headers.pop('Authorization', None)
63
+ raise OmronConnectAPIError(response)
64
+
65
+ @staticmethod
66
+ async def _log(url: str, response: aiohttp.ClientResponse, headers: dict, json: dict = None) -> None:
67
+ LOGGER.debug(
68
+ f"{url} : Request[[ headers=[{headers}] json=[{json}] ]] - Response[[ status=[{response.status}] headers=[{dict(response.headers)}] body=[{await response.text()}] ]]")
@@ -0,0 +1,10 @@
1
+ import aiohttp
2
+
3
+
4
+ class OmronConnectAPIError(aiohttp.ClientResponseError):
5
+ def __init__(self, error_response: aiohttp.ClientResponse):
6
+ self.request_info = error_response.request_info
7
+ self.history = error_response.history
8
+ self.status = error_response.status
9
+ self.message = error_response.reason
10
+ self.headers = error_response.headers
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "omron-connect-api"
3
+ version = "0.0.3"
4
+ description = "Library to communicate with OMRON connect API"
5
+ authors = [{ name = "Francois Perron", email = "fperron@gmail.com" }]
6
+ readme = "README.md"
7
+ classifiers = [
8
+ "Development Status :: 4 - Beta",
9
+ "Programming Language :: Python :: 3",
10
+ "License :: OSI Approved :: MIT License",
11
+ "Operating System :: OS Independent",
12
+ "Topic :: Home Automation",
13
+ ]
14
+
15
+ requires-python = ">=3.13"
16
+ dependencies = ["aiohttp"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/happydev-ca/omron-connect-api"
20
+ Issues = "https://github.com/happydev-ca/omron-connect-api/issues"
21
+
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["omronconnectapi"]
@@ -0,0 +1,11 @@
1
+ # prod
2
+ aiohttp~=3.11.18
3
+
4
+ # test
5
+ aioresponses~=0.7.8
6
+
7
+ # dev
8
+ build~=1.2.2
9
+ coverage~=7.8.0
10
+ pip~=25.1.1
11
+ ruff~=0.11.10
File without changes
@@ -0,0 +1,25 @@
1
+ import unittest
2
+ from datetime import datetime, timezone
3
+ from omron_connect_api.blood_pressure.blood_pressure_reading_response import BloodPressureReadingResponse
4
+
5
+
6
+ class TestBloodPressureReadingResponse(unittest.TestCase):
7
+
8
+ def test_parses_json(self):
9
+ reading = BloodPressureReadingResponse.from_json(
10
+ {
11
+ 'measurementDate': '1747615042000',
12
+ 'diastolic': 82,
13
+ 'systolic': 103,
14
+ 'pulse': 62,
15
+ 'countIrregularHeartBeat': '2',
16
+ 'movementDetect': 1
17
+ }
18
+ )
19
+ datetime_in_utc = datetime(2025, 5, 19, 0, 37, 22, tzinfo=timezone.utc)
20
+ self.assertEqual(reading.measurement_date, datetime_in_utc)
21
+ self.assertEqual(reading.diastolic, 82)
22
+ self.assertEqual(reading.systolic, 103)
23
+ self.assertEqual(reading.pulse, 62)
24
+ self.assertEqual(reading.irregular_heart_beats, 2)
25
+ self.assertEqual(reading.movements, 1)
@@ -0,0 +1,35 @@
1
+ from http import HTTPStatus
2
+
3
+ from aioresponses import aioresponses
4
+
5
+
6
+ class OmronConnectServerForTest:
7
+ base_url = 'https://oi-api.ohiomron.com'
8
+
9
+ def __init__(self):
10
+ self.server = aioresponses()
11
+
12
+ def __enter__(self):
13
+ self.server.__enter__()
14
+ return self
15
+
16
+ def __exit__(self, *args):
17
+ self.server.__exit__(*args)
18
+
19
+ def prepare_login_response(self, body: dict = None, repeat: bool = True) -> str:
20
+ if body is None:
21
+ body = {'success': True, 'accessToken': 'token', 'expiresIn': 3600}
22
+ self.server.post(f'{self.base_url}/app/login', status=HTTPStatus.OK, payload=body, repeat=repeat)
23
+ return body.get('accessToken')
24
+
25
+ def prepare_blood_pressure_readings_response(self, last_synced_time: int = 0, body: dict = None, repeat: bool = True):
26
+ if body is None:
27
+ body = {'success': True, 'data': [], 'lastSyncedTime': 0}
28
+ url = f'/app/v2/sync/bp?nextpaginationKey=0&lastSyncedTime={last_synced_time}&phoneIdentifier='
29
+ self.server.get(f'{self.base_url}{url}', status=HTTPStatus.OK, payload=body, repeat=repeat)
30
+
31
+ def assert_called_with(self, url, method, *args, **kwargs):
32
+ self.server.assert_called_with(f'{self.base_url}{url}', method, *args, **kwargs)
33
+
34
+ def assert_called_once_with(self, url, method, *args, **kwargs):
35
+ self.server.assert_called_once_with(f'{self.base_url}{url}', method, *args, **kwargs)
@@ -0,0 +1,130 @@
1
+ import unittest
2
+ from datetime import datetime, timedelta
3
+
4
+ import aiohttp
5
+
6
+ from omron_connect_api import OmronConnectAPIError, OmronConnectApi
7
+ from omron_connect_api.blood_pressure.blood_pressure_reading_response import BloodPressureReadingResponse
8
+ from test.omron_connect_server_for_test import OmronConnectServerForTest
9
+
10
+
11
+ class TestOmronConnectAPI(unittest.IsolatedAsyncioTestCase):
12
+ email_address = 'user@gmail.com'
13
+ password = 'password'
14
+
15
+ async def test_authenticates_with_email_address_and_password(self):
16
+ with OmronConnectServerForTest() as server:
17
+ server.prepare_login_response({'success': True, 'accessToken': 'any-token', 'expiresIn': 3600})
18
+
19
+ async with aiohttp.ClientSession() as session:
20
+ api = OmronConnectApi(self.email_address, self.password, session)
21
+ await api.async_authenticate()
22
+
23
+ server.assert_called_with(url='/app/login',
24
+ method='POST',
25
+ headers={'Content-Type': 'application/json'},
26
+ json={
27
+ 'app': 'OCM',
28
+ 'country': 'CA',
29
+ 'emailAddress': self.email_address,
30
+ 'password': self.password
31
+ })
32
+
33
+ self.assertEqual(api.headers.get('Authorization'), 'any-token')
34
+ self.assertLess(datetime.now() + timedelta(seconds=3600) - api.expires_at, timedelta(seconds=1))
35
+
36
+ async def test_reuse_valid_token(self):
37
+ with OmronConnectServerForTest() as server:
38
+ server.prepare_login_response({'success': True, 'accessToken': 'any-token', 'expiresIn': 3600})
39
+ async with aiohttp.ClientSession() as session:
40
+ api = OmronConnectApi(self.email_address, self.password, session)
41
+ await api.async_authenticate()
42
+ await api.async_authenticate()
43
+
44
+ server.assert_called_once_with(url='/app/login',
45
+ method='POST',
46
+ headers={'Content-Type': 'application/json'},
47
+ json={
48
+ 'app': 'OCM',
49
+ 'country': 'CA',
50
+ 'emailAddress': self.email_address,
51
+ 'password': self.password
52
+ })
53
+
54
+ async def test_authenticates_error(self):
55
+ with OmronConnectServerForTest() as server:
56
+ server.prepare_login_response({'success': False})
57
+
58
+ async with aiohttp.ClientSession() as session:
59
+ api = OmronConnectApi(self.email_address, self.password, session)
60
+
61
+ with self.assertRaises(OmronConnectAPIError):
62
+ await api.async_authenticate()
63
+
64
+ async def test_clear_headers_on_error(self):
65
+ with OmronConnectServerForTest() as server:
66
+ server.prepare_login_response({'success': False})
67
+
68
+ async with aiohttp.ClientSession() as session:
69
+ api = OmronConnectApi(self.email_address, self.password, session)
70
+ api.headers['Authorization'] = 'any-token'
71
+
72
+ with self.assertRaises(OmronConnectAPIError):
73
+ await api.async_authenticate()
74
+ self.assertEqual(api.headers.get('Authorization'), None)
75
+ self.assertLess(datetime.now() - api.TOKEN_EXPIRED - api.expires_at, timedelta(seconds=1))
76
+
77
+
78
+ async def test_get_blood_pressure_readings(self):
79
+ with OmronConnectServerForTest() as server:
80
+ token = server.prepare_login_response()
81
+ response = {
82
+ 'success': True,
83
+ 'lastSyncedTime': 1747615042001,
84
+ 'data': [
85
+ {
86
+ 'measurementDate': '1747615042000',
87
+ 'diastolic': 82,
88
+ 'systolic': 103,
89
+ 'pulse': 62,
90
+ 'countIrregularHeartBeat': 2,
91
+ 'movementDetect': 1
92
+ },
93
+ {
94
+ 'measurementDate': '1747615062000',
95
+ 'diastolic': 72,
96
+ 'systolic': 100,
97
+ 'pulse': 64,
98
+ 'countIrregularHeartBeat': 0,
99
+ 'movementDetect': 0
100
+ }
101
+ ]
102
+ }
103
+ server.prepare_blood_pressure_readings_response(0, response)
104
+ async with aiohttp.ClientSession() as session:
105
+ api = OmronConnectApi(self.email_address, self.password, session)
106
+ readings = await api.async_get_blood_pressure_readings()
107
+
108
+ server.assert_called_with(
109
+ url='/app/v2/sync/bp?nextpaginationKey=0&lastSyncedTime=0&phoneIdentifier=',
110
+ method='GET',
111
+ headers={'Content-Type': 'application/json', 'Authorization': token})
112
+
113
+ expected_readings = [BloodPressureReadingResponse.from_json(reading) for reading in response['data']]
114
+ self.assertEqual(readings, expected_readings)
115
+ self.assertEqual(api.last_synced_time, 1747615042001)
116
+
117
+ async def test_get_blood_pressure_readings_from_last_synced_time(self):
118
+ with OmronConnectServerForTest() as server:
119
+ token = server.prepare_login_response()
120
+ server.prepare_blood_pressure_readings_response(last_synced_time=1747615042001)
121
+
122
+ async with aiohttp.ClientSession() as session:
123
+ api = OmronConnectApi(self.email_address, self.password, session)
124
+ api.last_synced_time = 1747615042001
125
+ await api.async_get_blood_pressure_readings()
126
+
127
+ server.assert_called_with(
128
+ url='/app/v2/sync/bp?nextpaginationKey=0&lastSyncedTime=1747615042001&phoneIdentifier=',
129
+ method='GET',
130
+ headers={'Content-Type': 'application/json', 'Authorization': token})