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.
Files changed (47) hide show
  1. engie_nl-0.2.1/LICENSE +21 -0
  2. engie_nl-0.2.1/PKG-INFO +170 -0
  3. engie_nl-0.2.1/README.md +140 -0
  4. engie_nl-0.2.1/engie_nl/__init__.py +83 -0
  5. engie_nl-0.2.1/engie_nl/_http.py +84 -0
  6. engie_nl-0.2.1/engie_nl/_parse.py +41 -0
  7. engie_nl-0.2.1/engie_nl/api/__init__.py +49 -0
  8. engie_nl-0.2.1/engie_nl/api/_base.py +29 -0
  9. engie_nl-0.2.1/engie_nl/api/account.py +117 -0
  10. engie_nl-0.2.1/engie_nl/api/address.py +83 -0
  11. engie_nl-0.2.1/engie_nl/api/assets.py +139 -0
  12. engie_nl-0.2.1/engie_nl/api/billing.py +71 -0
  13. engie_nl-0.2.1/engie_nl/api/enode.py +209 -0
  14. engie_nl-0.2.1/engie_nl/api/ev.py +31 -0
  15. engie_nl-0.2.1/engie_nl/api/happy_hour.py +40 -0
  16. engie_nl-0.2.1/engie_nl/api/legacy.py +140 -0
  17. engie_nl-0.2.1/engie_nl/api/mandates.py +34 -0
  18. engie_nl-0.2.1/engie_nl/api/meter.py +73 -0
  19. engie_nl-0.2.1/engie_nl/api/smart_charging.py +69 -0
  20. engie_nl-0.2.1/engie_nl/api/solar.py +74 -0
  21. engie_nl-0.2.1/engie_nl/api/support.py +86 -0
  22. engie_nl-0.2.1/engie_nl/api/tariffs.py +54 -0
  23. engie_nl-0.2.1/engie_nl/auth.py +588 -0
  24. engie_nl-0.2.1/engie_nl/client.py +377 -0
  25. engie_nl-0.2.1/engie_nl/constants.py +108 -0
  26. engie_nl-0.2.1/engie_nl/exceptions.py +138 -0
  27. engie_nl-0.2.1/engie_nl/generated.py +4045 -0
  28. engie_nl-0.2.1/engie_nl/models.py +728 -0
  29. engie_nl-0.2.1/engie_nl/net2grid.py +101 -0
  30. engie_nl-0.2.1/engie_nl/py.typed +0 -0
  31. engie_nl-0.2.1/engie_nl.egg-info/PKG-INFO +170 -0
  32. engie_nl-0.2.1/engie_nl.egg-info/SOURCES.txt +45 -0
  33. engie_nl-0.2.1/engie_nl.egg-info/dependency_links.txt +1 -0
  34. engie_nl-0.2.1/engie_nl.egg-info/requires.txt +8 -0
  35. engie_nl-0.2.1/engie_nl.egg-info/top_level.txt +1 -0
  36. engie_nl-0.2.1/pyproject.toml +63 -0
  37. engie_nl-0.2.1/setup.cfg +4 -0
  38. engie_nl-0.2.1/tests/test_api.py +826 -0
  39. engie_nl-0.2.1/tests/test_auth.py +466 -0
  40. engie_nl-0.2.1/tests/test_client.py +428 -0
  41. engie_nl-0.2.1/tests/test_coverage.py +41 -0
  42. engie_nl-0.2.1/tests/test_exceptions.py +74 -0
  43. engie_nl-0.2.1/tests/test_generated.py +98 -0
  44. engie_nl-0.2.1/tests/test_http.py +77 -0
  45. engie_nl-0.2.1/tests/test_models.py +100 -0
  46. engie_nl-0.2.1/tests/test_net2grid.py +88 -0
  47. 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.
@@ -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.
@@ -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