From f0308d3df5ddb9f7bcb234b733773f4f6373b048 Mon Sep 17 00:00:00 2001 From: Ryoma Kai <706834+legnoh@users.noreply.github.com> Date: Sat, 25 Nov 2023 23:04:16 +0900 Subject: [PATCH] first implementation --- .dockerignore | 11 ++ .github/dependabot.yml | 17 ++ .github/workflows/automerge.yml | 24 +++ .github/workflows/ci.yml | 27 +++ .github/workflows/publish.yml | 47 +++++ .gitignore | 186 +++++++++++++++++++ .vscode/launch.json | 16 ++ Dockerfile | 15 ++ Pipfile | 18 ++ Pipfile.lock | 224 +++++++++++++++++++++++ README.md | 58 +++++- config/metrics.yml | 310 ++++++++++++++++++++++++++++++++ example/oura.prom | 159 ++++++++++++++++ main.py | 87 +++++++++ modules/oura.py | 71 ++++++++ modules/oura_dataclasses.py | 135 ++++++++++++++ modules/prometheus.py | 54 ++++++ 17 files changed, 1458 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/automerge.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 Dockerfile create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 config/metrics.yml create mode 100644 example/oura.prom create mode 100644 main.py create mode 100644 modules/oura.py create mode 100644 modules/oura_dataclasses.py create mode 100644 modules/prometheus.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8b84f00 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.github +.vscode +example +modules/__pycache__ +.dockerignore +.env +.git +.gitignore +Dockerfile +Pipfile +Pipfile.lock diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..334bd22 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: daily + time: "04:00" # 13:00(UTC+9) + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: daily + time: "04:00" # 13:00(UTC+9) + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: daily + time: "04:00" # 13:00(UTC+9) diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 0000000..0990454 --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,24 @@ +name: 'Merge Dependencies' + +on: + pull_request_target: + +permissions: + pull-requests: write + contents: write + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} + steps: + - name: 'Enable auto-merge on PR' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: 'Approve PR' + run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..30453b8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: ci + +on: + pull_request: + branches: + - main + +jobs: + docker-build-and-push: + runs-on: ubuntu-latest + steps: + + - name: Check Out Repo + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f7b670a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,47 @@ +name: publish + +on: + push: + branches: + - main + paths-ignore: + - '.github/**' + - '.vscode/**' + - '.example.env' + - 'LICENSE' + - 'README.md' + +jobs: + docker-build-and-push: + runs-on: ubuntu-latest + steps: + + - name: Check Out Repo + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/oura-exporter:latest + + # - name: Restart Docker on RaspberryPi + # uses: benc-uk/workflow-dispatch@v1.2.2 + # with: + # repo: legnoh/life-dashboard + # workflow: Reset docker-compose setting + # token: ${{ secrets.PERSONAL_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2df67c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,186 @@ +### Generated by gibo (https://github.com/simonwhitaker/gibo) +### https://raw.github.com/github/gitignore/991e760c1c6d50fdda246e0178b9c58b06770b90/Global/Linux.gitignore + +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* +### Generated by gibo (https://github.com/simonwhitaker/gibo) +### https://raw.github.com/github/gitignore/991e760c1c6d50fdda246e0178b9c58b06770b90/Global/macOS.gitignore + +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +### Generated by gibo (https://github.com/simonwhitaker/gibo) +### https://raw.github.com/github/gitignore/991e760c1c6d50fdda246e0178b9c58b06770b90/Python.gitignore + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7edf2ee --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "main", + "type": "python", + "request": "launch", + "program": "main.py", + "console": "integratedTerminal", + "envFile": "${workspaceFolder}/.env" + } + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7c10a01 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3-slim-buster + +WORKDIR /usr/src/app +COPY Pipfile Pipfile.lock ${WORKDIR} + +RUN pip3 install --upgrade pip && \ + pip3 install pipenv --no-cache-dir && \ + pipenv install --system --deploy && \ + pip3 uninstall -y pipenv virtualenv-clone virtualenv + +COPY . ${WORKDIR} + +EXPOSE 8000 + +CMD [ "python3", "./main.py" ] diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..848db18 --- /dev/null +++ b/Pipfile @@ -0,0 +1,18 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +requests = "*" +prometheus-client = "*" +pyyaml = "*" +dacite = "*" + +[dev-packages] + +[requires] +python_version = "3" + +[scripts] +main = "python3 main.py" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..26fb7d5 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,224 @@ +{ + "_meta": { + "hash": { + "sha256": "62dd8e3ff651e3288c6fbe34d6f7ff953aacf82bcd1e8e89620b85f6cf6c4530" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", + "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474" + ], + "markers": "python_version >= '3.6'", + "version": "==2023.11.17" + }, + "charset-normalizer": { + "hashes": [ + "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", + "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", + "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", + "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", + "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", + "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", + "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", + "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", + "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", + "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", + "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", + "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", + "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", + "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", + "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", + "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", + "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", + "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", + "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", + "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", + "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", + "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", + "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", + "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", + "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", + "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", + "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", + "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", + "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", + "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", + "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", + "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", + "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", + "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", + "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", + "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", + "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", + "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", + "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", + "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", + "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", + "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", + "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", + "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", + "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", + "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", + "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", + "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", + "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", + "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", + "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", + "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", + "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", + "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", + "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", + "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", + "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", + "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", + "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", + "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", + "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", + "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", + "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", + "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", + "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", + "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", + "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", + "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", + "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", + "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", + "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", + "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", + "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", + "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", + "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", + "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", + "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", + "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", + "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", + "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", + "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", + "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", + "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", + "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", + "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", + "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", + "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", + "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", + "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", + "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==3.3.2" + }, + "dacite": { + "hashes": [ + "sha256:cc31ad6fdea1f49962ea42db9421772afe01ac5442380d9a99fcf3d188c61afe" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==1.8.1" + }, + "idna": { + "hashes": [ + "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", + "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" + ], + "markers": "python_version >= '3.5'", + "version": "==3.4" + }, + "prometheus-client": { + "hashes": [ + "sha256:4585b0d1223148c27a225b10dbec5ae9bc4c81a99a3fa80774fa6209935324e1", + "sha256:c88b1e6ecf6b41cd8fb5731c7ae919bf66df6ec6fafa555cd6c0e16ca169ae92" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==0.19.0" + }, + "pyyaml": { + "hashes": [ + "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", + "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", + "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", + "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", + "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", + "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", + "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", + "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", + "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", + "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", + "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", + "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", + "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", + "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", + "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", + "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", + "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", + "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", + "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", + "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", + "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", + "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", + "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", + "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", + "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", + "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", + "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", + "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", + "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", + "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", + "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", + "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", + "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", + "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", + "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", + "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", + "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", + "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", + "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", + "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", + "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", + "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", + "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", + "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", + "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", + "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", + "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", + "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", + "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", + "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==6.0.1" + }, + "requests": { + "hashes": [ + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==2.31.0" + }, + "urllib3": { + "hashes": [ + "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3", + "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54" + ], + "markers": "python_version >= '3.8'", + "version": "==2.1.0" + } + }, + "develop": {} +} diff --git a/README.md b/README.md index 6fabc4a..9202b87 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,58 @@ # oura-exporter -Prometheus(OpenMetrics) exporter for Oura Ring + +[![Badge](https://img.shields.io/badge/docker-legnoh/oura--exporter-blue?logo=docker&link=https://hub.docker.com/r/legnoh/oura-exporter)](https://hub.docker.com/r/legnoh/oura-exporter) [![ci](https://github.com/legnoh/oura-exporter/actions/workflows/ci.yml/badge.svg)](https://github.com/legnoh/oura-exporter/actions/workflows/ci.yml) + +Prometheus(OpenMetrics) exporter for [Oura Ring](https://ouraring.com). + +## Usage + +### Registration + +At first, create Oura Personal Access Tokens(PATs) for yours. + +- [Personal Access Tokens - Authentication | Oura Developer](https://cloud.ouraring.com/docs/authentication#personal-access-tokens) + +And check your local TZ identifier. + +- [List of tz database time zones - Wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) + +### Start(Docker) + +The simplest way to use it is with Docker. + +``` +docker run -p 8000:8000 \ + -e OURA_ACCESS_TOKEN="youraccesstokenhere" \ + -e TZ="Asia/Tokyo" \ + legnoh/oura-exporter +``` + +### Start(source) + +Alternatively, it can be started from the source. + +```sh +# clone +git clone https://github.com/legnoh/oura-exporter.git && cd oura-exporter +pipenv install + +# prepare .env file for your apps +cat << EOS > .env +OURA_ACCESS_TOKEN="youraccesstokenhere" +TZ="Asia/Tokyo" +EOS + +# run exporter +pipenv run main +``` + +## Metrics + +please check [metrics.yml](./config/metrics.yml) or [example](./example/oura.prom) + +## Disclaim + +- This script is NOT authorized by Oura. + - We are not responsible for any damages caused by using this script. +- This script is not intended to overload these sites or services. + - When using this script, please keep your request frequency within a sensible range. diff --git a/config/metrics.yml b/config/metrics.yml new file mode 100644 index 0000000..0ea602f --- /dev/null +++ b/config/metrics.yml @@ -0,0 +1,310 @@ +categories: +- name: daily_activity + prefix: oura_daily_activity_ + labels: &common_labels [email] + metrics: + - name: score + desc: Activity score in range [1, 100] + type: gauge + unit: pt + labels: *common_labels + - name: active_calories + desc: Active calories expended (in kilocalories) + type: gauge + unit: kilocalories + labels: *common_labels + - name: average_met_minutes + desc: Average metabolic equivalent (MET) in minutes + type: gauge + unit: minutes + labels: *common_labels + - name: contributors_meet_daily_targets + desc: Contribution of meeting previous 7-day daily activity targets in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.meet_daily_targets + - name: contributors_move_every_hour + desc: Contribution of previous 24-hour inactivity alerts in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.move_every_hour + - name: contributors_recovery_time + desc: Contribution of previous 7-day recovery time in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.recovery_time + - name: contributors_stay_active + desc: Contribution of previous 24-hour activity in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.stay_active + - name: contributors_training_frequency + desc: Contribution of previous 7-day exercise frequency in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.training_frequency + - name: contributors_training_volume + desc: Contribution of previous 7-day exercise volume in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.training_volume + - name: equivalent_walking_distance + desc: Equivalent walking distance (in meters) of energy expenditure + type: gauge + unit: meters + labels: *common_labels + - name: high_activity_met_minutes + desc: High activity metabolic equivalent (MET) in minutes + type: gauge + unit: minutes + labels: *common_labels + - name: high_activity_time + desc: High activity metabolic equivalent (MET) in seconds + type: gauge + unit: seconds + labels: *common_labels + - name: inactivity_alerts + desc: Number of inactivity alerts received + type: gauge + unit: counts + labels: *common_labels + - name: low_activity_met_minutes + desc: Low activity metabolic equivalent (MET) in minutes + type: gauge + unit: minutes + labels: *common_labels + - name: low_activity_time + desc: Low activity metabolic equivalent (MET) in seconds + type: gauge + unit: seconds + labels: *common_labels + - name: medium_activity_met_minutes + desc: Medium activity metabolic equivalent (MET) in minutes + type: gauge + unit: minutes + labels: *common_labels + - name: medium_activity_time + desc: Medium activity metabolic equivalent (MET) in seconds + type: gauge + unit: seconds + labels: *common_labels + - name: meters_to_target + desc: Remaining meters to target (from target_meters) + type: gauge + unit: meters + labels: *common_labels + - name: non_wear_time + desc: The time (in seconds) in which the ring was not worn + type: gauge + unit: seconds + labels: *common_labels + - name: resting_time + desc: Resting time (in seconds) + type: gauge + unit: seconds + labels: *common_labels + - name: sedentary_met_minutes + desc: Sedentary metabolic equivalent (MET) in minutes + type: gauge + unit: minutes + labels: *common_labels + - name: sedentary_time + desc: Sedentary metabolic equivalent (MET) in seconds + type: gauge + unit: seconds + labels: *common_labels + - name: steps + desc: Total number of steps taken + type: gauge + unit: steps + labels: *common_labels + - name: target_calories + desc: Daily activity target (in kilocalories) + type: gauge + unit: kilocalories + labels: *common_labels + - name: target_meters + desc: Daily activity target (in meters) + type: gauge + unit: meters + labels: *common_labels + - name: total_calories + desc: Total calories expended (in kilocalories) + type: gauge + unit: kilocalories + labels: *common_labels +- name: daily_readiness + prefix: oura_daily_readiness_ + labels: *common_labels + metrics: + - name: contributors_activity_balance + desc: Contribution of cumulative activity balance in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.activity_balance + - name: contributors_body_temperature + desc: Contribution of body temperature in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.body_temperature + - name: contributors_hrv_balance + desc: Contribution of heart rate variability balance in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.hrv_balance + - name: contributors_previous_day_activity + desc: Contribution of previous day's activity in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.previous_day_activity + - name: contributors_previous_night + desc: Contribution of previous night's sleep in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.previous_night + - name: contributors_recovery_index + desc: Contribution of recovery index in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.recovery_index + - name: contributors_resting_heart_rate + desc: Contribution of resting heart rate in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.resting_heart_rate + - name: contributors_sleep_balance + desc: Contribution of sleep balance in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.sleep_balance + - name: score + desc: Daily readiness score. + type: gauge + unit: pt + labels: *common_labels + - name: temperature_deviation + desc: Temperature deviation in degrees Celsius. + type: gauge + unit: celsius + labels: *common_labels + - name: temperature_trend_deviation + desc: Temperature trend deviation in degrees Celsius. + type: gauge + unit: celsius + labels: *common_labels +- name: daily_sleep + prefix: oura_daily_sleep_ + labels: *common_labels + metrics: + - name: contributors_deep_sleep + desc: Contribution of deep sleep in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.deep_sleep + - name: contributors_efficiency + desc: Contribution of sleep efficiency in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.efficiency + - name: contributors_latency + desc: Contribution of sleep latency in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.latency + - name: contributors_rem_sleep + desc: Contribution of REM sleep in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.rem_sleep + - name: contributors_restfulness + desc: Contribution of sleep restfulness in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.restfulness + - name: contributors_timing + desc: Contribution of sleep timing in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.timing + - name: contributors_total_sleep + desc: Contribution of total sleep in range [1, 100]. + type: gauge + unit: pt + labels: *common_labels + iterator: contributors.total_sleep + - name: score + desc: Daily sleep score. + type: gauge + unit: pt + labels: *common_labels +- name: daily_spo2 + prefix: oura_daily_spo2_ + labels: *common_labels + metrics: + - name: spo2_percentage_average + desc: The SpO2 percentage value aggregated over a single day. + type: gauge + unit: percentage + labels: *common_labels + iterator: spo2_percentage.average +- name: heartrate + prefix: oura_heartrate_ + labels: *common_labels + metrics: + - name: bpm + desc: Bpm + type: gauge + unit: bpm + labels: *common_labels + - name: source + desc: An enumeration.("awake" "rest" "sleep" "session" "live" "workout") + type: info + unit: null + labels: *common_labels +- name: personal_info + prefix: oura_personal_info_ + labels: *common_labels + metrics: + - name: age + desc: Age + type: gauge + unit: age + labels: *common_labels + - name: weight + desc: Weight + type: gauge + unit: kilograms + labels: *common_labels + - name: height + desc: Height + type: gauge + unit: meters + labels: *common_labels + - name: biological_sex + desc: Biological Sex + type: info + labels: *common_labels + - name: email + desc: Email + type: info + labels: *common_labels diff --git a/example/oura.prom b/example/oura.prom new file mode 100644 index 0000000..fb04de0 --- /dev/null +++ b/example/oura.prom @@ -0,0 +1,159 @@ +# HELP oura_daily_activity_score Activity score in range [1, 100] +# TYPE oura_daily_activity_score gauge +oura_daily_activity_score{email="DUMMY@icloud.com"} 73.0 +# HELP oura_daily_activity_active_calories Active calories expended (in kilocalories) +# TYPE oura_daily_activity_active_calories gauge +oura_daily_activity_active_calories{email="DUMMY@icloud.com"} 115.0 +# HELP oura_daily_activity_average_met_minutes Average metabolic equivalent (MET) in minutes +# TYPE oura_daily_activity_average_met_minutes gauge +oura_daily_activity_average_met_minutes{email="DUMMY@icloud.com"} 1.3125 +# HELP oura_daily_activity_contributors_meet_daily_targets Contribution of meeting previous 7-day daily activity targets in range [1, 100]. +# TYPE oura_daily_activity_contributors_meet_daily_targets gauge +oura_daily_activity_contributors_meet_daily_targets{email="DUMMY@icloud.com"} 43.0 +# HELP oura_daily_activity_contributors_move_every_hour Contribution of previous 24-hour inactivity alerts in range [1, 100]. +# TYPE oura_daily_activity_contributors_move_every_hour gauge +oura_daily_activity_contributors_move_every_hour{email="DUMMY@icloud.com"} 78.0 +# HELP oura_daily_activity_contributors_recovery_time Contribution of previous 7-day recovery time in range [1, 100]. +# TYPE oura_daily_activity_contributors_recovery_time gauge +oura_daily_activity_contributors_recovery_time{email="DUMMY@icloud.com"} 100.0 +# HELP oura_daily_activity_contributors_stay_active Contribution of previous 24-hour activity in range [1, 100]. +# TYPE oura_daily_activity_contributors_stay_active gauge +oura_daily_activity_contributors_stay_active{email="DUMMY@icloud.com"} 40.0 +# HELP oura_daily_activity_contributors_training_frequency Contribution of previous 7-day exercise frequency in range [1, 100]. +# TYPE oura_daily_activity_contributors_training_frequency gauge +oura_daily_activity_contributors_training_frequency{email="DUMMY@icloud.com"} 96.0 +# HELP oura_daily_activity_contributors_training_volume Contribution of previous 7-day exercise volume in range [1, 100]. +# TYPE oura_daily_activity_contributors_training_volume gauge +oura_daily_activity_contributors_training_volume{email="DUMMY@icloud.com"} 95.0 +# HELP oura_daily_activity_equivalent_walking_distance Equivalent walking distance (in meters) of energy expenditure +# TYPE oura_daily_activity_equivalent_walking_distance gauge +oura_daily_activity_equivalent_walking_distance{email="DUMMY@icloud.com"} 2038.0 +# HELP oura_daily_activity_high_activity_met_minutes High activity metabolic equivalent (MET) in minutes +# TYPE oura_daily_activity_high_activity_met_minutes gauge +oura_daily_activity_high_activity_met_minutes{email="DUMMY@icloud.com"} 0.0 +# HELP oura_daily_activity_high_activity_time High activity metabolic equivalent (MET) in seconds +# TYPE oura_daily_activity_high_activity_time gauge +oura_daily_activity_high_activity_time{email="DUMMY@icloud.com"} 0.0 +# HELP oura_daily_activity_inactivity_alerts Number of inactivity alerts received +# TYPE oura_daily_activity_inactivity_alerts gauge +oura_daily_activity_inactivity_alerts{email="DUMMY@icloud.com"} 2.0 +# HELP oura_daily_activity_low_activity_met_minutes Low activity metabolic equivalent (MET) in minutes +# TYPE oura_daily_activity_low_activity_met_minutes gauge +oura_daily_activity_low_activity_met_minutes{email="DUMMY@icloud.com"} 56.0 +# HELP oura_daily_activity_low_activity_time Low activity metabolic equivalent (MET) in seconds +# TYPE oura_daily_activity_low_activity_time gauge +oura_daily_activity_low_activity_time{email="DUMMY@icloud.com"} 5220.0 +# HELP oura_daily_activity_medium_activity_met_minutes Medium activity metabolic equivalent (MET) in minutes +# TYPE oura_daily_activity_medium_activity_met_minutes gauge +oura_daily_activity_medium_activity_met_minutes{email="DUMMY@icloud.com"} 34.0 +# HELP oura_daily_activity_medium_activity_time Medium activity metabolic equivalent (MET) in seconds +# TYPE oura_daily_activity_medium_activity_time gauge +oura_daily_activity_medium_activity_time{email="DUMMY@icloud.com"} 540.0 +# HELP oura_daily_activity_meters_to_target Remaining meters to target (from target_meters) +# TYPE oura_daily_activity_meters_to_target gauge +oura_daily_activity_meters_to_target{email="DUMMY@icloud.com"} 6800.0 +# HELP oura_daily_activity_non_wear_time The time (in seconds) in which the ring was not worn +# TYPE oura_daily_activity_non_wear_time gauge +oura_daily_activity_non_wear_time{email="DUMMY@icloud.com"} 5340.0 +# HELP oura_daily_activity_resting_time Resting time (in seconds) +# TYPE oura_daily_activity_resting_time gauge +oura_daily_activity_resting_time{email="DUMMY@icloud.com"} 9840.0 +# HELP oura_daily_activity_sedentary_met_minutes Sedentary metabolic equivalent (MET) in minutes +# TYPE oura_daily_activity_sedentary_met_minutes gauge +oura_daily_activity_sedentary_met_minutes{email="DUMMY@icloud.com"} 6.0 +# HELP oura_daily_activity_sedentary_time Sedentary metabolic equivalent (MET) in seconds +# TYPE oura_daily_activity_sedentary_time gauge +oura_daily_activity_sedentary_time{email="DUMMY@icloud.com"} 45180.0 +# HELP oura_daily_activity_steps Total number of steps taken +# TYPE oura_daily_activity_steps gauge +oura_daily_activity_steps{email="DUMMY@icloud.com"} 2757.0 +# HELP oura_daily_activity_target_calories Daily activity target (in kilocalories) +# TYPE oura_daily_activity_target_calories gauge +oura_daily_activity_target_calories{email="DUMMY@icloud.com"} 450.0 +# HELP oura_daily_activity_target_meters Daily activity target (in meters) +# TYPE oura_daily_activity_target_meters gauge +oura_daily_activity_target_meters{email="DUMMY@icloud.com"} 10000.0 +# HELP oura_daily_activity_total_calories Total calories expended (in kilocalories) +# TYPE oura_daily_activity_total_calories gauge +oura_daily_activity_total_calories{email="DUMMY@icloud.com"} 2086.0 +# HELP oura_daily_readiness_contributors_activity_balance Contribution of cumulative activity balance in range [1, 100]. +# TYPE oura_daily_readiness_contributors_activity_balance gauge +oura_daily_readiness_contributors_activity_balance{email="DUMMY@icloud.com"} 81.0 +# HELP oura_daily_readiness_contributors_body_temperature Contribution of body temperature in range [1, 100]. +# TYPE oura_daily_readiness_contributors_body_temperature gauge +oura_daily_readiness_contributors_body_temperature{email="DUMMY@icloud.com"} 100.0 +# HELP oura_daily_readiness_contributors_hrv_balance Contribution of heart rate variability balance in range [1, 100]. +# TYPE oura_daily_readiness_contributors_hrv_balance gauge +oura_daily_readiness_contributors_hrv_balance{email="DUMMY@icloud.com"} 86.0 +# HELP oura_daily_readiness_contributors_previous_day_activity Contribution of previous day's activity in range [1, 100]. +# TYPE oura_daily_readiness_contributors_previous_day_activity gauge +oura_daily_readiness_contributors_previous_day_activity{email="DUMMY@icloud.com"} 74.0 +# HELP oura_daily_readiness_contributors_previous_night Contribution of previous night's sleep in range [1, 100]. +# TYPE oura_daily_readiness_contributors_previous_night gauge +oura_daily_readiness_contributors_previous_night{email="DUMMY@icloud.com"} 77.0 +# HELP oura_daily_readiness_contributors_recovery_index Contribution of recovery index in range [1, 100]. +# TYPE oura_daily_readiness_contributors_recovery_index gauge +oura_daily_readiness_contributors_recovery_index{email="DUMMY@icloud.com"} 92.0 +# HELP oura_daily_readiness_contributors_resting_heart_rate Contribution of resting heart rate in range [1, 100]. +# TYPE oura_daily_readiness_contributors_resting_heart_rate gauge +oura_daily_readiness_contributors_resting_heart_rate{email="DUMMY@icloud.com"} 100.0 +# HELP oura_daily_readiness_contributors_sleep_balance Contribution of sleep balance in range [1, 100]. +# TYPE oura_daily_readiness_contributors_sleep_balance gauge +oura_daily_readiness_contributors_sleep_balance{email="DUMMY@icloud.com"} 74.0 +# HELP oura_daily_readiness_score Daily readiness score. +# TYPE oura_daily_readiness_score gauge +oura_daily_readiness_score{email="DUMMY@icloud.com"} 83.0 +# HELP oura_daily_readiness_temperature_deviation Temperature deviation in degrees Celsius. +# TYPE oura_daily_readiness_temperature_deviation gauge +oura_daily_readiness_temperature_deviation{email="DUMMY@icloud.com"} -0.13 +# HELP oura_daily_readiness_temperature_trend_deviation Temperature trend deviation in degrees Celsius. +# TYPE oura_daily_readiness_temperature_trend_deviation gauge +oura_daily_readiness_temperature_trend_deviation{email="DUMMY@icloud.com"} -0.1 +# HELP oura_daily_sleep_contributors_deep_sleep Contribution of deep sleep in range [1, 100]. +# TYPE oura_daily_sleep_contributors_deep_sleep gauge +oura_daily_sleep_contributors_deep_sleep{email="DUMMY@icloud.com"} 98.0 +# HELP oura_daily_sleep_contributors_efficiency Contribution of sleep efficiency in range [1, 100]. +# TYPE oura_daily_sleep_contributors_efficiency gauge +oura_daily_sleep_contributors_efficiency{email="DUMMY@icloud.com"} 96.0 +# HELP oura_daily_sleep_contributors_latency Contribution of sleep latency in range [1, 100]. +# TYPE oura_daily_sleep_contributors_latency gauge +oura_daily_sleep_contributors_latency{email="DUMMY@icloud.com"} 75.0 +# HELP oura_daily_sleep_contributors_rem_sleep Contribution of REM sleep in range [1, 100]. +# TYPE oura_daily_sleep_contributors_rem_sleep gauge +oura_daily_sleep_contributors_rem_sleep{email="DUMMY@icloud.com"} 64.0 +# HELP oura_daily_sleep_contributors_restfulness Contribution of sleep restfulness in range [1, 100]. +# TYPE oura_daily_sleep_contributors_restfulness gauge +oura_daily_sleep_contributors_restfulness{email="DUMMY@icloud.com"} 80.0 +# HELP oura_daily_sleep_contributors_timing Contribution of sleep timing in range [1, 100]. +# TYPE oura_daily_sleep_contributors_timing gauge +oura_daily_sleep_contributors_timing{email="DUMMY@icloud.com"} 93.0 +# HELP oura_daily_sleep_contributors_total_sleep Contribution of total sleep in range [1, 100]. +# TYPE oura_daily_sleep_contributors_total_sleep gauge +oura_daily_sleep_contributors_total_sleep{email="DUMMY@icloud.com"} 58.0 +# HELP oura_daily_sleep_score Daily sleep score. +# TYPE oura_daily_sleep_score gauge +oura_daily_sleep_score{email="DUMMY@icloud.com"} 75.0 +# HELP oura_daily_spo2_spo2_percentage_average The SpO2 percentage value aggregated over a single day. +# TYPE oura_daily_spo2_spo2_percentage_average gauge +oura_daily_spo2_spo2_percentage_average{email="DUMMY@icloud.com"} 99.510 +# HELP oura_heartrate_bpm Bpm +# TYPE oura_heartrate_bpm gauge +oura_heartrate_bpm{email="DUMMY@icloud.com"} 75.0 +# HELP oura_heartrate_source_info An enumeration.("awake" "rest" "sleep" "session" "live" "workout") +# TYPE oura_heartrate_source_info gauge +oura_heartrate_source_info{email="DUMMY@icloud.com",val="awake"} 1.0 +# HELP oura_personal_info_age Age +# TYPE oura_personal_info_age gauge +oura_personal_info_age{email="DUMMY@icloud.com"} 40.0 +# HELP oura_personal_info_weight Weight +# TYPE oura_personal_info_weight gauge +oura_personal_info_weight{email="DUMMY@icloud.com"} 50.0 +# HELP oura_personal_info_height Height +# TYPE oura_personal_info_height gauge +oura_personal_info_height{email="DUMMY@icloud.com"} 1.8 +# HELP oura_personal_info_biological_sex_info Biological Sex +# TYPE oura_personal_info_biological_sex_info gauge +oura_personal_info_biological_sex_info{email="DUMMY@icloud.com",val="male"} 1.0 +# HELP oura_personal_info_email_info Email +# TYPE oura_personal_info_email_info gauge +oura_personal_info_email_info{email="DUMMY@icloud.com",val="DUMMY@icloud.com"} 1.0 diff --git a/main.py b/main.py new file mode 100644 index 0000000..6947b94 --- /dev/null +++ b/main.py @@ -0,0 +1,87 @@ +import os,time,yaml, logging, sys, datetime, zoneinfo +from modules.oura import Oura +from operator import attrgetter +from prometheus_client import CollectorRegistry, start_http_server +import modules.prometheus as prom + +ORIGIN_TZ = zoneinfo.ZoneInfo(os.environ.get("TZ")) +OURA_ACCESS_TOKEN = os.environ.get("OURA_ACCESS_TOKEN", None) +HTTP_PORT = os.environ.get('PORT', 8000) +LOGLEVEL = os.environ.get('LOGLEVEL', logging.INFO) +CONF_FILE = 'config/metrics.yml' + +if __name__ == "__main__": + + logger = logging.getLogger(__name__) + logging.basicConfig(level=LOGLEVEL, format='%(asctime)s - %(levelname)s : %(message)s', datefmt="%Y-%m-%dT%H:%M:%S%z") + + metrics_definitions = prom.load_oura_metrics_configs(CONF_FILE) + + registry = CollectorRegistry() + start_http_server(int(HTTP_PORT), registry=registry) + + if OURA_ACCESS_TOKEN == None: + logging.fatal("OURA_ACCESS_TOKEN env is not defined. Please set it!") + sys.exit(1) + + oura = Oura(personal_access_token=OURA_ACCESS_TOKEN) + + personal_info = oura.get_personal_info() + + if personal_info == None: + logging.fatal("OURA_ACCESS_TOKEN is not usable. Please check it!") + sys.exit(1) + + labels = [ personal_info.email ] + + root_metrics = {} + + while True: + now = datetime.datetime.now(ORIGIN_TZ) + today = now.date() + onedaybefore = now - datetime.timedelta(days=1) + yesterday = onedaybefore.date() + + for category in metrics_definitions.categories: + logging.debug("gathering {cat} data...".format(cat=category.name)) + + if not category.name in root_metrics: + root_metrics[category.name] = {} + + if category.name == 'daily_activity': + metrics = oura.get_daily_activity(yesterday, today) + elif category.name == 'daily_readiness': + metrics = oura.get_daily_readiness(today, today) + elif category.name == 'daily_sleep': + metrics = oura.get_daily_sleep(today, today) + elif category.name == 'daily_spo2': + metrics = oura.get_daily_spo2(today, today) + elif category.name == 'heartrate': + metrics = oura.get_heartrate(onedaybefore, now) + elif category.name == 'personal_info': + metrics = oura.get_personal_info() + + if metrics == None: + logging.warn("getting {cat} process was failed.".format(cat=category.name)) + continue + elif category.name != 'personal_info' and len(metrics.data) == 0: + logging.warn("{cat} data was not found.".format(cat=category.name)) + continue + + if category.name != 'personal_info': + latest_metrics = metrics.data[-1] + else: + latest_metrics = metrics + + for m in category.metrics: + iterator = m.iterator if m.iterator != None else m.name + extractor = attrgetter(iterator) + value = extractor(latest_metrics) + logging.debug("{prefix}{key}: {value}".format(prefix=category.prefix, key=m.name, value=value)) + if not m.name in root_metrics[category.name]: + root_metrics[category.name][m.name] = prom.create_metric_instance(m, registry, category.prefix) + prom.set_metrics(root_metrics[category.name][m.name], labels, value) + logging.info("gathering {cat} metrics successful.".format(cat=category.name)) + + logging.info("gathering all metrics successful.") + time.sleep(60) diff --git a/modules/oura.py b/modules/oura.py new file mode 100644 index 0000000..fcc27b7 --- /dev/null +++ b/modules/oura.py @@ -0,0 +1,71 @@ +import logging, requests, datetime +from modules.oura_dataclasses import * +from dacite import from_dict, Config + +logger = logging.getLogger(__name__) + +class Oura: + + def __init__(self, personal_access_token:str): + self.url = "https://api.ouraring.com/v2" + self.token = personal_access_token + self.cast_config = Config({ + datetime.datetime: datetime.datetime.fromisoformat, + datetime.date: datetime.date.fromisoformat, + }) + + def __call__(self, hook, **kwargs): + getattr(self, f'get_{hook}')(**kwargs) + + def get_usercollection(self, path:str, **params) -> dict | None: + try: + response = requests.get( + url="{u}/usercollection/{p}".format(u=self.url,p=path), + headers={ + "Authorization": "Bearer {t}".format(t=self.token), + }, + params=params + ) + if response.status_code != 200: + logging.error("{u} return {s}: {t}".format(u=response.url, s=response.status_code, t=response.text)) + return None + return response.json() + except requests.exceptions.RequestException as err: + logging.error("HTTP Request failed: {e}".format(e=err)) + return None + + def get_daily_activity(self, start_date:datetime.date, end_date:datetime.date) -> OuraDailyActivities: + res_dict = self.get_usercollection("daily_activity", start_date=start_date, end_date=end_date) + if res_dict != None: + return from_dict(data_class=OuraDailyActivities, data=res_dict, config=self.cast_config) + return None + + def get_daily_readiness(self, start_date:datetime.date, end_date:datetime.date) -> OuraDailyReadinesses: + res_dict = self.get_usercollection("daily_readiness", start_date=start_date, end_date=end_date) + if res_dict != None: + return from_dict(data_class=OuraDailyReadinesses, data=res_dict, config=self.cast_config) + return None + + def get_daily_sleep(self, start_date:datetime.date, end_date:datetime.date) -> OuraDailySleeps: + res_dict = self.get_usercollection("daily_sleep", start_date=start_date, end_date=end_date) + if res_dict != None: + return from_dict(data_class=OuraDailySleeps, data=res_dict, config=self.cast_config) + return None + + def get_daily_spo2(self, start_date:datetime.date, end_date:datetime.date) -> OuraDailySpo2s: + res_dict = self.get_usercollection("daily_spo2", start_date=start_date, end_date=end_date) + if res_dict != None: + return from_dict(data_class=OuraDailySpo2s, data=res_dict, config=self.cast_config) + return None + + def get_heartrate(self, start_datetime:datetime.datetime, end_datetime:datetime.datetime) -> OuraHeartRates: + res_dict = self.get_usercollection("heartrate", start_datetime=start_datetime, end_datetime=end_datetime) + if res_dict != None: + return from_dict(data_class=OuraHeartRates, data=res_dict, config=self.cast_config) + return None + + def get_personal_info(self) -> OuraPersonalInfo: + res_dict = self.get_usercollection("personal_info") + if res_dict != None: + return from_dict(data_class=OuraPersonalInfo, data=res_dict, config=self.cast_config) + return None diff --git a/modules/oura_dataclasses.py b/modules/oura_dataclasses.py new file mode 100644 index 0000000..4896df6 --- /dev/null +++ b/modules/oura_dataclasses.py @@ -0,0 +1,135 @@ +import datetime +from dataclasses import dataclass + +@dataclass +class OuraDailyActivityContributors: + meet_daily_targets: int + move_every_hour: int + recovery_time: int + stay_active: int + training_frequency: int + training_volume: int + +@dataclass +class OuraDailyActivityMet: + interval: float + items: list[float] + timestamp: datetime.datetime + +@dataclass +class OuraDailyActivity: + id: str + class_5_min: str + score: int + active_calories: int + average_met_minutes: float + contributors: OuraDailyActivityContributors + equivalent_walking_distance: int + high_activity_met_minutes: int + high_activity_time: int + inactivity_alerts: int + low_activity_met_minutes: int + low_activity_time: int + medium_activity_met_minutes: int + medium_activity_time: int + met: OuraDailyActivityMet + meters_to_target: int + non_wear_time: int + resting_time: int + sedentary_met_minutes: int + sedentary_time: int + steps: int + target_calories: int + target_meters: int + total_calories: int + day: datetime.date + timestamp: datetime.datetime + +@dataclass +class OuraDailyActivities: + data: list[OuraDailyActivity] + next_token: str | None + +@dataclass +class OuraDailyReadinessContributors: + activity_balance: int + body_temperature: int + hrv_balance: int | None + previous_day_activity: int + previous_night: int + recovery_index: int + resting_heart_rate: int + sleep_balance: int + +@dataclass +class OuraDailyReadiness: + id: str + contributors: OuraDailyReadinessContributors + day: datetime.date + score: int + temperature_deviation: float + temperature_trend_deviation: float + timestamp: datetime.datetime + +@dataclass +class OuraDailyReadinesses: + data: list[OuraDailyReadiness] + next_token: str | None + +@dataclass +class OuraDailySleepContributors: + deep_sleep: int + efficiency: int + latency: int + rem_sleep: int + restfulness: int + timing: int + total_sleep: int + +@dataclass +class OuraDailySleep: + id: str + contributors: OuraDailySleepContributors + day: datetime.date + score: int + timestamp: datetime.datetime + +@dataclass +class OuraDailySleeps: + data: list[OuraDailySleep] + next_token: str | None + +@dataclass +class OuraDailySpo2Spo2Percentage: + average: float + +@dataclass +class OuraDailySpo2: + id: str + day: datetime.date + spo2_percentage: OuraDailySpo2Spo2Percentage + +@dataclass +class OuraDailySpo2s: + data: list[OuraDailySpo2] + next_token: str | None + +@dataclass +class OuraHeartRate: + bpm: int + source: str + timestamp: datetime.datetime + +@dataclass +class OuraHeartRates: + data: list[OuraHeartRate] + next_token: str | None + +@dataclass +class OuraPersonalInfo: + id: str + age: int + weight: float + height: float + biological_sex: str + email: str diff --git a/modules/prometheus.py b/modules/prometheus.py new file mode 100644 index 0000000..0aa9ee1 --- /dev/null +++ b/modules/prometheus.py @@ -0,0 +1,54 @@ +from prometheus_client import Gauge, Counter, Info, CollectorRegistry +from dataclasses import dataclass +from dacite import from_dict +import yaml + +@dataclass +class OuraMetricsConfig: + name: str + desc: str + type: str + unit: str | None + labels: list[str] + iterator: str | None + +@dataclass +class OuraCategoryConfig: + name: str + prefix: str + labels: list[str] + metrics: list[OuraMetricsConfig] + +@dataclass +class OuraRootConfig: + categories: list[OuraCategoryConfig] + +def load_oura_metrics_configs(file:str): + with open(file, 'r') as f: + metrics_definitions_dict = yaml.load(f, Loader=yaml.FullLoader) + return from_dict(data_class=OuraRootConfig, data=metrics_definitions_dict) + +def create_metric_instance(definition:OuraMetricsConfig, registry:CollectorRegistry, prefix:str): + if definition.type == 'gauge': + m = Gauge( prefix + definition.name, definition.desc, definition.labels, registry=registry ) + elif definition.type == 'counter': + m = Counter( prefix + definition.name, definition.desc, definition.labels, registry=registry ) + elif definition.type == 'summary': + m = Counter( prefix + definition.name, definition.desc, definition.labels, registry=registry ) + elif definition.type == 'info': + m = Info( prefix + definition.name, definition.desc, definition.labels, registry=registry ) + else: + return None + return m + +def set_metrics(m, labels:list, value): + if value == None: + pass + elif m._type == 'gauge': + m.labels(*labels).set(value) + elif m._type == 'info': + m.labels(*labels).info({'val': value}) + elif m._type == 'counter': + m.labels(*labels).inc(value) + else: + pass