veolia-api-foxace 2.3.0__py3-none-any.whl
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.
- veolia_api/__init__.py +15 -0
- veolia_api/constants.py +40 -0
- veolia_api/exceptions.py +35 -0
- veolia_api/model.py +64 -0
- veolia_api/portals.py +75 -0
- veolia_api/veolia_api.py +642 -0
- veolia_api_foxace-2.3.0.dist-info/METADATA +128 -0
- veolia_api_foxace-2.3.0.dist-info/RECORD +10 -0
- veolia_api_foxace-2.3.0.dist-info/WHEEL +4 -0
- veolia_api_foxace-2.3.0.dist-info/licenses/LICENSE +21 -0
veolia_api/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""veolia_api package"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .portals import VEOLIA_PORTAL_CLIENTS, VEOLIA_PORTALS, VeoliaPortal
|
|
6
|
+
from .veolia_api import VeoliaAPI
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"VEOLIA_PORTALS",
|
|
10
|
+
"VEOLIA_PORTAL_CLIENTS",
|
|
11
|
+
"VeoliaAPI",
|
|
12
|
+
"VeoliaPortal",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
__version__ = "2.3.0"
|
veolia_api/constants.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Constants for the Veolia API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
from .portals import DEFAULT_BACKEND_URL, DEFAULT_PORTAL_URL, VEOLIA_PORTALS
|
|
9
|
+
|
|
10
|
+
# URLS
|
|
11
|
+
# Cognito authentication endpoint (region eu-west-3).
|
|
12
|
+
LOGIN_URL: Final = "https://cognito-idp.eu-west-3.amazonaws.com"
|
|
13
|
+
# Default data backend (national portal). Kept for backward compatibility;
|
|
14
|
+
# the effective backend is resolved per portal (see portals.py).
|
|
15
|
+
BACKEND_ISTEFR: Final = DEFAULT_BACKEND_URL
|
|
16
|
+
|
|
17
|
+
# AUTH — default Cognito client id (national portal), for backward compatibility.
|
|
18
|
+
LOGIN_CLIENT_ID: Final = VEOLIA_PORTALS[DEFAULT_PORTAL_URL].client_id
|
|
19
|
+
|
|
20
|
+
# API Flow Endpoints
|
|
21
|
+
CALLBACK_ENDPOINT: Final = "/callback"
|
|
22
|
+
|
|
23
|
+
TYPE_FRONT: Final = "WEB_ORDINATEUR"
|
|
24
|
+
|
|
25
|
+
# HTTP Methods
|
|
26
|
+
GET: Final = "GET"
|
|
27
|
+
POST: Final = "POST"
|
|
28
|
+
|
|
29
|
+
# AsyncIO HTTP/Session
|
|
30
|
+
TIMEOUT: Final = 15
|
|
31
|
+
CONCURRENTS_TASKS: Final = 3
|
|
32
|
+
# Re-login this many seconds before the token actually expires.
|
|
33
|
+
TOKEN_EXPIRY_MARGIN: Final = 60
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConsumptionType(Enum):
|
|
37
|
+
"""Consumption type."""
|
|
38
|
+
|
|
39
|
+
MONTHLY = "monthly"
|
|
40
|
+
YEARLY = "yearly"
|
veolia_api/exceptions.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Custom exception classes for Veolia API errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VeoliaAPIError(Exception):
|
|
7
|
+
"""Base exception class for Veolia API errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VeoliaAPIInvalidCredentialsError(VeoliaAPIError):
|
|
11
|
+
"""Exception for missing or rejected credentials."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class VeoliaAPITokenError(VeoliaAPIError):
|
|
15
|
+
"""Exception for access-token retrieval or validation failures."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VeoliaAPIResponseError(VeoliaAPIError):
|
|
19
|
+
"""Exception for unexpected API response payloads."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VeoliaAPIGetDataError(VeoliaAPIError):
|
|
23
|
+
"""Exception for data-fetching failures."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class VeoliaAPISetDataError(VeoliaAPIError):
|
|
27
|
+
"""Exception for data-writing failures."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class VeoliaAPIUnknownError(VeoliaAPIError):
|
|
31
|
+
"""Exception for unknown Veolia API errors."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VeoliaAPIRateLimitError(VeoliaAPIError):
|
|
35
|
+
"""Exception for HTTP 429 Too Many Requests."""
|
veolia_api/model.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Veolia API model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class AlertSettings:
|
|
11
|
+
"""Alert settings.
|
|
12
|
+
|
|
13
|
+
daily_enabled: bool = To enable or disable daily alerts
|
|
14
|
+
daily_threshold: int = Daily threshold in liters (minimum 100)
|
|
15
|
+
daily_notif_email: bool = To enable or disable daily
|
|
16
|
+
alerts by email (Can't be disabled)
|
|
17
|
+
daily_notif_sms: bool = To enable or disable daily alerts by SMS
|
|
18
|
+
monthly_enabled: bool = To enable or disable monthly alerts
|
|
19
|
+
monthly_threshold: int = Monthly threshold in M3 (minimum 1)
|
|
20
|
+
monthly_notif_email: bool = To enable or disable monthly
|
|
21
|
+
alerts by email (Can't be disabled)
|
|
22
|
+
monthly_notif_sms: bool = To enable or disable monthly alerts by SMS.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
daily_enabled: bool
|
|
26
|
+
daily_threshold: int
|
|
27
|
+
daily_notif_email: bool
|
|
28
|
+
daily_notif_sms: bool
|
|
29
|
+
monthly_enabled: bool
|
|
30
|
+
monthly_threshold: int
|
|
31
|
+
monthly_notif_email: bool
|
|
32
|
+
monthly_notif_sms: bool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class VeoliaAccountData:
|
|
37
|
+
"""Data for the Veolia integration."""
|
|
38
|
+
|
|
39
|
+
access_token: str | None = None
|
|
40
|
+
token_expiration: float = 0
|
|
41
|
+
code: str | None = None
|
|
42
|
+
verifier: str | None = None
|
|
43
|
+
id_abonnement: str | None = None
|
|
44
|
+
numero_pds: str | None = None
|
|
45
|
+
contact_id: str | None = None
|
|
46
|
+
tiers_id: str | None = None
|
|
47
|
+
numero_compteur: str | None = None
|
|
48
|
+
date_debut_abonnement: str | None = None
|
|
49
|
+
solde: float | None = None
|
|
50
|
+
dernier_index_releve: float | None = None
|
|
51
|
+
date_index_releve: str | None = None
|
|
52
|
+
mode_releve: str | None = None
|
|
53
|
+
mode_paiement: str | None = None
|
|
54
|
+
numero_client: str | None = None
|
|
55
|
+
titulaire: str | None = None
|
|
56
|
+
marque: str | None = None
|
|
57
|
+
adresse_de_branchement: str | None = None
|
|
58
|
+
emplacement_compteur: str | None = None
|
|
59
|
+
libelle_contrat: str | None = None
|
|
60
|
+
statut: str | None = None
|
|
61
|
+
monthly_consumption: list[dict[str, Any]] | None = None
|
|
62
|
+
daily_consumption: list[dict[str, Any]] | None = None
|
|
63
|
+
alert_settings: AlertSettings | None = None
|
|
64
|
+
billing_plan: dict[str, Any] | None = None
|
veolia_api/portals.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Supported Veolia portals.
|
|
2
|
+
|
|
3
|
+
Veolia operates several water-management portals. They all share the same
|
|
4
|
+
AWS Cognito authentication flow (region ``eu-west-3``) but each portal has its
|
|
5
|
+
own Cognito ``client_id`` and may expose its data on a different backend host.
|
|
6
|
+
|
|
7
|
+
The national portal (``eau.veolia.fr``) and its delegated brandings (e.g. Eau
|
|
8
|
+
de Toulouse Métropole) run on the default backend. Some independent deployments
|
|
9
|
+
(e.g. Eau de Perpignan Méditerranée Métropole, ``www.ea-pm.fr``) run on their
|
|
10
|
+
own backend.
|
|
11
|
+
|
|
12
|
+
To add support for a portal, add an entry to :data:`VEOLIA_PORTALS`:
|
|
13
|
+
|
|
14
|
+
* ``client_id`` — the Cognito app client id, found in the portal's JavaScript
|
|
15
|
+
bundle (``ClientId:"..."``) or in the network traffic when logging in.
|
|
16
|
+
* ``backend_url`` — the ``*.istefr.fr`` host serving the account/consumption
|
|
17
|
+
data; omit it when the portal uses the default backend.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
# Default data backend, used by the national portal and its delegated brandings.
|
|
25
|
+
DEFAULT_BACKEND_URL = "https://prd-ael-sirius-backend.istefr.fr"
|
|
26
|
+
|
|
27
|
+
# Default portal, used when no portal is explicitly selected.
|
|
28
|
+
DEFAULT_PORTAL_URL = "eau.veolia.fr"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class VeoliaPortal:
|
|
33
|
+
"""Configuration of a Veolia portal."""
|
|
34
|
+
|
|
35
|
+
client_id: str
|
|
36
|
+
backend_url: str = DEFAULT_BACKEND_URL
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# hostname (as returned by the commune reference API in ``url_redirection``,
|
|
40
|
+
# or the portal's own hostname) -> portal configuration.
|
|
41
|
+
VEOLIA_PORTALS: dict[str, VeoliaPortal] = {
|
|
42
|
+
"eau.veolia.fr": VeoliaPortal(
|
|
43
|
+
client_id="3kghade1fg54739kj8pkbova8j",
|
|
44
|
+
),
|
|
45
|
+
"eaudetm.monespace.eau.veolia.fr": VeoliaPortal(
|
|
46
|
+
client_id="19bjc8ldefie683n889iiubjc8", # Eau de Toulouse Métropole
|
|
47
|
+
),
|
|
48
|
+
"www.ea-pm.fr": VeoliaPortal(
|
|
49
|
+
client_id="54e8dri103e65defj6p67eolli", # Eau de Perpignan Méditerranée Métropole
|
|
50
|
+
backend_url="https://prd-ael-sirius-pmm-backend.istefr.fr",
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Backward-compatible mapping hostname -> client_id (kept for consumers that
|
|
55
|
+
# only need to test portal support or read the client id).
|
|
56
|
+
VEOLIA_PORTAL_CLIENTS: dict[str, str] = {
|
|
57
|
+
hostname: portal.client_id for hostname, portal in VEOLIA_PORTALS.items()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_portal(portal_url: str | None) -> VeoliaPortal:
|
|
62
|
+
"""Return the configuration for ``portal_url`` (or the default portal).
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: if ``portal_url`` is not a supported portal.
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
hostname = portal_url or DEFAULT_PORTAL_URL
|
|
69
|
+
try:
|
|
70
|
+
return VEOLIA_PORTALS[hostname]
|
|
71
|
+
except KeyError:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Unknown Veolia portal: {hostname!r}. "
|
|
74
|
+
f"Add it to VEOLIA_PORTALS in portals.py",
|
|
75
|
+
) from None
|
veolia_api/veolia_api.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""Veolia API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import itertools
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from datetime import UTC, date, datetime, timedelta
|
|
10
|
+
from http import HTTPStatus
|
|
11
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
12
|
+
from urllib.parse import urlencode
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
from tenacity import (
|
|
16
|
+
retry,
|
|
17
|
+
retry_if_exception_type,
|
|
18
|
+
stop_after_attempt,
|
|
19
|
+
wait_exponential,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .constants import (
|
|
23
|
+
CONCURRENTS_TASKS,
|
|
24
|
+
GET,
|
|
25
|
+
LOGIN_URL,
|
|
26
|
+
POST,
|
|
27
|
+
TIMEOUT,
|
|
28
|
+
TOKEN_EXPIRY_MARGIN,
|
|
29
|
+
TYPE_FRONT,
|
|
30
|
+
ConsumptionType,
|
|
31
|
+
)
|
|
32
|
+
from .exceptions import (
|
|
33
|
+
VeoliaAPIGetDataError,
|
|
34
|
+
VeoliaAPIInvalidCredentialsError,
|
|
35
|
+
VeoliaAPIRateLimitError,
|
|
36
|
+
VeoliaAPIResponseError,
|
|
37
|
+
VeoliaAPISetDataError,
|
|
38
|
+
VeoliaAPITokenError,
|
|
39
|
+
)
|
|
40
|
+
from .model import AlertSettings, VeoliaAccountData
|
|
41
|
+
from .portals import get_portal
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from collections.abc import Coroutine, Iterator
|
|
45
|
+
|
|
46
|
+
_LOGGER = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class VeoliaAPI:
|
|
50
|
+
"""Veolia API client."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
username: str,
|
|
55
|
+
password: str,
|
|
56
|
+
session: aiohttp.ClientSession | None = None,
|
|
57
|
+
portal_url: str | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Initialize the Veolia API client.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
username: Veolia account email.
|
|
63
|
+
password: Veolia account password.
|
|
64
|
+
session: optional shared aiohttp session.
|
|
65
|
+
portal_url: hostname of the Veolia portal to use (see
|
|
66
|
+
``VEOLIA_PORTALS``). Defaults to the national portal.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
self.username = username
|
|
70
|
+
self.password = password
|
|
71
|
+
portal = get_portal(portal_url)
|
|
72
|
+
self._client_id = portal.client_id
|
|
73
|
+
self._backend_url = portal.backend_url
|
|
74
|
+
self.account_data = VeoliaAccountData()
|
|
75
|
+
self._owns_session = session is None
|
|
76
|
+
self.session = session or aiohttp.ClientSession(
|
|
77
|
+
timeout=aiohttp.ClientTimeout(total=TIMEOUT),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
async def close(self) -> None:
|
|
81
|
+
"""Release the HTTP session if this client owns it."""
|
|
82
|
+
if self._owns_session and not self.session.closed:
|
|
83
|
+
await self.session.close()
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _log_request(
|
|
87
|
+
url: str,
|
|
88
|
+
method: str,
|
|
89
|
+
params: dict[str, Any] | None,
|
|
90
|
+
json_data: dict[str, Any] | None,
|
|
91
|
+
headers: dict[str, str],
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Debug-log a request with credentials redacted.
|
|
94
|
+
|
|
95
|
+
The Cognito login payload carries the password in ``AuthParameters``;
|
|
96
|
+
headers carry the bearer token.
|
|
97
|
+
"""
|
|
98
|
+
safe_params = {**params} if params else {}
|
|
99
|
+
if "password" in safe_params:
|
|
100
|
+
safe_params["password"] = "REDACTED"
|
|
101
|
+
|
|
102
|
+
safe_headers = {**headers}
|
|
103
|
+
if "Authorization" in safe_headers:
|
|
104
|
+
safe_headers["Authorization"] = "REDACTED"
|
|
105
|
+
|
|
106
|
+
safe_json = None
|
|
107
|
+
if json_data is not None:
|
|
108
|
+
safe_json = {**json_data}
|
|
109
|
+
auth_params = safe_json.get("AuthParameters")
|
|
110
|
+
if isinstance(auth_params, dict) and "PASSWORD" in auth_params:
|
|
111
|
+
safe_json["AuthParameters"] = {**auth_params, "PASSWORD": "REDACTED"}
|
|
112
|
+
for key in list(safe_json):
|
|
113
|
+
if key.lower() == "password":
|
|
114
|
+
safe_json[key] = "REDACTED"
|
|
115
|
+
|
|
116
|
+
_LOGGER.debug(
|
|
117
|
+
"Making %s request to %s with params: %s, headers: %s, json: %s",
|
|
118
|
+
method,
|
|
119
|
+
url,
|
|
120
|
+
safe_params,
|
|
121
|
+
safe_headers,
|
|
122
|
+
safe_json,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@retry(
|
|
126
|
+
reraise=True,
|
|
127
|
+
stop=stop_after_attempt(5),
|
|
128
|
+
wait=wait_exponential(multiplier=1, min=1, max=16),
|
|
129
|
+
retry=retry_if_exception_type((aiohttp.ClientError, VeoliaAPIRateLimitError)),
|
|
130
|
+
)
|
|
131
|
+
async def _send_request(
|
|
132
|
+
self,
|
|
133
|
+
url: str,
|
|
134
|
+
method: str,
|
|
135
|
+
params: dict[str, Any] | None = None,
|
|
136
|
+
json_data: dict[str, Any] | None = None,
|
|
137
|
+
is_login: bool = False,
|
|
138
|
+
) -> aiohttp.ClientResponse:
|
|
139
|
+
"""Make an HTTP request with support for params, headers and JSON body."""
|
|
140
|
+
req_headers = {
|
|
141
|
+
"User-Agent": (
|
|
142
|
+
"Mozilla/5.0 (X11; Linux x86_64) "
|
|
143
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
144
|
+
"Chrome/140.0.0.0 Safari/537.36"
|
|
145
|
+
),
|
|
146
|
+
"Accept": "*/*",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if self.account_data.access_token:
|
|
150
|
+
req_headers["Authorization"] = f"Bearer {self.account_data.access_token}"
|
|
151
|
+
|
|
152
|
+
if method == POST:
|
|
153
|
+
req_headers["Content-Type"] = "application/json"
|
|
154
|
+
|
|
155
|
+
self._log_request(url, method, params, json_data, req_headers)
|
|
156
|
+
|
|
157
|
+
kwargs: dict[str, Any] = {
|
|
158
|
+
"headers": req_headers,
|
|
159
|
+
"allow_redirects": False,
|
|
160
|
+
# Per-request timeout: the injected shared session (e.g. the Home
|
|
161
|
+
# Assistant one) has no total timeout of its own.
|
|
162
|
+
"timeout": aiohttp.ClientTimeout(total=TIMEOUT),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if params:
|
|
166
|
+
kwargs["params"] = params
|
|
167
|
+
|
|
168
|
+
if json_data is not None:
|
|
169
|
+
req_headers["Content-Type"] = "application/json"
|
|
170
|
+
kwargs["json"] = json_data
|
|
171
|
+
|
|
172
|
+
if is_login:
|
|
173
|
+
req_headers["Content-Type"] = "application/x-amz-json-1.1"
|
|
174
|
+
req_headers["x-amz-target"] = (
|
|
175
|
+
"AWSCognitoIdentityProviderService.InitiateAuth"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
elif method.upper() == POST and params:
|
|
179
|
+
req_headers.update(
|
|
180
|
+
{
|
|
181
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
182
|
+
"Cache-Control": "no-cache",
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
kwargs["data"] = urlencode(params)
|
|
186
|
+
|
|
187
|
+
response = await self.session.request(method.upper(), url, **kwargs)
|
|
188
|
+
_LOGGER.debug("Received response with status code %s", response.status)
|
|
189
|
+
if response.status == HTTPStatus.TOO_MANY_REQUESTS:
|
|
190
|
+
_LOGGER.warning(
|
|
191
|
+
"Rate limit hit (HTTP 429) for %s %s, retrying...",
|
|
192
|
+
method,
|
|
193
|
+
url,
|
|
194
|
+
)
|
|
195
|
+
raise VeoliaAPIRateLimitError("HTTP 429 Too Many Requests")
|
|
196
|
+
|
|
197
|
+
if not is_login and response.status == HTTPStatus.UNAUTHORIZED:
|
|
198
|
+
raise VeoliaAPITokenError("Authentication rejected (HTTP 401)")
|
|
199
|
+
|
|
200
|
+
return response
|
|
201
|
+
|
|
202
|
+
async def login(self) -> bool:
|
|
203
|
+
"""Login to the Veolia API."""
|
|
204
|
+
_LOGGER.info("Logging in...")
|
|
205
|
+
email_regex = r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$"
|
|
206
|
+
|
|
207
|
+
if not self.username or not self.password:
|
|
208
|
+
raise VeoliaAPIInvalidCredentialsError("Missing username or password")
|
|
209
|
+
if not re.match(email_regex, self.username):
|
|
210
|
+
raise VeoliaAPIInvalidCredentialsError("Invalid email format")
|
|
211
|
+
_LOGGER.debug("Starting login process...")
|
|
212
|
+
await self._get_access_token()
|
|
213
|
+
await self._get_client_data()
|
|
214
|
+
|
|
215
|
+
# Check if login was successful
|
|
216
|
+
if (
|
|
217
|
+
self.account_data.access_token
|
|
218
|
+
and self.account_data.id_abonnement
|
|
219
|
+
and self.account_data.numero_pds
|
|
220
|
+
and self.account_data.contact_id
|
|
221
|
+
and self.account_data.tiers_id
|
|
222
|
+
and self.account_data.numero_compteur
|
|
223
|
+
and self.account_data.date_debut_abonnement
|
|
224
|
+
):
|
|
225
|
+
_LOGGER.info("Login successful")
|
|
226
|
+
return True
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
async def _check_token(self) -> None:
|
|
230
|
+
"""Check if the access token is still valid, re-login otherwise."""
|
|
231
|
+
if (
|
|
232
|
+
not self.account_data.access_token
|
|
233
|
+
or datetime.now(UTC).timestamp()
|
|
234
|
+
>= self.account_data.token_expiration - TOKEN_EXPIRY_MARGIN
|
|
235
|
+
):
|
|
236
|
+
_LOGGER.debug("No access token or token expired")
|
|
237
|
+
await self.login()
|
|
238
|
+
|
|
239
|
+
async def _get_access_token(self) -> None:
|
|
240
|
+
"""Request the access token."""
|
|
241
|
+
token_url = f"{LOGIN_URL}"
|
|
242
|
+
_LOGGER.debug("Requesting access token...")
|
|
243
|
+
json_payload = {
|
|
244
|
+
"ClientId": self._client_id,
|
|
245
|
+
"AuthFlow": "USER_PASSWORD_AUTH",
|
|
246
|
+
"AuthParameters": {"USERNAME": self.username, "PASSWORD": self.password},
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
response = await self._send_request(
|
|
250
|
+
url=token_url,
|
|
251
|
+
method=POST,
|
|
252
|
+
json_data=json_payload,
|
|
253
|
+
is_login=True,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
token_data = await response.json(content_type="json")
|
|
257
|
+
|
|
258
|
+
if response.status != HTTPStatus.OK:
|
|
259
|
+
error_type = token_data.get("__type", "")
|
|
260
|
+
if error_type in ("NotAuthorizedException", "UserNotFoundException"):
|
|
261
|
+
raise VeoliaAPIInvalidCredentialsError(
|
|
262
|
+
token_data.get("message", "Invalid credentials"),
|
|
263
|
+
)
|
|
264
|
+
raise VeoliaAPITokenError(
|
|
265
|
+
"Token API call error: " + token_data.get("message", "Unknown error"),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
authentication_result = token_data.get("AuthenticationResult")
|
|
269
|
+
if not authentication_result:
|
|
270
|
+
raise VeoliaAPITokenError("Authentication failed")
|
|
271
|
+
|
|
272
|
+
self.account_data.access_token = authentication_result.get("AccessToken")
|
|
273
|
+
if not self.account_data.access_token:
|
|
274
|
+
raise VeoliaAPITokenError("Access token not found")
|
|
275
|
+
|
|
276
|
+
self.account_data.token_expiration = (
|
|
277
|
+
datetime.now(UTC)
|
|
278
|
+
+ timedelta(seconds=authentication_result.get("ExpiresIn", 0))
|
|
279
|
+
).timestamp()
|
|
280
|
+
_LOGGER.debug("OK - Access token retrieved")
|
|
281
|
+
|
|
282
|
+
async def _get_client_data(self) -> None:
|
|
283
|
+
"""Get the account data."""
|
|
284
|
+
_LOGGER.debug("Fetching user & billing data...")
|
|
285
|
+
url = f"{self._backend_url}/espace-client?type-front={TYPE_FRONT}"
|
|
286
|
+
response = await self._send_request(url=url, method=GET)
|
|
287
|
+
if response.status != HTTPStatus.OK:
|
|
288
|
+
raise VeoliaAPIGetDataError(
|
|
289
|
+
f"call to= espace-client failed with http status= {response.status}",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
userdata = await response.json()
|
|
293
|
+
self.account_data.id_abonnement = (
|
|
294
|
+
userdata.get("contacts", None)[0]
|
|
295
|
+
.get("tiers", None)[0]
|
|
296
|
+
.get("abonnements", None)[0]
|
|
297
|
+
.get("id_abonnement", None)
|
|
298
|
+
)
|
|
299
|
+
self.account_data.tiers_id = (
|
|
300
|
+
userdata.get("contacts", None)[0].get("tiers", None)[0].get("id", None)
|
|
301
|
+
)
|
|
302
|
+
self.account_data.contact_id = userdata.get("contacts", None)[0].get(
|
|
303
|
+
"id_contact",
|
|
304
|
+
None,
|
|
305
|
+
)
|
|
306
|
+
self.account_data.numero_compteur = (
|
|
307
|
+
userdata.get("contacts", None)[0]
|
|
308
|
+
.get("tiers", None)[0]
|
|
309
|
+
.get("abonnements", None)[0]
|
|
310
|
+
.get("numero_compteur", None)
|
|
311
|
+
)
|
|
312
|
+
if (
|
|
313
|
+
not self.account_data.id_abonnement
|
|
314
|
+
or not self.account_data.tiers_id
|
|
315
|
+
or not self.account_data.contact_id
|
|
316
|
+
or not self.account_data.numero_compteur
|
|
317
|
+
):
|
|
318
|
+
raise VeoliaAPIResponseError("Some user data not found in the response")
|
|
319
|
+
|
|
320
|
+
# Contract details (optional)
|
|
321
|
+
abonnement = (
|
|
322
|
+
(userdata.get("contacts") or [{}])[0]
|
|
323
|
+
.get("tiers", [{}])[0]
|
|
324
|
+
.get("abonnements", [{}])[0]
|
|
325
|
+
)
|
|
326
|
+
self.account_data.adresse_de_branchement = abonnement.get(
|
|
327
|
+
"adresse_de_branchement",
|
|
328
|
+
)
|
|
329
|
+
self.account_data.emplacement_compteur = abonnement.get("emplacement_compteur")
|
|
330
|
+
self.account_data.libelle_contrat = abonnement.get("libelle_contrat")
|
|
331
|
+
self.account_data.statut = abonnement.get("statut")
|
|
332
|
+
_LOGGER.debug("OK - Fetch done for user & billing data")
|
|
333
|
+
|
|
334
|
+
# Facturation request
|
|
335
|
+
url_facturation = f"{self._backend_url}/abonnements/{self.account_data.id_abonnement}/facturation"
|
|
336
|
+
response_facturation = await self._send_request(url=url_facturation, method=GET)
|
|
337
|
+
if response_facturation.status != HTTPStatus.OK:
|
|
338
|
+
raise VeoliaAPIGetDataError(
|
|
339
|
+
f"call to= facturation failed with http status= {response_facturation.status}",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
facturation_data = await response_facturation.json()
|
|
343
|
+
self.account_data.numero_pds = facturation_data.get("numero_pds")
|
|
344
|
+
self.account_data.solde = facturation_data.get("solde")
|
|
345
|
+
self.account_data.dernier_index_releve = facturation_data.get(
|
|
346
|
+
"dernier_index_releve",
|
|
347
|
+
)
|
|
348
|
+
self.account_data.date_index_releve = facturation_data.get("date_index_releve")
|
|
349
|
+
self.account_data.mode_releve = facturation_data.get("mode_releve")
|
|
350
|
+
self.account_data.mode_paiement = facturation_data.get("mode_paiement")
|
|
351
|
+
self.account_data.numero_client = facturation_data.get("numero_client")
|
|
352
|
+
self.account_data.titulaire = facturation_data.get("titulaire")
|
|
353
|
+
self.account_data.marque = facturation_data.get("marque")
|
|
354
|
+
if not self.account_data.numero_pds:
|
|
355
|
+
raise VeoliaAPIResponseError("numero_pds not found in the response")
|
|
356
|
+
|
|
357
|
+
self.account_data.date_debut_abonnement = facturation_data.get(
|
|
358
|
+
"date_debut_abonnement",
|
|
359
|
+
)
|
|
360
|
+
if not self.account_data.date_debut_abonnement:
|
|
361
|
+
raise VeoliaAPIResponseError(
|
|
362
|
+
"date_debut_abonnement not found in the response",
|
|
363
|
+
)
|
|
364
|
+
_LOGGER.debug("OK - Billing data received")
|
|
365
|
+
|
|
366
|
+
async def _get_consumption_data(
|
|
367
|
+
self,
|
|
368
|
+
data_type: ConsumptionType,
|
|
369
|
+
year: int,
|
|
370
|
+
month: int | None = None,
|
|
371
|
+
) -> list[dict[str, Any]]:
|
|
372
|
+
"""Get the water consumption data."""
|
|
373
|
+
date_debut_str = self.account_data.date_debut_abonnement
|
|
374
|
+
if not date_debut_str:
|
|
375
|
+
raise VeoliaAPIGetDataError("Subscription start date unknown, login first")
|
|
376
|
+
date_debut = datetime.strptime(date_debut_str, "%Y-%m-%d").replace(tzinfo=UTC)
|
|
377
|
+
if month is not None:
|
|
378
|
+
requested_date = datetime(year, month, 1, tzinfo=UTC)
|
|
379
|
+
else:
|
|
380
|
+
requested_date = datetime(year, 1, 1, tzinfo=UTC)
|
|
381
|
+
|
|
382
|
+
if requested_date < date_debut:
|
|
383
|
+
_LOGGER.warning(
|
|
384
|
+
"Requested data for %s-%s is before subscription start date %s. Request aborted.",
|
|
385
|
+
year,
|
|
386
|
+
month,
|
|
387
|
+
self.account_data.date_debut_abonnement,
|
|
388
|
+
)
|
|
389
|
+
return []
|
|
390
|
+
|
|
391
|
+
params = {
|
|
392
|
+
"annee": year,
|
|
393
|
+
"numero-pds": self.account_data.numero_pds,
|
|
394
|
+
"date-debut-abonnement": self.account_data.date_debut_abonnement,
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if data_type == ConsumptionType.MONTHLY and month is not None:
|
|
398
|
+
params["mois"] = month
|
|
399
|
+
endpoint = "journalieres"
|
|
400
|
+
_LOGGER.debug(
|
|
401
|
+
"Fetching daily consumption data for : %s-%s",
|
|
402
|
+
year,
|
|
403
|
+
month,
|
|
404
|
+
)
|
|
405
|
+
elif data_type == ConsumptionType.YEARLY:
|
|
406
|
+
endpoint = "mensuelles"
|
|
407
|
+
_LOGGER.debug("Fetching monthly consumption data for year : %s", year)
|
|
408
|
+
else:
|
|
409
|
+
raise ValueError("Invalid data type or missing month for monthly data")
|
|
410
|
+
|
|
411
|
+
url = f"{self._backend_url}/consommations/{self.account_data.id_abonnement}/{endpoint}"
|
|
412
|
+
|
|
413
|
+
response = await self._send_request(
|
|
414
|
+
url=url,
|
|
415
|
+
method=GET,
|
|
416
|
+
params=params,
|
|
417
|
+
)
|
|
418
|
+
if response.status != HTTPStatus.OK:
|
|
419
|
+
raise VeoliaAPIGetDataError(
|
|
420
|
+
f"call to= consommations failed with http status= {response.status}",
|
|
421
|
+
)
|
|
422
|
+
_LOGGER.debug("OK - Fetch done for %s-%s", year, month)
|
|
423
|
+
return cast("list[dict[str, Any]]", await response.json())
|
|
424
|
+
|
|
425
|
+
async def get_alerts_settings(self) -> AlertSettings:
|
|
426
|
+
"""Get the consumption alerts.
|
|
427
|
+
|
|
428
|
+
Response example:
|
|
429
|
+
{
|
|
430
|
+
"seuils": {
|
|
431
|
+
"journalier": {
|
|
432
|
+
"valeur": 100,
|
|
433
|
+
"unite": "L",
|
|
434
|
+
"moyen_contact": {
|
|
435
|
+
"souscrit_par_email": true,
|
|
436
|
+
"souscrit_par_mobile": true
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
"mensuel": {
|
|
440
|
+
"valeur": 5,
|
|
441
|
+
"unite": "M3",
|
|
442
|
+
"moyen_contact": {
|
|
443
|
+
"souscrit_par_email": true,
|
|
444
|
+
"souscrit_par_mobile": false
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}.
|
|
449
|
+
"""
|
|
450
|
+
await self._check_token()
|
|
451
|
+
|
|
452
|
+
_LOGGER.debug("Fetching alerts settings...")
|
|
453
|
+
params = {
|
|
454
|
+
"abo_id": self.account_data.id_abonnement,
|
|
455
|
+
}
|
|
456
|
+
url = f"{self._backend_url}/alertes/{self.account_data.numero_pds}"
|
|
457
|
+
response = await self._send_request(
|
|
458
|
+
url=url,
|
|
459
|
+
method=GET,
|
|
460
|
+
params=params,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
if response.status == HTTPStatus.NO_CONTENT:
|
|
464
|
+
_LOGGER.info("No alerts settings found")
|
|
465
|
+
return AlertSettings(
|
|
466
|
+
daily_enabled=False,
|
|
467
|
+
daily_threshold=0,
|
|
468
|
+
daily_notif_email=False,
|
|
469
|
+
daily_notif_sms=False,
|
|
470
|
+
monthly_enabled=False,
|
|
471
|
+
monthly_threshold=0,
|
|
472
|
+
monthly_notif_email=False,
|
|
473
|
+
monthly_notif_sms=False,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
if response.status == HTTPStatus.OK:
|
|
477
|
+
data = await response.json()
|
|
478
|
+
seuils = data.get("seuils", {})
|
|
479
|
+
daily_alert = seuils.get("journalier", None)
|
|
480
|
+
monthly_alert = seuils.get("mensuel", None)
|
|
481
|
+
|
|
482
|
+
_LOGGER.debug("Alerts settings: %s", data)
|
|
483
|
+
_LOGGER.debug("OK - Fetch done for alerts settings")
|
|
484
|
+
|
|
485
|
+
return AlertSettings(
|
|
486
|
+
daily_enabled=bool(daily_alert),
|
|
487
|
+
daily_threshold=daily_alert["valeur"] if daily_alert else 0,
|
|
488
|
+
daily_notif_email=(
|
|
489
|
+
daily_alert["moyen_contact"]["souscrit_par_email"]
|
|
490
|
+
if daily_alert
|
|
491
|
+
else False
|
|
492
|
+
),
|
|
493
|
+
daily_notif_sms=(
|
|
494
|
+
daily_alert["moyen_contact"]["souscrit_par_mobile"]
|
|
495
|
+
if daily_alert
|
|
496
|
+
else False
|
|
497
|
+
),
|
|
498
|
+
monthly_enabled=bool(monthly_alert),
|
|
499
|
+
monthly_threshold=(monthly_alert["valeur"] if monthly_alert else 0),
|
|
500
|
+
monthly_notif_email=(
|
|
501
|
+
monthly_alert["moyen_contact"]["souscrit_par_email"]
|
|
502
|
+
if monthly_alert
|
|
503
|
+
else False
|
|
504
|
+
),
|
|
505
|
+
monthly_notif_sms=(
|
|
506
|
+
monthly_alert["moyen_contact"]["souscrit_par_mobile"]
|
|
507
|
+
if monthly_alert
|
|
508
|
+
else False
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
raise VeoliaAPIGetDataError(
|
|
512
|
+
f"call to= alertes failed with http status= {response.status}",
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
async def _get_mensualisation_plan(self) -> dict[str, Any]:
|
|
516
|
+
"""Get the plan de mensualisation for the given abonnement ID."""
|
|
517
|
+
_LOGGER.debug("Getting mensualisation plan...")
|
|
518
|
+
url = f"{self._backend_url}/abonnements/{self.account_data.id_abonnement}/facturation/mensualisation/plan"
|
|
519
|
+
|
|
520
|
+
response = await self._send_request(url=url, method=GET)
|
|
521
|
+
|
|
522
|
+
if response.status == HTTPStatus.NO_CONTENT:
|
|
523
|
+
_LOGGER.info("No mensualisation plan found")
|
|
524
|
+
return {}
|
|
525
|
+
|
|
526
|
+
if response.status == HTTPStatus.OK:
|
|
527
|
+
_LOGGER.debug("OK - Mensualisation plan received")
|
|
528
|
+
return cast("dict[str, Any]", await response.json())
|
|
529
|
+
|
|
530
|
+
_LOGGER.warning(
|
|
531
|
+
"call to mensualisation/plan failed with HTTP status %s",
|
|
532
|
+
response.status,
|
|
533
|
+
)
|
|
534
|
+
return {}
|
|
535
|
+
|
|
536
|
+
@staticmethod
|
|
537
|
+
def _iter_months(start_date: date, end_date: date) -> Iterator[tuple[int, int]]:
|
|
538
|
+
"""Yield (year, month) pairs covering [start_date, end_date]."""
|
|
539
|
+
y, m = start_date.year, start_date.month
|
|
540
|
+
while (y < end_date.year) or (y == end_date.year and m <= end_date.month):
|
|
541
|
+
yield y, m
|
|
542
|
+
if m == 12: # noqa: PLR2004
|
|
543
|
+
y, m = y + 1, 1
|
|
544
|
+
else:
|
|
545
|
+
m += 1
|
|
546
|
+
|
|
547
|
+
async def fetch_all_data(self, start_date: date, end_date: date) -> None:
|
|
548
|
+
"""Fetch consumption for a date range and store in the dataclass.
|
|
549
|
+
|
|
550
|
+
- monthly_consumption: list of yearly payloads for each covered year
|
|
551
|
+
- daily_consumption: list of monthly payloads for each covered (year, month)
|
|
552
|
+
"""
|
|
553
|
+
_LOGGER.info(
|
|
554
|
+
"Fetching all data for range %s -> %s...",
|
|
555
|
+
start_date,
|
|
556
|
+
end_date,
|
|
557
|
+
)
|
|
558
|
+
await self._check_token()
|
|
559
|
+
|
|
560
|
+
semaphore = asyncio.Semaphore(CONCURRENTS_TASKS)
|
|
561
|
+
|
|
562
|
+
async def _sem_task(
|
|
563
|
+
task_coro: Coroutine[Any, Any, list[dict[str, Any]]],
|
|
564
|
+
) -> list[dict[str, Any]]:
|
|
565
|
+
"""Semaphore coro."""
|
|
566
|
+
async with semaphore:
|
|
567
|
+
return await task_coro
|
|
568
|
+
|
|
569
|
+
years = list(range(start_date.year, end_date.year + 1))
|
|
570
|
+
monthly_tasks = [
|
|
571
|
+
_sem_task(self._get_consumption_data(ConsumptionType.YEARLY, y))
|
|
572
|
+
for y in years
|
|
573
|
+
]
|
|
574
|
+
daily_tasks = [
|
|
575
|
+
_sem_task(self._get_consumption_data(ConsumptionType.MONTHLY, y, m))
|
|
576
|
+
for (y, m) in self._iter_months(start_date, end_date)
|
|
577
|
+
]
|
|
578
|
+
|
|
579
|
+
monthly_results = await asyncio.gather(*monthly_tasks) if monthly_tasks else []
|
|
580
|
+
daily_results = await asyncio.gather(*daily_tasks) if daily_tasks else []
|
|
581
|
+
|
|
582
|
+
self.account_data.monthly_consumption = list(
|
|
583
|
+
itertools.chain.from_iterable(monthly_results),
|
|
584
|
+
)
|
|
585
|
+
self.account_data.daily_consumption = list(
|
|
586
|
+
itertools.chain.from_iterable(daily_results),
|
|
587
|
+
)
|
|
588
|
+
# Fetch other data with no historical
|
|
589
|
+
self.account_data.billing_plan = await self._get_mensualisation_plan()
|
|
590
|
+
self.account_data.alert_settings = await self.get_alerts_settings()
|
|
591
|
+
_LOGGER.info("OK - All data fetched for range")
|
|
592
|
+
|
|
593
|
+
async def set_alerts_settings(self, alert_settings: AlertSettings) -> bool:
|
|
594
|
+
"""Set the consumption alerts."""
|
|
595
|
+
await self._check_token()
|
|
596
|
+
|
|
597
|
+
_LOGGER.debug("Setting alerts params...")
|
|
598
|
+
url = f"{self._backend_url}/alertes/{self.account_data.numero_pds}"
|
|
599
|
+
payload: dict[str, Any] = {}
|
|
600
|
+
|
|
601
|
+
if alert_settings.daily_enabled:
|
|
602
|
+
payload["alerte_journaliere"] = {
|
|
603
|
+
"seuil": alert_settings.daily_threshold,
|
|
604
|
+
"unite": "L",
|
|
605
|
+
"souscrite": True,
|
|
606
|
+
"contact_channel": {
|
|
607
|
+
"subscribed_by_email": alert_settings.daily_notif_email,
|
|
608
|
+
"subscribed_by_mobile": alert_settings.daily_notif_sms,
|
|
609
|
+
},
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if alert_settings.monthly_enabled:
|
|
613
|
+
payload["alerte_mensuelle"] = {
|
|
614
|
+
"seuil": alert_settings.monthly_threshold,
|
|
615
|
+
"unite": "M3",
|
|
616
|
+
"souscrite": True,
|
|
617
|
+
"contact_channel": {
|
|
618
|
+
"subscribed_by_email": alert_settings.monthly_notif_email,
|
|
619
|
+
"subscribed_by_mobile": alert_settings.monthly_notif_sms,
|
|
620
|
+
},
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
payload.update(
|
|
624
|
+
{
|
|
625
|
+
"contact_id": self.account_data.contact_id,
|
|
626
|
+
"numero_compteur": self.account_data.numero_compteur,
|
|
627
|
+
"tiers_id": self.account_data.tiers_id,
|
|
628
|
+
"abo_id": str(self.account_data.id_abonnement),
|
|
629
|
+
"type_front": TYPE_FRONT,
|
|
630
|
+
},
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
_LOGGER.debug("Alert settings payload: %s", payload)
|
|
634
|
+
|
|
635
|
+
response = await self._send_request(url=url, method=POST, json_data=payload)
|
|
636
|
+
if response.status != HTTPStatus.NO_CONTENT:
|
|
637
|
+
raise VeoliaAPISetDataError(
|
|
638
|
+
f"Failed to set alerts settings with status code {response.status}, maybe alert are not supported on this account ?",
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
_LOGGER.debug("OK - Alerts settings set")
|
|
642
|
+
return True
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: veolia-api-foxace
|
|
3
|
+
Version: 2.3.0
|
|
4
|
+
Summary: Async client for the Veolia water portals (eau.veolia.fr and delegated portals)
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: python,veolia
|
|
8
|
+
Author: Jezza34000
|
|
9
|
+
Author-email: info@mail.com
|
|
10
|
+
Maintainer: foXaCe
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Requires-Dist: aiohttp (>=3.11.11,<4.0.0)
|
|
20
|
+
Requires-Dist: tenacity (>=9.1.2,<10.0.0)
|
|
21
|
+
Project-URL: Homepage, https://github.com/foXaCe/veolia-api
|
|
22
|
+
Project-URL: Repository, https://github.com/foXaCe/veolia-api
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
<p align=center>
|
|
26
|
+
<img src="https://upload.wikimedia.org/wikipedia/fi/thumb/2/2a/Veolia-logo.svg/250px-Veolia-logo.svg.png"/>
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
<p>
|
|
30
|
+
<a href="https://pypi.org/project/veolia-api-foxace/"><img src="https://img.shields.io/pypi/v/veolia-api-foxace.svg"/></a>
|
|
31
|
+
<a href="https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white"><img src="https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white" /></a>
|
|
32
|
+
<a href="https://github.com/psf/black"><img src="https://img.shields.io/badge/code%20style-black-000000.svg" /></a>
|
|
33
|
+
<a href="https://github.com/foXaCe/veolia-api/actions"><img src="https://github.com/foXaCe/veolia-api/workflows/CI/badge.svg"/></a>
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
Async Python client for the Veolia water portal API (`eau.veolia.fr`).
|
|
37
|
+
|
|
38
|
+
## Table of contents
|
|
39
|
+
|
|
40
|
+
- [Installation](#installation)
|
|
41
|
+
- [Usage](#usage)
|
|
42
|
+
- [Contributing](#contributing)
|
|
43
|
+
- [Credits](#credits)
|
|
44
|
+
- [License](#license)
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
First of all, you need to install [devbox](https://www.jetify.com/docs/devbox/installing-devbox) **if you don't have a python environment**
|
|
49
|
+
|
|
50
|
+
Once the previous step is done, simply run
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
devbox shell
|
|
54
|
+
cp .env.example .env # fill in your credentials
|
|
55
|
+
python usage_example.py
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
That's it !
|
|
59
|
+
|
|
60
|
+
If you already have a python environment just run
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install veolia-api-foxace
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
"""Example of usage of the Veolia API"""
|
|
70
|
+
|
|
71
|
+
import asyncio
|
|
72
|
+
from datetime import date
|
|
73
|
+
|
|
74
|
+
import aiohttp
|
|
75
|
+
|
|
76
|
+
from veolia_api.veolia_api import VeoliaAPI
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def main() -> None:
|
|
80
|
+
"""Main function."""
|
|
81
|
+
|
|
82
|
+
async with aiohttp.ClientSession() as session:
|
|
83
|
+
client_api = VeoliaAPI("your@email.com", "password", session)
|
|
84
|
+
|
|
85
|
+
await client_api.fetch_all_data(date(2025, 1, 1), date(2025, 9, 1))
|
|
86
|
+
|
|
87
|
+
# Display fetched data
|
|
88
|
+
print(client_api.account_data.daily_consumption)
|
|
89
|
+
print(client_api.account_data.monthly_consumption)
|
|
90
|
+
print(client_api.account_data.alert_settings.daily_enabled)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
asyncio.run(main())
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
You can use usage_example.py
|
|
99
|
+
|
|
100
|
+
### Portals
|
|
101
|
+
|
|
102
|
+
Veolia operates several portals. They share the same Cognito authentication flow
|
|
103
|
+
but each has its own `client_id`, and some run on a dedicated data backend. Select
|
|
104
|
+
a portal with the `portal_url` argument (defaults to the national portal):
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
client_api = VeoliaAPI("your@email.com", "password", session, portal_url="www.ea-pm.fr")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
| `portal_url` | Description | Backend |
|
|
111
|
+
| --------------------------------- | --------------------------------------- | -------- |
|
|
112
|
+
| `eau.veolia.fr` (default) | Veolia France (national) | default |
|
|
113
|
+
| `eaudetm.monespace.eau.veolia.fr` | Eau de Toulouse Métropole | default |
|
|
114
|
+
| `www.ea-pm.fr` | Eau de Perpignan Méditerranée Métropole | dedicated |
|
|
115
|
+
|
|
116
|
+
To add a portal, add an entry to `VEOLIA_PORTALS` in
|
|
117
|
+
[`veolia_api/portals.py`](veolia_api/portals.py) with its `client_id` (found in
|
|
118
|
+
the portal's JavaScript bundle as `ClientId:"..."`) and, if different from the
|
|
119
|
+
default, its `backend_url`.
|
|
120
|
+
|
|
121
|
+
## Contributing
|
|
122
|
+
|
|
123
|
+
Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on reporting bugs, suggesting features, and submitting pull requests.
|
|
124
|
+
|
|
125
|
+
## Credits
|
|
126
|
+
|
|
127
|
+
This repository is inspired by the work done by @CorentinGrard. Thanks to him for his work.
|
|
128
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
veolia_api/__init__.py,sha256=Sjxz59Bl5-QVk6e3kr3an6uqkdyEfcJy2ieBFQIHMWE,295
|
|
2
|
+
veolia_api/constants.py,sha256=RcSVuABALHa7ahLz3tfAugk4qoc3SUA6BZ73yJDxMFk,1094
|
|
3
|
+
veolia_api/exceptions.py,sha256=tjikhgfeEVdGfRHsGeUphDfsjolgtrC4S5rASXoKeD4,904
|
|
4
|
+
veolia_api/model.py,sha256=TgsTMceRvbeYRiccioGKy4bxXIyTN32S5o5_RrCeB5Y,2097
|
|
5
|
+
veolia_api/portals.py,sha256=vZlwt430MF0sl9XFOxn8JdoG2HN9gbFaBDw-S-5IL_w,2717
|
|
6
|
+
veolia_api/veolia_api.py,sha256=PfT_PHUnEC2xm28eY5RAUBUdeRMHBfQZjq2kQWI_JVM,23799
|
|
7
|
+
veolia_api_foxace-2.3.0.dist-info/METADATA,sha256=wmxYVq_e471-EI4Bq2zj3DTgLFoYe2rKFDh117Uz-u8,4334
|
|
8
|
+
veolia_api_foxace-2.3.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
9
|
+
veolia_api_foxace-2.3.0.dist-info/licenses/LICENSE,sha256=PT_M2vrKC4ksLkpuXcIi9ojgypP-egmv5DXftkVP_04,1067
|
|
10
|
+
veolia_api_foxace-2.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jezza34000
|
|
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.
|