engie-nl 0.2.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.
- engie_nl-0.2.1/LICENSE +21 -0
- engie_nl-0.2.1/PKG-INFO +170 -0
- engie_nl-0.2.1/README.md +140 -0
- engie_nl-0.2.1/engie_nl/__init__.py +83 -0
- engie_nl-0.2.1/engie_nl/_http.py +84 -0
- engie_nl-0.2.1/engie_nl/_parse.py +41 -0
- engie_nl-0.2.1/engie_nl/api/__init__.py +49 -0
- engie_nl-0.2.1/engie_nl/api/_base.py +29 -0
- engie_nl-0.2.1/engie_nl/api/account.py +117 -0
- engie_nl-0.2.1/engie_nl/api/address.py +83 -0
- engie_nl-0.2.1/engie_nl/api/assets.py +139 -0
- engie_nl-0.2.1/engie_nl/api/billing.py +71 -0
- engie_nl-0.2.1/engie_nl/api/enode.py +209 -0
- engie_nl-0.2.1/engie_nl/api/ev.py +31 -0
- engie_nl-0.2.1/engie_nl/api/happy_hour.py +40 -0
- engie_nl-0.2.1/engie_nl/api/legacy.py +140 -0
- engie_nl-0.2.1/engie_nl/api/mandates.py +34 -0
- engie_nl-0.2.1/engie_nl/api/meter.py +73 -0
- engie_nl-0.2.1/engie_nl/api/smart_charging.py +69 -0
- engie_nl-0.2.1/engie_nl/api/solar.py +74 -0
- engie_nl-0.2.1/engie_nl/api/support.py +86 -0
- engie_nl-0.2.1/engie_nl/api/tariffs.py +54 -0
- engie_nl-0.2.1/engie_nl/auth.py +588 -0
- engie_nl-0.2.1/engie_nl/client.py +377 -0
- engie_nl-0.2.1/engie_nl/constants.py +108 -0
- engie_nl-0.2.1/engie_nl/exceptions.py +138 -0
- engie_nl-0.2.1/engie_nl/generated.py +4045 -0
- engie_nl-0.2.1/engie_nl/models.py +728 -0
- engie_nl-0.2.1/engie_nl/net2grid.py +101 -0
- engie_nl-0.2.1/engie_nl/py.typed +0 -0
- engie_nl-0.2.1/engie_nl.egg-info/PKG-INFO +170 -0
- engie_nl-0.2.1/engie_nl.egg-info/SOURCES.txt +45 -0
- engie_nl-0.2.1/engie_nl.egg-info/dependency_links.txt +1 -0
- engie_nl-0.2.1/engie_nl.egg-info/requires.txt +8 -0
- engie_nl-0.2.1/engie_nl.egg-info/top_level.txt +1 -0
- engie_nl-0.2.1/pyproject.toml +63 -0
- engie_nl-0.2.1/setup.cfg +4 -0
- engie_nl-0.2.1/tests/test_api.py +826 -0
- engie_nl-0.2.1/tests/test_auth.py +466 -0
- engie_nl-0.2.1/tests/test_client.py +428 -0
- engie_nl-0.2.1/tests/test_coverage.py +41 -0
- engie_nl-0.2.1/tests/test_exceptions.py +74 -0
- engie_nl-0.2.1/tests/test_generated.py +98 -0
- engie_nl-0.2.1/tests/test_http.py +77 -0
- engie_nl-0.2.1/tests/test_models.py +100 -0
- engie_nl-0.2.1/tests/test_net2grid.py +88 -0
- engie_nl-0.2.1/tests/test_parse.py +41 -0
engie_nl-0.2.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AboveColin
|
|
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.
|
engie_nl-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: engie-nl
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Async Python client for the private API behind the ENGIE Energie NL app
|
|
5
|
+
Author: AboveColin
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AboveColin/engie-nl
|
|
8
|
+
Project-URL: Issues, https://github.com/AboveColin/engie-nl/issues
|
|
9
|
+
Keywords: engie,energy,netherlands,home-assistant,smart-meter
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Home Automation
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: aiohttp>=3.9
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
27
|
+
Requires-Dist: pylint>=3; extra == "dev"
|
|
28
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# engie-nl
|
|
32
|
+
|
|
33
|
+
Async Python client for the private API behind the **ENGIE Energie NL** app
|
|
34
|
+
(`nl.engie.engieapp`). It covers the whole thing: all 148 endpoints the app
|
|
35
|
+
declares, with 199 typed models.
|
|
36
|
+
|
|
37
|
+
The reads a household actually polls are on the client itself: the customer
|
|
38
|
+
record and EANs, daily consumption per meter, meter readings, the termijnbedrag
|
|
39
|
+
advice, invoices, the smart-meter data mandate, outages, and day-ahead prices.
|
|
40
|
+
The rest is grouped by area, `client.tariffs`, `client.assets`, `client.enode`
|
|
41
|
+
and so on, listed below.
|
|
42
|
+
|
|
43
|
+
ENGIE does not document or support this API. It can change without notice.
|
|
44
|
+
The map it is built from, with the receipts, lives in
|
|
45
|
+
`apk-reverse-engineering/docs/engie-nl/ENGIE_NL_API.md`.
|
|
46
|
+
|
|
47
|
+
## How the login works
|
|
48
|
+
|
|
49
|
+
The app logs in through Okta (`login.engie.nl`) and sends the Okta access token
|
|
50
|
+
straight to the gateway as `Authorization: Bearer`. ENGIE's org runs Okta
|
|
51
|
+
Identity Engine, so this package walks the interaction code flow: interact,
|
|
52
|
+
introspect, identify, then the password.
|
|
53
|
+
|
|
54
|
+
**ENGIE then emails a one-time code, and it is not optional.** `login()` asks
|
|
55
|
+
Okta to send the mail and raises `EngieEmailCodeRequired`; hand the code to
|
|
56
|
+
`submit_email_code()` to finish. Only the token pair is kept, never the
|
|
57
|
+
password. The scope includes `offline_access`, so this happens once: the
|
|
58
|
+
refresh token carries every later session without touching the mailbox.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import asyncio
|
|
62
|
+
from engie_nl import EngieClient, EngieEmailCodeRequired, OktaAuth
|
|
63
|
+
|
|
64
|
+
async def main():
|
|
65
|
+
async with OktaAuth() as auth:
|
|
66
|
+
try:
|
|
67
|
+
tokens = await auth.login("you@example.com", "password")
|
|
68
|
+
except EngieEmailCodeRequired as err:
|
|
69
|
+
code = input("code from the email: ")
|
|
70
|
+
tokens = await auth.submit_email_code(err.challenge, code)
|
|
71
|
+
|
|
72
|
+
async with EngieClient(tokens, auth=auth) as client:
|
|
73
|
+
user = await client.get_user()
|
|
74
|
+
for series in await client.get_consumptions(user.eans, days=7):
|
|
75
|
+
for day in series.data:
|
|
76
|
+
print(series.ean, day.day, day.total, "kWh/m3")
|
|
77
|
+
|
|
78
|
+
asyncio.run(main())
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Persist `tokens.to_dict()` and rebuild with `TokenSet.from_dict()`; losing the
|
|
82
|
+
refresh token means another trip to the mailbox.
|
|
83
|
+
|
|
84
|
+
Pass `on_tokens_updated=` to be told when the pair is refreshed, and persist
|
|
85
|
+
`TokenSet.to_dict()`. Home Assistant passes its own `aiohttp` session via
|
|
86
|
+
`session=`.
|
|
87
|
+
|
|
88
|
+
## The whole surface
|
|
89
|
+
|
|
90
|
+
| Group | What it covers |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `client.get_*` | user, consumptions, meterstands, estimations, transactions, documents, mandates, outages, day-ahead prices, MER periods, opening hours |
|
|
93
|
+
| `client.tariffs` | the contract's own rates, `GET /api/v1/tariffs` |
|
|
94
|
+
| `client.meter` | filing and withdrawing meter readings, the P4 feed, dongle activation |
|
|
95
|
+
| `client.billing` | invoice payment status, documents, the MER report, the termijnbedrag, iDEAL |
|
|
96
|
+
| `client.account` | profile, settings cards, areas of interest, passwords, payment details |
|
|
97
|
+
| `client.mandates` | granting and withdrawing the smart-meter mandate |
|
|
98
|
+
| `client.assets` | declared solar panels, heat pumps, batteries, cars, chargers, aircos |
|
|
99
|
+
| `client.enode` | linked vehicles and chargers, locations, charge policies, sessions |
|
|
100
|
+
| `client.smart_charging` | ENGIE's own smart-charging programme |
|
|
101
|
+
| `client.happy_hour` | announced Happy Hours, subscriptions, payouts |
|
|
102
|
+
| `client.solar` | solar potential, quotes, home and energy scans |
|
|
103
|
+
| `client.address` | postcode lookup, iDIN verification, moving the contract |
|
|
104
|
+
| `client.support` | opening hours, waiting time, advice articles, feedback, chat |
|
|
105
|
+
| `client.ev` | charge card and charging station lead forms |
|
|
106
|
+
| `client.legacy` | pre-Okta authentication and account creation |
|
|
107
|
+
| `Net2GridClient` | the P1 dongle, on Net2Grid's own host |
|
|
108
|
+
|
|
109
|
+
`tools/check_coverage.py` compares the package against the APK map and is run by
|
|
110
|
+
the test suite, so "all 148" stays true rather than being a claim in a README.
|
|
111
|
+
|
|
112
|
+
## Writes are off unless you ask
|
|
113
|
+
|
|
114
|
+
Every method that changes the account raises `EngieWriteBlocked` on a normal
|
|
115
|
+
client. The endpoints behind them are not test fixtures: `POST
|
|
116
|
+
/api/v1/meterstands` files a meter reading with the supplier who bills you, `PUT
|
|
117
|
+
/api/v1/prepayment` changes a direct debit, and `POST /api/v1/contract/move`
|
|
118
|
+
moves the contract to another address.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
async with EngieClient(tokens, auth=auth, allow_writes=True) as client:
|
|
122
|
+
await client.billing.set_prepayment(user.eans, amount=195)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Two POSTs are queries despite the verb, `/api/v1/readings` and
|
|
126
|
+
`/api/v1/p4-errors`, and need no permission: both send a body to read P4 data
|
|
127
|
+
back.
|
|
128
|
+
|
|
129
|
+
## Scripts
|
|
130
|
+
|
|
131
|
+
- `scripts/login.py`: interactive login. Prompts for the password, then for the
|
|
132
|
+
code ENGIE emails, and stores the token pair at
|
|
133
|
+
`~/.config/engie-nl/tokens.json` (mode 0600). `--check` runs the whole login
|
|
134
|
+
and saves nothing; `--browser` uses the browser flow instead.
|
|
135
|
+
- `scripts/probe.py`: reads every supported endpoint once and writes the raw
|
|
136
|
+
responses to `~/.config/engie-nl/captures/` (mode 0600, override with
|
|
137
|
+
`ENGIE_CAPTURES`) for inspection. They land outside the repo because a real
|
|
138
|
+
`/user` answer carries the account holder's name, address, bank account and
|
|
139
|
+
both meter EANs.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
uv venv .venv && uv pip install -e '.[dev]'
|
|
145
|
+
.venv/bin/pytest
|
|
146
|
+
.venv/bin/pytest --cov # line and branch coverage, gated at 100%
|
|
147
|
+
.venv/bin/pylint engie_nl
|
|
148
|
+
.venv/bin/mypy engie_nl
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Tests run the client against a loopback aiohttp server that plays both Okta
|
|
152
|
+
and the gateway; no network, no mocks of aiohttp internals.
|
|
153
|
+
|
|
154
|
+
`tests/test_coverage.py` checks the package against the APK's `api-map.json`,
|
|
155
|
+
which lives in a separate repo that is not published with this one. Point
|
|
156
|
+
`ENGIE_NL_API_MAP` at that file, or check the repo out beside this one, and the
|
|
157
|
+
two endpoint tests run. Without it they skip and name the path they looked for,
|
|
158
|
+
so a skip is visible rather than a silent pass:
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
export ENGIE_NL_API_MAP=/path/to/apk-reverse-engineering/docs/engie-nl/api-map.json
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`--cov` needs no other flags: the source list, branch coverage and the 100%
|
|
165
|
+
gate are in `pyproject.toml`, so the number is the same wherever it is run.
|
|
166
|
+
Every value in the tests is invented. Nothing in `tests/` came from a real
|
|
167
|
+
account; `scripts/probe.py` writes real responses to
|
|
168
|
+
`~/.config/engie-nl/captures/`, outside the working tree, and no test reads
|
|
169
|
+
them. Keeping them out of the repo means no gitignore rule stands between a
|
|
170
|
+
real bank account number and a public push.
|
engie_nl-0.2.1/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# engie-nl
|
|
2
|
+
|
|
3
|
+
Async Python client for the private API behind the **ENGIE Energie NL** app
|
|
4
|
+
(`nl.engie.engieapp`). It covers the whole thing: all 148 endpoints the app
|
|
5
|
+
declares, with 199 typed models.
|
|
6
|
+
|
|
7
|
+
The reads a household actually polls are on the client itself: the customer
|
|
8
|
+
record and EANs, daily consumption per meter, meter readings, the termijnbedrag
|
|
9
|
+
advice, invoices, the smart-meter data mandate, outages, and day-ahead prices.
|
|
10
|
+
The rest is grouped by area, `client.tariffs`, `client.assets`, `client.enode`
|
|
11
|
+
and so on, listed below.
|
|
12
|
+
|
|
13
|
+
ENGIE does not document or support this API. It can change without notice.
|
|
14
|
+
The map it is built from, with the receipts, lives in
|
|
15
|
+
`apk-reverse-engineering/docs/engie-nl/ENGIE_NL_API.md`.
|
|
16
|
+
|
|
17
|
+
## How the login works
|
|
18
|
+
|
|
19
|
+
The app logs in through Okta (`login.engie.nl`) and sends the Okta access token
|
|
20
|
+
straight to the gateway as `Authorization: Bearer`. ENGIE's org runs Okta
|
|
21
|
+
Identity Engine, so this package walks the interaction code flow: interact,
|
|
22
|
+
introspect, identify, then the password.
|
|
23
|
+
|
|
24
|
+
**ENGIE then emails a one-time code, and it is not optional.** `login()` asks
|
|
25
|
+
Okta to send the mail and raises `EngieEmailCodeRequired`; hand the code to
|
|
26
|
+
`submit_email_code()` to finish. Only the token pair is kept, never the
|
|
27
|
+
password. The scope includes `offline_access`, so this happens once: the
|
|
28
|
+
refresh token carries every later session without touching the mailbox.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
from engie_nl import EngieClient, EngieEmailCodeRequired, OktaAuth
|
|
33
|
+
|
|
34
|
+
async def main():
|
|
35
|
+
async with OktaAuth() as auth:
|
|
36
|
+
try:
|
|
37
|
+
tokens = await auth.login("you@example.com", "password")
|
|
38
|
+
except EngieEmailCodeRequired as err:
|
|
39
|
+
code = input("code from the email: ")
|
|
40
|
+
tokens = await auth.submit_email_code(err.challenge, code)
|
|
41
|
+
|
|
42
|
+
async with EngieClient(tokens, auth=auth) as client:
|
|
43
|
+
user = await client.get_user()
|
|
44
|
+
for series in await client.get_consumptions(user.eans, days=7):
|
|
45
|
+
for day in series.data:
|
|
46
|
+
print(series.ean, day.day, day.total, "kWh/m3")
|
|
47
|
+
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Persist `tokens.to_dict()` and rebuild with `TokenSet.from_dict()`; losing the
|
|
52
|
+
refresh token means another trip to the mailbox.
|
|
53
|
+
|
|
54
|
+
Pass `on_tokens_updated=` to be told when the pair is refreshed, and persist
|
|
55
|
+
`TokenSet.to_dict()`. Home Assistant passes its own `aiohttp` session via
|
|
56
|
+
`session=`.
|
|
57
|
+
|
|
58
|
+
## The whole surface
|
|
59
|
+
|
|
60
|
+
| Group | What it covers |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `client.get_*` | user, consumptions, meterstands, estimations, transactions, documents, mandates, outages, day-ahead prices, MER periods, opening hours |
|
|
63
|
+
| `client.tariffs` | the contract's own rates, `GET /api/v1/tariffs` |
|
|
64
|
+
| `client.meter` | filing and withdrawing meter readings, the P4 feed, dongle activation |
|
|
65
|
+
| `client.billing` | invoice payment status, documents, the MER report, the termijnbedrag, iDEAL |
|
|
66
|
+
| `client.account` | profile, settings cards, areas of interest, passwords, payment details |
|
|
67
|
+
| `client.mandates` | granting and withdrawing the smart-meter mandate |
|
|
68
|
+
| `client.assets` | declared solar panels, heat pumps, batteries, cars, chargers, aircos |
|
|
69
|
+
| `client.enode` | linked vehicles and chargers, locations, charge policies, sessions |
|
|
70
|
+
| `client.smart_charging` | ENGIE's own smart-charging programme |
|
|
71
|
+
| `client.happy_hour` | announced Happy Hours, subscriptions, payouts |
|
|
72
|
+
| `client.solar` | solar potential, quotes, home and energy scans |
|
|
73
|
+
| `client.address` | postcode lookup, iDIN verification, moving the contract |
|
|
74
|
+
| `client.support` | opening hours, waiting time, advice articles, feedback, chat |
|
|
75
|
+
| `client.ev` | charge card and charging station lead forms |
|
|
76
|
+
| `client.legacy` | pre-Okta authentication and account creation |
|
|
77
|
+
| `Net2GridClient` | the P1 dongle, on Net2Grid's own host |
|
|
78
|
+
|
|
79
|
+
`tools/check_coverage.py` compares the package against the APK map and is run by
|
|
80
|
+
the test suite, so "all 148" stays true rather than being a claim in a README.
|
|
81
|
+
|
|
82
|
+
## Writes are off unless you ask
|
|
83
|
+
|
|
84
|
+
Every method that changes the account raises `EngieWriteBlocked` on a normal
|
|
85
|
+
client. The endpoints behind them are not test fixtures: `POST
|
|
86
|
+
/api/v1/meterstands` files a meter reading with the supplier who bills you, `PUT
|
|
87
|
+
/api/v1/prepayment` changes a direct debit, and `POST /api/v1/contract/move`
|
|
88
|
+
moves the contract to another address.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
async with EngieClient(tokens, auth=auth, allow_writes=True) as client:
|
|
92
|
+
await client.billing.set_prepayment(user.eans, amount=195)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Two POSTs are queries despite the verb, `/api/v1/readings` and
|
|
96
|
+
`/api/v1/p4-errors`, and need no permission: both send a body to read P4 data
|
|
97
|
+
back.
|
|
98
|
+
|
|
99
|
+
## Scripts
|
|
100
|
+
|
|
101
|
+
- `scripts/login.py`: interactive login. Prompts for the password, then for the
|
|
102
|
+
code ENGIE emails, and stores the token pair at
|
|
103
|
+
`~/.config/engie-nl/tokens.json` (mode 0600). `--check` runs the whole login
|
|
104
|
+
and saves nothing; `--browser` uses the browser flow instead.
|
|
105
|
+
- `scripts/probe.py`: reads every supported endpoint once and writes the raw
|
|
106
|
+
responses to `~/.config/engie-nl/captures/` (mode 0600, override with
|
|
107
|
+
`ENGIE_CAPTURES`) for inspection. They land outside the repo because a real
|
|
108
|
+
`/user` answer carries the account holder's name, address, bank account and
|
|
109
|
+
both meter EANs.
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
uv venv .venv && uv pip install -e '.[dev]'
|
|
115
|
+
.venv/bin/pytest
|
|
116
|
+
.venv/bin/pytest --cov # line and branch coverage, gated at 100%
|
|
117
|
+
.venv/bin/pylint engie_nl
|
|
118
|
+
.venv/bin/mypy engie_nl
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Tests run the client against a loopback aiohttp server that plays both Okta
|
|
122
|
+
and the gateway; no network, no mocks of aiohttp internals.
|
|
123
|
+
|
|
124
|
+
`tests/test_coverage.py` checks the package against the APK's `api-map.json`,
|
|
125
|
+
which lives in a separate repo that is not published with this one. Point
|
|
126
|
+
`ENGIE_NL_API_MAP` at that file, or check the repo out beside this one, and the
|
|
127
|
+
two endpoint tests run. Without it they skip and name the path they looked for,
|
|
128
|
+
so a skip is visible rather than a silent pass:
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
export ENGIE_NL_API_MAP=/path/to/apk-reverse-engineering/docs/engie-nl/api-map.json
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`--cov` needs no other flags: the source list, branch coverage and the 100%
|
|
135
|
+
gate are in `pyproject.toml`, so the number is the same wherever it is run.
|
|
136
|
+
Every value in the tests is invented. Nothing in `tests/` came from a real
|
|
137
|
+
account; `scripts/probe.py` writes real responses to
|
|
138
|
+
`~/.config/engie-nl/captures/`, outside the working tree, and no test reads
|
|
139
|
+
them. Keeping them out of the repo means no gitignore rule stands between a
|
|
140
|
+
real bank account number and a public push.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""engie-nl: async client for the private API behind the ENGIE Energie NL app."""
|
|
2
|
+
|
|
3
|
+
from .auth import BrowserLogin, EmailChallenge, OktaAuth, TokenSet, make_pkce_pair
|
|
4
|
+
from .client import EngieClient
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
EngieApiError,
|
|
7
|
+
EngieAuthError,
|
|
8
|
+
EngieEmailCodeRequired,
|
|
9
|
+
EngieError,
|
|
10
|
+
EngieMfaRequiredError,
|
|
11
|
+
EngieNetworkError,
|
|
12
|
+
EngieRateLimited,
|
|
13
|
+
EngieWriteBlocked,
|
|
14
|
+
)
|
|
15
|
+
from .net2grid import Net2GridClient, P1Client
|
|
16
|
+
from .models import (
|
|
17
|
+
Consumption,
|
|
18
|
+
ConsumptionSeries,
|
|
19
|
+
DayAheadPrice,
|
|
20
|
+
DeliveryAddress,
|
|
21
|
+
DocumentRef,
|
|
22
|
+
EnergyType,
|
|
23
|
+
EstimationCosts,
|
|
24
|
+
Mandate,
|
|
25
|
+
MerPeriod,
|
|
26
|
+
MeteringPoint,
|
|
27
|
+
MeterReadings,
|
|
28
|
+
OutageMessage,
|
|
29
|
+
ProductInfo,
|
|
30
|
+
Reading,
|
|
31
|
+
Register,
|
|
32
|
+
Tariffs,
|
|
33
|
+
Transaction,
|
|
34
|
+
TransactionStatus,
|
|
35
|
+
User,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# The 180 dataclasses generated from the APK's api-map.json are not re-exported
|
|
39
|
+
# one by one: import them from engie_nl.generated. The 19 below are the curated
|
|
40
|
+
# ones, which carry behaviour the generator cannot infer.
|
|
41
|
+
from . import generated # noqa: E402 pylint: disable=wrong-import-position
|
|
42
|
+
|
|
43
|
+
__version__ = "0.2.1"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"__version__",
|
|
47
|
+
"generated",
|
|
48
|
+
"BrowserLogin",
|
|
49
|
+
"EmailChallenge",
|
|
50
|
+
"OktaAuth",
|
|
51
|
+
"TokenSet",
|
|
52
|
+
"make_pkce_pair",
|
|
53
|
+
"EngieClient",
|
|
54
|
+
"EngieApiError",
|
|
55
|
+
"EngieAuthError",
|
|
56
|
+
"EngieEmailCodeRequired",
|
|
57
|
+
"EngieError",
|
|
58
|
+
"EngieMfaRequiredError",
|
|
59
|
+
"EngieNetworkError",
|
|
60
|
+
"EngieRateLimited",
|
|
61
|
+
"EngieWriteBlocked",
|
|
62
|
+
"Net2GridClient",
|
|
63
|
+
"P1Client",
|
|
64
|
+
"Consumption",
|
|
65
|
+
"ConsumptionSeries",
|
|
66
|
+
"DayAheadPrice",
|
|
67
|
+
"DeliveryAddress",
|
|
68
|
+
"DocumentRef",
|
|
69
|
+
"EnergyType",
|
|
70
|
+
"EstimationCosts",
|
|
71
|
+
"Mandate",
|
|
72
|
+
"MerPeriod",
|
|
73
|
+
"MeteringPoint",
|
|
74
|
+
"MeterReadings",
|
|
75
|
+
"OutageMessage",
|
|
76
|
+
"ProductInfo",
|
|
77
|
+
"Reading",
|
|
78
|
+
"Register",
|
|
79
|
+
"Tariffs",
|
|
80
|
+
"Transaction",
|
|
81
|
+
"TransactionStatus",
|
|
82
|
+
"User",
|
|
83
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Shared aiohttp session handling for the auth and gateway clients.
|
|
2
|
+
|
|
3
|
+
Home Assistant passes its own session; scripts pass none and get one that is
|
|
4
|
+
closed with the owner. Both classes need that same rule, so it lives once.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
|
|
14
|
+
from .exceptions import EngieNetworkError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SessionOwner:
|
|
18
|
+
"""Holds an aiohttp session, creating one lazily and closing only what it created."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, session: aiohttp.ClientSession | None, timeout: float) -> None:
|
|
21
|
+
self._session: aiohttp.ClientSession | None = session
|
|
22
|
+
self._owns_session: bool = session is None
|
|
23
|
+
self._timeout: aiohttp.ClientTimeout = aiohttp.ClientTimeout(total=timeout)
|
|
24
|
+
|
|
25
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
26
|
+
if self._session is None or self._session.closed:
|
|
27
|
+
self._session = aiohttp.ClientSession(timeout=self._timeout)
|
|
28
|
+
self._owns_session = True
|
|
29
|
+
return self._session
|
|
30
|
+
|
|
31
|
+
async def close(self) -> None:
|
|
32
|
+
"""Close the session if this object created it. A borrowed session is left alone."""
|
|
33
|
+
if self._owns_session and self._session is not None and not self._session.closed:
|
|
34
|
+
await self._session.close()
|
|
35
|
+
|
|
36
|
+
async def __aenter__(self) -> "SessionOwner":
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
40
|
+
await self.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def json_or_text(resp: aiohttp.ClientResponse) -> Any:
|
|
44
|
+
"""Decode a response as JSON when it is, otherwise return the text; empty body -> ``{}``."""
|
|
45
|
+
text = await resp.text()
|
|
46
|
+
if not text:
|
|
47
|
+
return {}
|
|
48
|
+
try:
|
|
49
|
+
return await resp.json(content_type=None)
|
|
50
|
+
except (aiohttp.ContentTypeError, ValueError):
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def send(
|
|
55
|
+
session: aiohttp.ClientSession,
|
|
56
|
+
verb: str,
|
|
57
|
+
url: str,
|
|
58
|
+
*,
|
|
59
|
+
params: list[tuple[str, str]] | None = None,
|
|
60
|
+
form: list[tuple[str, str]] | None = None,
|
|
61
|
+
json_body: Any = None,
|
|
62
|
+
headers: dict[str, str] | None = None,
|
|
63
|
+
timeout: aiohttp.ClientTimeout | None = None,
|
|
64
|
+
) -> tuple[int, Any]:
|
|
65
|
+
"""One request, returning ``(status, body)``.
|
|
66
|
+
|
|
67
|
+
Both the gateway client and the Net2Grid client need exactly this, down to
|
|
68
|
+
turning every transport failure into :class:`EngieNetworkError` so callers
|
|
69
|
+
can retry on one type. The status is returned rather than raised on, because
|
|
70
|
+
what a 4xx means differs per host.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
async with session.request(
|
|
74
|
+
verb,
|
|
75
|
+
url,
|
|
76
|
+
params=params,
|
|
77
|
+
data=form,
|
|
78
|
+
json=json_body,
|
|
79
|
+
headers=headers,
|
|
80
|
+
timeout=timeout,
|
|
81
|
+
) as resp:
|
|
82
|
+
return resp.status, await json_or_text(resp)
|
|
83
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
|
|
84
|
+
raise EngieNetworkError(f"{verb} {url} failed: {err or type(err).__name__}") from err
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Turning a decoded response into models.
|
|
2
|
+
|
|
3
|
+
Both the client's own reads and the endpoint groups need these, and importing
|
|
4
|
+
one from the other would tie them together in a circle, so they live here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
Params = list[tuple[str, str]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def eans_param(eans: Iterable[str] | str) -> Params:
|
|
18
|
+
"""``eans[]`` is one query key per value; a comma-joined string is rejected."""
|
|
19
|
+
values = [eans] if isinstance(eans, str) else list(eans)
|
|
20
|
+
if not values:
|
|
21
|
+
raise ValueError("at least one EAN is required")
|
|
22
|
+
return [("eans[]", ean) for ean in values]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def as_dicts(data: Any) -> list[dict[str, Any]]:
|
|
26
|
+
"""The list of objects in a response, whether it is bare or wrapped in ``data``."""
|
|
27
|
+
if isinstance(data, list):
|
|
28
|
+
return [d for d in data if isinstance(d, dict)]
|
|
29
|
+
if isinstance(data, dict) and isinstance(data.get("data"), list):
|
|
30
|
+
return [d for d in data["data"] if isinstance(d, dict)]
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_list(data: Any, model: Callable[[dict[str, Any]], T]) -> list[T]:
|
|
35
|
+
"""Every object in a list response, as models. A non-list gives an empty list."""
|
|
36
|
+
return [model(d) for d in as_dicts(data)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_one(data: Any, model: Callable[[dict[str, Any]], T]) -> T | None:
|
|
40
|
+
"""One object response as a model, or None when the body was not an object."""
|
|
41
|
+
return model(data) if isinstance(data, dict) else None
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The rest of the gateway, grouped by what it is for.
|
|
2
|
+
|
|
3
|
+
:class:`~engie_nl.client.EngieClient` carries the reads a household actually
|
|
4
|
+
polls: user, consumption, meter readings, costs, day-ahead prices. Everything
|
|
5
|
+
else the app can reach lives here, one module per area, reachable as
|
|
6
|
+
``client.assets``, ``client.enode`` and so on.
|
|
7
|
+
|
|
8
|
+
Paths are written out at the call site rather than pulled from constants.py.
|
|
9
|
+
There are 148 of them and each is used once, so a constant would only add a
|
|
10
|
+
name to look up.
|
|
11
|
+
|
|
12
|
+
Every method that changes the account goes through
|
|
13
|
+
:meth:`~engie_nl.client.EngieClient._write`, which refuses unless the client was
|
|
14
|
+
built with ``allow_writes=True``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .account import AccountApi
|
|
20
|
+
from .address import AddressApi
|
|
21
|
+
from .assets import AssetsApi
|
|
22
|
+
from .billing import BillingApi
|
|
23
|
+
from .enode import EnodeApi
|
|
24
|
+
from .ev import EvApi
|
|
25
|
+
from .happy_hour import HappyHourApi
|
|
26
|
+
from .legacy import LegacyApi
|
|
27
|
+
from .mandates import MandatesApi
|
|
28
|
+
from .meter import MeterApi
|
|
29
|
+
from .smart_charging import SmartChargingApi
|
|
30
|
+
from .solar import SolarApi
|
|
31
|
+
from .support import SupportApi
|
|
32
|
+
from .tariffs import TariffsApi
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"AccountApi",
|
|
36
|
+
"AddressApi",
|
|
37
|
+
"AssetsApi",
|
|
38
|
+
"BillingApi",
|
|
39
|
+
"EnodeApi",
|
|
40
|
+
"EvApi",
|
|
41
|
+
"HappyHourApi",
|
|
42
|
+
"LegacyApi",
|
|
43
|
+
"MandatesApi",
|
|
44
|
+
"MeterApi",
|
|
45
|
+
"SmartChargingApi",
|
|
46
|
+
"SolarApi",
|
|
47
|
+
"SupportApi",
|
|
48
|
+
"TariffsApi",
|
|
49
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared plumbing for the endpoint groups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from .._parse import Params, as_dicts, eans_param, parse_list, parse_one
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING: # pragma: no cover - the import is a cycle at runtime
|
|
10
|
+
from ..client import EngieClient
|
|
11
|
+
|
|
12
|
+
__all__ = ["ApiGroup", "Params", "as_dicts", "eans_param", "parse_list", "parse_one"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiGroup: # pylint: disable=too-few-public-methods
|
|
16
|
+
"""One area of the gateway, bound to the client that owns the session."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, client: "EngieClient") -> None:
|
|
19
|
+
self._client = client
|
|
20
|
+
|
|
21
|
+
async def _get(self, path: str, params: Params | None = None) -> Any:
|
|
22
|
+
return await self._client._get(path, params) # pylint: disable=protected-access
|
|
23
|
+
|
|
24
|
+
async def _query(self, verb: str, path: str, **kwargs: Any) -> Any:
|
|
25
|
+
"""A request that reads despite not being a GET, so no write gate."""
|
|
26
|
+
return await self._client._request(verb, path, **kwargs) # pylint: disable=protected-access
|
|
27
|
+
|
|
28
|
+
async def _write(self, verb: str, path: str, **kwargs: Any) -> Any:
|
|
29
|
+
return await self._client._write(verb, path, **kwargs) # pylint: disable=protected-access
|