myconso 0.0.1__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.
- myconso-0.0.1/.gitignore +207 -0
- myconso-0.0.1/PKG-INFO +119 -0
- myconso-0.0.1/README.md +106 -0
- myconso-0.0.1/myconso/__init__.py +0 -0
- myconso-0.0.1/myconso/api.py +248 -0
- myconso-0.0.1/myconso/cli.py +139 -0
- myconso-0.0.1/myconso/middlewares.py +35 -0
- myconso-0.0.1/myconso/utils.py +45 -0
- myconso-0.0.1/pyproject.toml +77 -0
myconso-0.0.1/.gitignore
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
env/
|
|
142
|
+
venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
myconso-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: myconso
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Async Python client to interact with myconso.net API
|
|
5
|
+
Project-URL: homepage, https://github.com/remijouannet/myconso.py
|
|
6
|
+
Project-URL: repository, https://github.com/remijouannet/myconso.py
|
|
7
|
+
Author-email: Rémi Jouannet <remijouannet@gmail.com>
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: aiohttp
|
|
10
|
+
Requires-Dist: aiohttp-retry>=2.9.1
|
|
11
|
+
Requires-Dist: pyjwt[crypto]
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Unofficial async python client for myconso (proxiserve) API
|
|
16
|
+
|
|
17
|
+
Unofficial fully asynchronous API client for the myconso api behind the [Myconso application](https://play.google.com/store/apps/details?id=fr.proxiserve.myconso&hl=fr). To easily retrieve information about your counters (waterhot, watercold, thermal, ...)
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install myconso
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Getting started
|
|
26
|
+
|
|
27
|
+
### Client
|
|
28
|
+
```python
|
|
29
|
+
import asyncio
|
|
30
|
+
import os
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
from pprint import pprint
|
|
33
|
+
|
|
34
|
+
from myconso.api import MyConsoClient
|
|
35
|
+
|
|
36
|
+
MYCONSO_EMAIL = os.getenv("MYCONSO_EMAIL")
|
|
37
|
+
MYCONSO_PASSWORD = os.getenv("MYCONSO_PASSWORD")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
async with MyConsoClient(username=MYCONSO_EMAIL, password=MYCONSO_PASSWORD) as c:
|
|
42
|
+
pprint(await c.get_dashboard())
|
|
43
|
+
pprint(await c.get_housing())
|
|
44
|
+
|
|
45
|
+
ctrs = await c.get_counters()
|
|
46
|
+
pprint(ctrs)
|
|
47
|
+
|
|
48
|
+
pprint(await c.get_meter_info(counter=ctrs[0]["counter"]))
|
|
49
|
+
|
|
50
|
+
pprint(await c.get_consumption(fluidtype="waterHot"))
|
|
51
|
+
|
|
52
|
+
pprint(
|
|
53
|
+
await c.get_consumption(
|
|
54
|
+
fluidtype="waterHot",
|
|
55
|
+
startdate=datetime(2025, 12, 1),
|
|
56
|
+
enddate=datetime(2025, 12, 4),
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
pprint(await c.get_meter(counter=ctrs[0]["counter"]))
|
|
61
|
+
|
|
62
|
+
pprint(
|
|
63
|
+
await c.get_meter(
|
|
64
|
+
counter=ctrs[0]["counter"],
|
|
65
|
+
startdate=datetime(2025, 12, 1),
|
|
66
|
+
enddate=datetime(2025, 12, 4),
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
asyncio.run(main())
|
|
72
|
+
```
|
|
73
|
+
### cli
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
.venv/bin/myconsocli --help
|
|
77
|
+
usage: myconsocli [-h] [--debug] --email EMAIL --password PASSWORD [--auth] [--dashboard] [--counters] [--housing] [--user] [--meter-info METER_INFO]
|
|
78
|
+
[--meter METER] [--consumption CONSUMPTION] [--start-date START_DATE] [--end-date END_DATE]
|
|
79
|
+
|
|
80
|
+
myconso cli
|
|
81
|
+
|
|
82
|
+
options:
|
|
83
|
+
-h, --help show this help message and exit
|
|
84
|
+
--debug enable debug logging
|
|
85
|
+
--email EMAIL email
|
|
86
|
+
--password PASSWORD password
|
|
87
|
+
--auth POST auth/
|
|
88
|
+
--dashboard GET /secured/consumption/{housing}/dashboard
|
|
89
|
+
--counters List counters from dashboard
|
|
90
|
+
--housing GET /secured/housing/{housing}
|
|
91
|
+
--user GET /secured/users/{user}
|
|
92
|
+
--meter-info METER_INFO
|
|
93
|
+
GET /secured/meter/{housing}/{fluidType}/{counter}/info
|
|
94
|
+
--meter METER GET /secured/meter/{housing}/{fluidType}/{counter}
|
|
95
|
+
--consumption CONSUMPTION
|
|
96
|
+
/secured/consumption/{housing}/{fluidtype}/day
|
|
97
|
+
--start-date START_DATE
|
|
98
|
+
start date for consumption and meter
|
|
99
|
+
--end-date END_DATE end date for consumption and meter
|
|
100
|
+
|
|
101
|
+
.venv/bin/myconsocli --email $MYCONSO_EMAIL --password $MYCONSO_PASSWORD --dashboard
|
|
102
|
+
{'currentMonth': {'endDate': '2025-12-13T12:01:00+00:00',
|
|
103
|
+
'startDate': '2025-12-01T17:25:19+00:00',
|
|
104
|
+
'values': [{'counters': ['123456'],
|
|
105
|
+
'fluidType': 'waterHot',
|
|
106
|
+
'maxValue': 1.0,
|
|
107
|
+
'meterType': 'waterHot',
|
|
108
|
+
'minValue': 1.0,
|
|
109
|
+
'unit': 'm3',
|
|
110
|
+
'value': 1.0,
|
|
111
|
+
'weightedValue': None},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.venv/bin/myconsocli --email $MYCONSO_EMAIL --password $MYCONSO_PASSWORD --meter-info 123456789
|
|
115
|
+
{}
|
|
116
|
+
|
|
117
|
+
.venv/bin/myconsocli --email $MYCONSO_EMAIL --password $MYCONSO_PASSWORD --meter 123456789 --start-date 2025-11-01 --end-date 2025-11-05
|
|
118
|
+
{}
|
|
119
|
+
```
|
myconso-0.0.1/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
|
|
2
|
+
# Unofficial async python client for myconso (proxiserve) API
|
|
3
|
+
|
|
4
|
+
Unofficial fully asynchronous API client for the myconso api behind the [Myconso application](https://play.google.com/store/apps/details?id=fr.proxiserve.myconso&hl=fr). To easily retrieve information about your counters (waterhot, watercold, thermal, ...)
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install myconso
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Getting started
|
|
13
|
+
|
|
14
|
+
### Client
|
|
15
|
+
```python
|
|
16
|
+
import asyncio
|
|
17
|
+
import os
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pprint import pprint
|
|
20
|
+
|
|
21
|
+
from myconso.api import MyConsoClient
|
|
22
|
+
|
|
23
|
+
MYCONSO_EMAIL = os.getenv("MYCONSO_EMAIL")
|
|
24
|
+
MYCONSO_PASSWORD = os.getenv("MYCONSO_PASSWORD")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def main():
|
|
28
|
+
async with MyConsoClient(username=MYCONSO_EMAIL, password=MYCONSO_PASSWORD) as c:
|
|
29
|
+
pprint(await c.get_dashboard())
|
|
30
|
+
pprint(await c.get_housing())
|
|
31
|
+
|
|
32
|
+
ctrs = await c.get_counters()
|
|
33
|
+
pprint(ctrs)
|
|
34
|
+
|
|
35
|
+
pprint(await c.get_meter_info(counter=ctrs[0]["counter"]))
|
|
36
|
+
|
|
37
|
+
pprint(await c.get_consumption(fluidtype="waterHot"))
|
|
38
|
+
|
|
39
|
+
pprint(
|
|
40
|
+
await c.get_consumption(
|
|
41
|
+
fluidtype="waterHot",
|
|
42
|
+
startdate=datetime(2025, 12, 1),
|
|
43
|
+
enddate=datetime(2025, 12, 4),
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
pprint(await c.get_meter(counter=ctrs[0]["counter"]))
|
|
48
|
+
|
|
49
|
+
pprint(
|
|
50
|
+
await c.get_meter(
|
|
51
|
+
counter=ctrs[0]["counter"],
|
|
52
|
+
startdate=datetime(2025, 12, 1),
|
|
53
|
+
enddate=datetime(2025, 12, 4),
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
### cli
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
.venv/bin/myconsocli --help
|
|
64
|
+
usage: myconsocli [-h] [--debug] --email EMAIL --password PASSWORD [--auth] [--dashboard] [--counters] [--housing] [--user] [--meter-info METER_INFO]
|
|
65
|
+
[--meter METER] [--consumption CONSUMPTION] [--start-date START_DATE] [--end-date END_DATE]
|
|
66
|
+
|
|
67
|
+
myconso cli
|
|
68
|
+
|
|
69
|
+
options:
|
|
70
|
+
-h, --help show this help message and exit
|
|
71
|
+
--debug enable debug logging
|
|
72
|
+
--email EMAIL email
|
|
73
|
+
--password PASSWORD password
|
|
74
|
+
--auth POST auth/
|
|
75
|
+
--dashboard GET /secured/consumption/{housing}/dashboard
|
|
76
|
+
--counters List counters from dashboard
|
|
77
|
+
--housing GET /secured/housing/{housing}
|
|
78
|
+
--user GET /secured/users/{user}
|
|
79
|
+
--meter-info METER_INFO
|
|
80
|
+
GET /secured/meter/{housing}/{fluidType}/{counter}/info
|
|
81
|
+
--meter METER GET /secured/meter/{housing}/{fluidType}/{counter}
|
|
82
|
+
--consumption CONSUMPTION
|
|
83
|
+
/secured/consumption/{housing}/{fluidtype}/day
|
|
84
|
+
--start-date START_DATE
|
|
85
|
+
start date for consumption and meter
|
|
86
|
+
--end-date END_DATE end date for consumption and meter
|
|
87
|
+
|
|
88
|
+
.venv/bin/myconsocli --email $MYCONSO_EMAIL --password $MYCONSO_PASSWORD --dashboard
|
|
89
|
+
{'currentMonth': {'endDate': '2025-12-13T12:01:00+00:00',
|
|
90
|
+
'startDate': '2025-12-01T17:25:19+00:00',
|
|
91
|
+
'values': [{'counters': ['123456'],
|
|
92
|
+
'fluidType': 'waterHot',
|
|
93
|
+
'maxValue': 1.0,
|
|
94
|
+
'meterType': 'waterHot',
|
|
95
|
+
'minValue': 1.0,
|
|
96
|
+
'unit': 'm3',
|
|
97
|
+
'value': 1.0,
|
|
98
|
+
'weightedValue': None},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.venv/bin/myconsocli --email $MYCONSO_EMAIL --password $MYCONSO_PASSWORD --meter-info 123456789
|
|
102
|
+
{}
|
|
103
|
+
|
|
104
|
+
.venv/bin/myconsocli --email $MYCONSO_EMAIL --password $MYCONSO_PASSWORD --meter 123456789 --start-date 2025-11-01 --end-date 2025-11-05
|
|
105
|
+
{}
|
|
106
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
|
|
7
|
+
from aiohttp import ClientHandlerType, ClientRequest, ClientResponse, ClientSession
|
|
8
|
+
|
|
9
|
+
from myconso.middlewares import exponential_backoff_middleware
|
|
10
|
+
from myconso.utils import (
|
|
11
|
+
clean_json_ld,
|
|
12
|
+
decode_jwt,
|
|
13
|
+
first_day_of_the_month,
|
|
14
|
+
last_day_of_the_month,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
MYCONSO_API = "https://api.myconso.net"
|
|
20
|
+
MYCONSO_USER_AGENT = "MyConso"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_auth(func):
|
|
24
|
+
async def wrapper(self, *args, **kwargs):
|
|
25
|
+
if not self.token and (self.username and self.password):
|
|
26
|
+
# class has been initialized with username/password
|
|
27
|
+
async with self.lock:
|
|
28
|
+
await self.auth()
|
|
29
|
+
elif not self._housing and self.token and self.refresh_token:
|
|
30
|
+
# class has been initialized with token/refresh_token
|
|
31
|
+
async with self.lock:
|
|
32
|
+
await self.auth_refresh()
|
|
33
|
+
return await func(self, *args, **kwargs)
|
|
34
|
+
|
|
35
|
+
return wrapper
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MyConsoClient:
|
|
39
|
+
username: str | None
|
|
40
|
+
password: str | None
|
|
41
|
+
token: str | None
|
|
42
|
+
refresh_token: str | None
|
|
43
|
+
_counters: list[dict]
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
username: str | None = None,
|
|
48
|
+
password: str | None = None,
|
|
49
|
+
token: str | None = None,
|
|
50
|
+
refresh_token: str | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
if token and refresh_token:
|
|
53
|
+
self.token = token
|
|
54
|
+
self.token_exp, self.token_iat = decode_jwt(self.token)
|
|
55
|
+
self.refresh_token = refresh_token
|
|
56
|
+
elif username and password:
|
|
57
|
+
self.token = None
|
|
58
|
+
self.refresh_token = None
|
|
59
|
+
self.username = username
|
|
60
|
+
self.password = password
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"You must either provide username/password or token/refresh_token"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self._housing = None
|
|
67
|
+
self._counters = []
|
|
68
|
+
self.lock = asyncio.Lock()
|
|
69
|
+
self.session = ClientSession(
|
|
70
|
+
base_url=MYCONSO_API,
|
|
71
|
+
headers={"user-agent": MYCONSO_USER_AGENT},
|
|
72
|
+
raise_for_status=True,
|
|
73
|
+
middlewares=(
|
|
74
|
+
exponential_backoff_middleware,
|
|
75
|
+
self._auth_refresh_middleware,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def __aenter__(self) -> "MyConsoClient":
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
async def __aexit__(
|
|
83
|
+
self,
|
|
84
|
+
exc_type: type[BaseException] | None,
|
|
85
|
+
exc_value: BaseException | None,
|
|
86
|
+
traceback: TracebackType | None,
|
|
87
|
+
) -> None:
|
|
88
|
+
await self.close()
|
|
89
|
+
|
|
90
|
+
async def close(self) -> None:
|
|
91
|
+
await self.session.close()
|
|
92
|
+
|
|
93
|
+
async def _auth_refresh_middleware(
|
|
94
|
+
self, req: ClientRequest, handler: ClientHandlerType
|
|
95
|
+
) -> ClientResponse:
|
|
96
|
+
epoch_now = time.time()
|
|
97
|
+
if epoch_now > self.token_exp:
|
|
98
|
+
log.debug(
|
|
99
|
+
"token is expired, refresh it, exp: %s, time: %s",
|
|
100
|
+
self.token_exp,
|
|
101
|
+
epoch_now,
|
|
102
|
+
)
|
|
103
|
+
async with self.lock:
|
|
104
|
+
await self.auth_refresh()
|
|
105
|
+
|
|
106
|
+
for _ in range(2):
|
|
107
|
+
res = await handler(req)
|
|
108
|
+
if res.status in {401}:
|
|
109
|
+
log.debug("received %s, try to refresh the bearer token", res.status)
|
|
110
|
+
async with self.lock:
|
|
111
|
+
await self.auth_refresh()
|
|
112
|
+
else:
|
|
113
|
+
return res
|
|
114
|
+
|
|
115
|
+
return res
|
|
116
|
+
|
|
117
|
+
async def auth(self) -> dict:
|
|
118
|
+
async with self.session.post(
|
|
119
|
+
"/auth",
|
|
120
|
+
json={
|
|
121
|
+
"email": self.username,
|
|
122
|
+
"password": self.password,
|
|
123
|
+
},
|
|
124
|
+
middlewares=(),
|
|
125
|
+
) as res:
|
|
126
|
+
res = await res.json()
|
|
127
|
+
self._user = res["user"]["email"]
|
|
128
|
+
self._housing = res["housing"]
|
|
129
|
+
self.token = res["token"]
|
|
130
|
+
self.refresh_token = res["refresh_token"]
|
|
131
|
+
self.token_exp, self.token_iat = decode_jwt(self.token)
|
|
132
|
+
self.session.headers.update({"authorization": f"Bearer {self.token}"})
|
|
133
|
+
|
|
134
|
+
log.debug("successful authentification for housing: %s", self._housing)
|
|
135
|
+
|
|
136
|
+
return res
|
|
137
|
+
|
|
138
|
+
async def auth_refresh(self) -> dict:
|
|
139
|
+
async with self.session.post(
|
|
140
|
+
"/auth/refresh",
|
|
141
|
+
json={
|
|
142
|
+
"refresh_token": self.refresh_token,
|
|
143
|
+
},
|
|
144
|
+
middlewares=(),
|
|
145
|
+
) as res:
|
|
146
|
+
res = await res.json()
|
|
147
|
+
self._user = res["user"]["email"]
|
|
148
|
+
self._housing = res["housing"]
|
|
149
|
+
self.token = res["token"]
|
|
150
|
+
self.refresh_token = res["refresh_token"]
|
|
151
|
+
self.token_exp, self.token_iat = decode_jwt(self.token)
|
|
152
|
+
self.session.headers["authorization"] = f"Bearer {self.token}"
|
|
153
|
+
|
|
154
|
+
return res
|
|
155
|
+
|
|
156
|
+
@check_auth
|
|
157
|
+
async def get_user(self) -> dict:
|
|
158
|
+
async with self.session.get(f"/secured/users/{self._user}") as res:
|
|
159
|
+
return clean_json_ld(await res.json())
|
|
160
|
+
|
|
161
|
+
@check_auth
|
|
162
|
+
async def get_housing(self) -> dict:
|
|
163
|
+
async with self.session.get(f"/secured/housing/{self._housing}") as res:
|
|
164
|
+
return clean_json_ld(await res.json())
|
|
165
|
+
|
|
166
|
+
@check_auth
|
|
167
|
+
async def get_dashboard(self) -> dict:
|
|
168
|
+
async with self.session.get(
|
|
169
|
+
f"/secured/consumption/{self._housing}/dashboard"
|
|
170
|
+
) as res:
|
|
171
|
+
return clean_json_ld(await res.json())
|
|
172
|
+
|
|
173
|
+
@check_auth
|
|
174
|
+
async def get_counters(self) -> list[dict]:
|
|
175
|
+
if not self._counters:
|
|
176
|
+
res = await self.get_dashboard()
|
|
177
|
+
for v in res["currentMonth"]["values"]:
|
|
178
|
+
for counter in v["counters"]:
|
|
179
|
+
self._counters.append(
|
|
180
|
+
{
|
|
181
|
+
"counter": counter,
|
|
182
|
+
"fluidType": v["fluidType"],
|
|
183
|
+
"meterType": v["meterType"],
|
|
184
|
+
"unit": v["unit"],
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
return self._counters
|
|
188
|
+
|
|
189
|
+
@check_auth
|
|
190
|
+
async def get_consumption(
|
|
191
|
+
self,
|
|
192
|
+
fluidtype: str,
|
|
193
|
+
startdate: datetime | None = None,
|
|
194
|
+
enddate: datetime | None = None,
|
|
195
|
+
) -> dict | None:
|
|
196
|
+
if not startdate:
|
|
197
|
+
startdate = first_day_of_the_month()
|
|
198
|
+
if not enddate:
|
|
199
|
+
enddate = last_day_of_the_month()
|
|
200
|
+
|
|
201
|
+
async with self.session.get(
|
|
202
|
+
f"/secured/consumption/{self._housing}/{fluidtype}/day",
|
|
203
|
+
params={
|
|
204
|
+
"startDate": startdate.isoformat(timespec="milliseconds"),
|
|
205
|
+
"endDate": enddate.isoformat(timespec="milliseconds"),
|
|
206
|
+
},
|
|
207
|
+
) as res:
|
|
208
|
+
return clean_json_ld(await res.json())
|
|
209
|
+
|
|
210
|
+
@check_auth
|
|
211
|
+
async def get_meter_info(self, counter: str) -> dict | None:
|
|
212
|
+
if not self._counters:
|
|
213
|
+
await self.get_counters()
|
|
214
|
+
|
|
215
|
+
for c in self._counters:
|
|
216
|
+
if c["counter"] == counter:
|
|
217
|
+
async with self.session.get(
|
|
218
|
+
f"/secured/meter/{self._housing}/{c['fluidType']}/{c['counter']}/info",
|
|
219
|
+
) as res:
|
|
220
|
+
return clean_json_ld(await res.json())
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
@check_auth
|
|
224
|
+
async def get_meter(
|
|
225
|
+
self,
|
|
226
|
+
counter: str,
|
|
227
|
+
startdate: datetime | None = None,
|
|
228
|
+
enddate: datetime | None = None,
|
|
229
|
+
) -> dict | None:
|
|
230
|
+
if not startdate:
|
|
231
|
+
startdate = first_day_of_the_month()
|
|
232
|
+
if not enddate:
|
|
233
|
+
enddate = last_day_of_the_month()
|
|
234
|
+
|
|
235
|
+
if not self._counters:
|
|
236
|
+
await self.get_counters()
|
|
237
|
+
|
|
238
|
+
for c in self._counters:
|
|
239
|
+
if c["counter"] == counter:
|
|
240
|
+
async with self.session.get(
|
|
241
|
+
f"/secured/meter/{self._housing}/{c['fluidType']}/{c['counter']}",
|
|
242
|
+
params={
|
|
243
|
+
"startDate": startdate.isoformat(timespec="milliseconds"),
|
|
244
|
+
"endDate": enddate.isoformat(timespec="milliseconds"),
|
|
245
|
+
},
|
|
246
|
+
) as res:
|
|
247
|
+
return clean_json_ld(await res.json())
|
|
248
|
+
return None
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import datetime
|
|
4
|
+
import logging
|
|
5
|
+
from pprint import pprint
|
|
6
|
+
|
|
7
|
+
from myconso.api import MyConsoClient
|
|
8
|
+
|
|
9
|
+
logging.basicConfig(level=logging.INFO)
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def cli() -> None:
|
|
14
|
+
parser = argparse.ArgumentParser(description="myconso cli")
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--debug",
|
|
17
|
+
dest="debug",
|
|
18
|
+
default=False,
|
|
19
|
+
action="store_true",
|
|
20
|
+
help="enable debug logging",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--email",
|
|
24
|
+
dest="email",
|
|
25
|
+
type=str,
|
|
26
|
+
required=True,
|
|
27
|
+
help="email",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--password",
|
|
31
|
+
dest="password",
|
|
32
|
+
type=str,
|
|
33
|
+
required=True,
|
|
34
|
+
help="password",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--auth",
|
|
38
|
+
dest="auth",
|
|
39
|
+
default=False,
|
|
40
|
+
action="store_true",
|
|
41
|
+
help="POST auth/",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--dashboard",
|
|
45
|
+
dest="dashboard",
|
|
46
|
+
default=False,
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="GET /secured/consumption/{housing}/dashboard",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--counters",
|
|
52
|
+
dest="counters",
|
|
53
|
+
default=False,
|
|
54
|
+
action="store_true",
|
|
55
|
+
help="List counters from dashboard",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--housing",
|
|
59
|
+
dest="housing",
|
|
60
|
+
default=False,
|
|
61
|
+
action="store_true",
|
|
62
|
+
help="GET /secured/housing/{housing}",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--user",
|
|
66
|
+
dest="user",
|
|
67
|
+
default=False,
|
|
68
|
+
action="store_true",
|
|
69
|
+
help="GET /secured/users/{user}",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--meter-info",
|
|
73
|
+
dest="meter_info",
|
|
74
|
+
default=None,
|
|
75
|
+
type=str,
|
|
76
|
+
help="GET /secured/meter/{housing}/{fluidType}/{counter}/info",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--meter",
|
|
80
|
+
dest="meter",
|
|
81
|
+
default=None,
|
|
82
|
+
type=str,
|
|
83
|
+
help="GET /secured/meter/{housing}/{fluidType}/{counter}",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--consumption",
|
|
87
|
+
dest="consumption",
|
|
88
|
+
default=None,
|
|
89
|
+
type=str,
|
|
90
|
+
help="/secured/consumption/{housing}/{fluidtype}/day",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--start-date",
|
|
94
|
+
dest="start_date",
|
|
95
|
+
default=None,
|
|
96
|
+
type=lambda s: datetime.datetime.strptime(s, "%Y-%m-%d"),
|
|
97
|
+
help="start date for consumption and meter",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--end-date",
|
|
101
|
+
dest="end_date",
|
|
102
|
+
default=None,
|
|
103
|
+
type=lambda s: datetime.datetime.strptime(s, "%Y-%m-%d"),
|
|
104
|
+
help="end date for consumption and meter",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
args = parser.parse_args()
|
|
108
|
+
|
|
109
|
+
if args.debug:
|
|
110
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
111
|
+
log.debug('debug enabled')
|
|
112
|
+
|
|
113
|
+
async with MyConsoClient(username=args.email, password=args.password) as myconso:
|
|
114
|
+
if args.auth:
|
|
115
|
+
pprint(await myconso.auth())
|
|
116
|
+
elif args.dashboard:
|
|
117
|
+
pprint(await myconso.get_dashboard())
|
|
118
|
+
elif args.counters:
|
|
119
|
+
pprint(await myconso.get_counters())
|
|
120
|
+
elif args.housing:
|
|
121
|
+
pprint(await myconso.get_housing())
|
|
122
|
+
elif args.user:
|
|
123
|
+
pprint(await myconso.get_user())
|
|
124
|
+
elif args.meter_info:
|
|
125
|
+
pprint(await myconso.get_meter_info(args.meter_info))
|
|
126
|
+
elif args.meter:
|
|
127
|
+
pprint(await myconso.get_meter(args.meter, args.start_date, args.end_date))
|
|
128
|
+
elif args.consumption:
|
|
129
|
+
pprint(
|
|
130
|
+
await myconso.get_consumption(
|
|
131
|
+
args.consumption, args.start_date, args.end_date
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
pprint(await myconso.get_dashboard())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def main() -> None:
|
|
139
|
+
asyncio.run(cli())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
from aiohttp import ClientHandlerType, ClientRequest, ClientResponse
|
|
6
|
+
|
|
7
|
+
log = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
BACKOFF_STATUS_CODES = {429, 503}
|
|
10
|
+
BACKOFF_MAX_RETRIES = 2
|
|
11
|
+
BACKOFF_FACTOR = 2.0
|
|
12
|
+
BACKOFF_MAX_DELAY = 60.0
|
|
13
|
+
BACKOFF_JITTER = 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def exponential_backoff_middleware(
|
|
17
|
+
req: ClientRequest, handler: ClientHandlerType
|
|
18
|
+
) -> ClientResponse:
|
|
19
|
+
retry_count = 1
|
|
20
|
+
|
|
21
|
+
res = await handler(req)
|
|
22
|
+
while retry_count <= BACKOFF_MAX_RETRIES:
|
|
23
|
+
if retry_count < BACKOFF_MAX_RETRIES and res.status in BACKOFF_STATUS_CODES:
|
|
24
|
+
delay = round(
|
|
25
|
+
min(BACKOFF_FACTOR * (2**retry_count), BACKOFF_MAX_DELAY)
|
|
26
|
+
+ random.uniform(0, BACKOFF_JITTER),
|
|
27
|
+
3,
|
|
28
|
+
)
|
|
29
|
+
log.debug("retry backoff for %s, sleep for %ss", res.status, str(delay))
|
|
30
|
+
await asyncio.sleep(delay)
|
|
31
|
+
retry_count += 1
|
|
32
|
+
res = await handler(req)
|
|
33
|
+
else:
|
|
34
|
+
break
|
|
35
|
+
return res
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import calendar
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
import jwt
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def clean_json_ld(obj: dict) -> dict:
|
|
8
|
+
# json-ld add keys that starts with @
|
|
9
|
+
# we don't need thoses, pop them
|
|
10
|
+
if isinstance(obj, dict):
|
|
11
|
+
keys_to_pop = [key for key in obj if key.startswith("@")]
|
|
12
|
+
for key in keys_to_pop:
|
|
13
|
+
obj.pop(key, None)
|
|
14
|
+
return obj
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def decode_jwt(token: str) -> tuple[int, int]:
|
|
18
|
+
token_jwt = jwt.decode(
|
|
19
|
+
token,
|
|
20
|
+
algorithms=["RS256"],
|
|
21
|
+
key="",
|
|
22
|
+
options={"verify_signature": False},
|
|
23
|
+
)
|
|
24
|
+
return (token_jwt["exp"], token_jwt["iat"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def last_day_of_the_month() -> datetime:
|
|
28
|
+
# last day of the current month
|
|
29
|
+
return datetime.now(timezone.utc).replace(
|
|
30
|
+
day=calendar.monthrange(
|
|
31
|
+
datetime.now(timezone.utc).year,
|
|
32
|
+
datetime.now(timezone.utc).month,
|
|
33
|
+
)[1],
|
|
34
|
+
hour=23,
|
|
35
|
+
minute=59,
|
|
36
|
+
second=59,
|
|
37
|
+
microsecond=0,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def first_day_of_the_month() -> datetime:
|
|
42
|
+
# first day of the current month
|
|
43
|
+
return datetime.now(timezone.utc).replace(
|
|
44
|
+
day=1, hour=0, minute=0, second=0, microsecond=0
|
|
45
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "myconso"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Async Python client to interact with myconso.net API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{name = "Rémi Jouannet", email = "remijouannet@gmail.com"},
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
packages = [
|
|
11
|
+
{ include = "myconso" }
|
|
12
|
+
]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"aiohttp",
|
|
15
|
+
"aiohttp-retry>=2.9.1",
|
|
16
|
+
"pyjwt[crypto]",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
homepage = "https://github.com/remijouannet/myconso.py"
|
|
21
|
+
repository = "https://github.com/remijouannet/myconso.py"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
myconsocli = "myconso.cli:main"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"ruff",
|
|
29
|
+
"mypy",
|
|
30
|
+
"pre-commit>=4.2.0",
|
|
31
|
+
"pytest>=9.0.1",
|
|
32
|
+
"pytest-asyncio>=1.3.0",
|
|
33
|
+
"pytest-aiohttp>=1.1.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
include = [
|
|
42
|
+
"myconso/*",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["myconso"]
|
|
47
|
+
only-packages = true
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 88
|
|
51
|
+
include = ["tests/*.py", "myconso/*.py"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = [
|
|
55
|
+
# flake8-async
|
|
56
|
+
"ASYNC",
|
|
57
|
+
# pycodestyle
|
|
58
|
+
"E",
|
|
59
|
+
# Pyflakes
|
|
60
|
+
"F",
|
|
61
|
+
# pyupgrade
|
|
62
|
+
"UP",
|
|
63
|
+
# flake8-bugbear
|
|
64
|
+
"B",
|
|
65
|
+
# flake8-simplify
|
|
66
|
+
"SIM",
|
|
67
|
+
# isort
|
|
68
|
+
"I",
|
|
69
|
+
# ruff
|
|
70
|
+
"RUF",
|
|
71
|
+
# flake8-quotes
|
|
72
|
+
"Q"
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
[tool.mypy]
|
|
76
|
+
exclude = "tests/"
|
|
77
|
+
files = "myconso/*.py"
|