e-data 2.0.0b1__tar.gz → 2.0.0b2__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.
- {e_data-2.0.0b1 → e_data-2.0.0b2}/PKG-INFO +4 -2
- e_data-2.0.0b2/e_data.egg-info/SOURCES.txt +43 -0
- e_data-2.0.0b2/edata/connectors/datadis.py +544 -0
- e_data-2.0.0b2/edata/connectors/redata.py +88 -0
- e_data-2.0.0b2/edata/models/__init__.py +22 -0
- e_data-2.0.0b2/edata/models/base.py +105 -0
- e_data-2.0.0b2/edata/models/consumption.py +101 -0
- e_data-2.0.0b2/edata/models/contract.py +52 -0
- e_data-2.0.0b2/edata/models/database.py +136 -0
- e_data-2.0.0b2/edata/models/maximeter.py +39 -0
- e_data-2.0.0b2/edata/models/pricing.py +174 -0
- e_data-2.0.0b2/edata/models/supply.py +71 -0
- e_data-2.0.0b2/edata/scripts/__init__.py +1 -0
- e_data-2.0.0b2/edata/scripts/dump.py +408 -0
- e_data-2.0.0b2/edata/services/__init__.py +18 -0
- e_data-2.0.0b2/edata/services/billing.py +1046 -0
- e_data-2.0.0b2/edata/services/consumption.py +742 -0
- e_data-2.0.0b2/edata/services/contract.py +343 -0
- e_data-2.0.0b2/edata/services/database.py +468 -0
- e_data-2.0.0b2/edata/services/maximeter.py +409 -0
- e_data-2.0.0b2/edata/services/supply.py +389 -0
- e_data-2.0.0b2/edata/tests/connectors/__init__.py +0 -0
- e_data-2.0.0b2/edata/tests/services/__init__.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/pyproject.toml +8 -3
- e_data-2.0.0b1/e_data.egg-info/SOURCES.txt +0 -22
- {e_data-2.0.0b1 → e_data-2.0.0b2}/.python-version +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/LICENSE +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/MANIFEST.in +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/Makefile +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/README.md +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/__init__.py +0 -0
- {e_data-2.0.0b1/edata/tests → e_data-2.0.0b2/edata/connectors}/__init__.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/const.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/helpers.py +0 -0
- /e_data-2.0.0b1/edata/tests/connectors/__init__.py → /e_data-2.0.0b2/edata/scripts/__main__.py +0 -0
- {e_data-2.0.0b1/edata/tests/services → e_data-2.0.0b2/edata/tests}/__init__.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/connectors/test_datadis_connector.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/connectors/test_redata_connector.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/services/test_billing_service.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/services/test_consumption_service.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/services/test_contract_service.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/services/test_database_service.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/services/test_maximeter_service.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/services/test_supply_service.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/test_helpers.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/utils.py +0 -0
- {e_data-2.0.0b1 → e_data-2.0.0b2}/setup.cfg +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: e-data
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.0b2
|
|
4
4
|
Summary: Python library for managing spanish energy data from various web providers
|
|
5
5
|
Author-email: VMG <vmayorg@outlook.es>
|
|
6
|
-
License: GPL-3.0-or-later
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
7
|
Project-URL: Homepage, https://github.com/uvejota/python-edata
|
|
8
8
|
Project-URL: Repository, https://github.com/uvejota/python-edata
|
|
9
9
|
Project-URL: Issues, https://github.com/uvejota/python-edata/issues
|
|
@@ -31,6 +31,8 @@ Requires-Dist: pydantic>=2.0.0
|
|
|
31
31
|
Requires-Dist: sqlmodel>=0.0.24
|
|
32
32
|
Requires-Dist: aiosqlite>=0.20.0
|
|
33
33
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.0
|
|
34
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
35
|
+
Requires-Dist: diskcache>=5.4.0
|
|
34
36
|
Provides-Extra: dev
|
|
35
37
|
Requires-Dist: pytest>=7.1.2; extra == "dev"
|
|
36
38
|
Requires-Dist: freezegun>=1.2.1; extra == "dev"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
.python-version
|
|
2
|
+
LICENSE
|
|
3
|
+
MANIFEST.in
|
|
4
|
+
Makefile
|
|
5
|
+
README.md
|
|
6
|
+
pyproject.toml
|
|
7
|
+
edata/__init__.py
|
|
8
|
+
edata/const.py
|
|
9
|
+
edata/helpers.py
|
|
10
|
+
edata/utils.py
|
|
11
|
+
edata/connectors/__init__.py
|
|
12
|
+
edata/connectors/datadis.py
|
|
13
|
+
edata/connectors/redata.py
|
|
14
|
+
edata/models/__init__.py
|
|
15
|
+
edata/models/base.py
|
|
16
|
+
edata/models/consumption.py
|
|
17
|
+
edata/models/contract.py
|
|
18
|
+
edata/models/database.py
|
|
19
|
+
edata/models/maximeter.py
|
|
20
|
+
edata/models/pricing.py
|
|
21
|
+
edata/models/supply.py
|
|
22
|
+
edata/scripts/__init__.py
|
|
23
|
+
edata/scripts/__main__.py
|
|
24
|
+
edata/scripts/dump.py
|
|
25
|
+
edata/services/__init__.py
|
|
26
|
+
edata/services/billing.py
|
|
27
|
+
edata/services/consumption.py
|
|
28
|
+
edata/services/contract.py
|
|
29
|
+
edata/services/database.py
|
|
30
|
+
edata/services/maximeter.py
|
|
31
|
+
edata/services/supply.py
|
|
32
|
+
edata/tests/__init__.py
|
|
33
|
+
edata/tests/test_helpers.py
|
|
34
|
+
edata/tests/connectors/__init__.py
|
|
35
|
+
edata/tests/connectors/test_datadis_connector.py
|
|
36
|
+
edata/tests/connectors/test_redata_connector.py
|
|
37
|
+
edata/tests/services/__init__.py
|
|
38
|
+
edata/tests/services/test_billing_service.py
|
|
39
|
+
edata/tests/services/test_consumption_service.py
|
|
40
|
+
edata/tests/services/test_contract_service.py
|
|
41
|
+
edata/tests/services/test_database_service.py
|
|
42
|
+
edata/tests/services/test_maximeter_service.py
|
|
43
|
+
edata/tests/services/test_supply_service.py
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""Datadis API connector.
|
|
2
|
+
|
|
3
|
+
To fetch data from datadis.es private API.
|
|
4
|
+
There a few issues that are workarounded:
|
|
5
|
+
- You have to wait 24h between two identical requests.
|
|
6
|
+
- Datadis server does not like ranges greater than 1 month.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import hashlib
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
|
|
16
|
+
import aiohttp
|
|
17
|
+
import diskcache
|
|
18
|
+
from dateutil.relativedelta import relativedelta
|
|
19
|
+
|
|
20
|
+
from edata import utils
|
|
21
|
+
from edata.models import Consumption, Contract, MaxPower, Supply
|
|
22
|
+
|
|
23
|
+
_LOGGER = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Request timeout constant
|
|
26
|
+
REQUESTS_TIMEOUT = 30
|
|
27
|
+
|
|
28
|
+
# Token-related constants
|
|
29
|
+
URL_TOKEN = "https://datadis.es/nikola-auth/tokens/login"
|
|
30
|
+
TOKEN_USERNAME = "username"
|
|
31
|
+
TOKEN_PASSWD = "password"
|
|
32
|
+
|
|
33
|
+
# Supplies-related constants
|
|
34
|
+
URL_GET_SUPPLIES = "https://datadis.es/api-private/api/get-supplies"
|
|
35
|
+
GET_SUPPLIES_MANDATORY_FIELDS = [
|
|
36
|
+
"cups",
|
|
37
|
+
"validDateFrom",
|
|
38
|
+
"validDateTo",
|
|
39
|
+
"pointType",
|
|
40
|
+
"distributorCode",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Contracts-related constants
|
|
44
|
+
URL_GET_CONTRACT_DETAIL = "https://datadis.es/api-private/api/get-contract-detail"
|
|
45
|
+
GET_CONTRACT_DETAIL_MANDATORY_FIELDS = [
|
|
46
|
+
"startDate",
|
|
47
|
+
"endDate",
|
|
48
|
+
"marketer",
|
|
49
|
+
"contractedPowerkW",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
# Consumption-related constants
|
|
53
|
+
URL_GET_CONSUMPTION_DATA = "https://datadis.es/api-private/api/get-consumption-data"
|
|
54
|
+
GET_CONSUMPTION_DATA_MANDATORY_FIELDS = [
|
|
55
|
+
"time",
|
|
56
|
+
"date",
|
|
57
|
+
"consumptionKWh",
|
|
58
|
+
"obtainMethod",
|
|
59
|
+
]
|
|
60
|
+
MAX_CONSUMPTIONS_MONTHS = (
|
|
61
|
+
1 # max consumptions in a single request (fixed to 1 due to datadis limitations)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Maximeter-related constants
|
|
65
|
+
URL_GET_MAX_POWER = "https://datadis.es/api-private/api/get-max-power"
|
|
66
|
+
GET_MAX_POWER_MANDATORY_FIELDS = ["time", "date", "maxPower"]
|
|
67
|
+
|
|
68
|
+
# Timing constants
|
|
69
|
+
TIMEOUT = 3 * 60 # requests timeout
|
|
70
|
+
QUERY_LIMIT = timedelta(hours=24) # a datadis limitation, again...
|
|
71
|
+
|
|
72
|
+
# Cache-related constants
|
|
73
|
+
RECENT_CACHE_SUBDIR = "cache"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DatadisConnector:
|
|
77
|
+
"""A Datadis private API connector."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
username: str,
|
|
82
|
+
password: str,
|
|
83
|
+
enable_smart_fetch: bool = True,
|
|
84
|
+
storage_path: str | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""DatadisConnector constructor."""
|
|
87
|
+
|
|
88
|
+
# initialize some things
|
|
89
|
+
self._usr = username
|
|
90
|
+
self._pwd = password
|
|
91
|
+
self._token = {}
|
|
92
|
+
self._smart_fetch = enable_smart_fetch
|
|
93
|
+
self._warned_queries = []
|
|
94
|
+
if storage_path is not None:
|
|
95
|
+
self._recent_cache_dir = os.path.join(storage_path, RECENT_CACHE_SUBDIR)
|
|
96
|
+
else:
|
|
97
|
+
self._recent_cache_dir = os.path.join(
|
|
98
|
+
tempfile.gettempdir(), RECENT_CACHE_SUBDIR
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
os.makedirs(self._recent_cache_dir, exist_ok=True)
|
|
102
|
+
|
|
103
|
+
# Initialize diskcache for persistent caching
|
|
104
|
+
self._cache = diskcache.Cache(
|
|
105
|
+
self._recent_cache_dir,
|
|
106
|
+
size_limit=100 * 1024 * 1024, # 100MB limit
|
|
107
|
+
eviction_policy="least-recently-used",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def login(self):
|
|
111
|
+
"""Test to login with provided credentials."""
|
|
112
|
+
return await self._get_token()
|
|
113
|
+
|
|
114
|
+
async def get_supplies(self, authorized_nif: str | None = None) -> list[Supply]:
|
|
115
|
+
"""Datadis 'get_supplies' query (async version)."""
|
|
116
|
+
|
|
117
|
+
data = {}
|
|
118
|
+
|
|
119
|
+
# If authorized_nif is provided, we have to include it as parameter
|
|
120
|
+
if authorized_nif is not None:
|
|
121
|
+
data["authorizedNif"] = authorized_nif
|
|
122
|
+
|
|
123
|
+
# Request the resource using get method
|
|
124
|
+
response = await self._get(
|
|
125
|
+
URL_GET_SUPPLIES, request_data=data, ignore_recent_queries=True
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Response is a list of serialized supplies.
|
|
129
|
+
# We will iter through them to transform them into Supply objects
|
|
130
|
+
supplies = []
|
|
131
|
+
# Build tomorrow Y/m/d string since we will use it as the 'date_end' of
|
|
132
|
+
# active supplies
|
|
133
|
+
tomorrow_str = (datetime.today() + timedelta(days=1)).strftime("%Y/%m/%d")
|
|
134
|
+
for i in response:
|
|
135
|
+
# check data integrity (maybe this can be supressed if datadis proves to be reliable)
|
|
136
|
+
if all(k in i for k in GET_SUPPLIES_MANDATORY_FIELDS):
|
|
137
|
+
supplies.append(
|
|
138
|
+
Supply(
|
|
139
|
+
cups=i["cups"], # the supply identifier
|
|
140
|
+
date_start=datetime.strptime(
|
|
141
|
+
(
|
|
142
|
+
i["validDateFrom"]
|
|
143
|
+
if i["validDateFrom"] != ""
|
|
144
|
+
else "1970/01/01"
|
|
145
|
+
),
|
|
146
|
+
"%Y/%m/%d",
|
|
147
|
+
), # start date of the supply. 1970/01/01 if unset.
|
|
148
|
+
date_end=datetime.strptime(
|
|
149
|
+
(
|
|
150
|
+
i["validDateTo"]
|
|
151
|
+
if i["validDateTo"] != ""
|
|
152
|
+
else tomorrow_str
|
|
153
|
+
),
|
|
154
|
+
"%Y/%m/%d",
|
|
155
|
+
), # end date of the supply, tomorrow if unset
|
|
156
|
+
# the following parameters are not crucial, so they can be none
|
|
157
|
+
address=i.get("address", None),
|
|
158
|
+
postal_code=i.get("postalCode", None),
|
|
159
|
+
province=i.get("province", None),
|
|
160
|
+
municipality=i.get("municipality", None),
|
|
161
|
+
distributor=i.get("distributor", None),
|
|
162
|
+
# these two are mandatory, we will use them to fetch contracts data
|
|
163
|
+
point_type=i["pointType"],
|
|
164
|
+
distributor_code=i["distributorCode"],
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
_LOGGER.warning(
|
|
169
|
+
"Weird data structure while fetching supplies data, got %s",
|
|
170
|
+
response,
|
|
171
|
+
)
|
|
172
|
+
return supplies
|
|
173
|
+
|
|
174
|
+
async def _get(
|
|
175
|
+
self,
|
|
176
|
+
url: str,
|
|
177
|
+
request_data: dict | None = None,
|
|
178
|
+
refresh_token: bool = False,
|
|
179
|
+
is_retry: bool = False,
|
|
180
|
+
ignore_recent_queries: bool = False,
|
|
181
|
+
):
|
|
182
|
+
"""Get request for Datadis API (async version)."""
|
|
183
|
+
|
|
184
|
+
if request_data is None:
|
|
185
|
+
data = {}
|
|
186
|
+
else:
|
|
187
|
+
data = request_data
|
|
188
|
+
|
|
189
|
+
# build get parameters
|
|
190
|
+
params = "?" if len(data) > 0 else ""
|
|
191
|
+
for param in data:
|
|
192
|
+
key = param
|
|
193
|
+
value = data[param]
|
|
194
|
+
params = params + f"{key}={value}&"
|
|
195
|
+
anonym_params = "?" if len(data) > 0 else ""
|
|
196
|
+
|
|
197
|
+
# build anonymized params for logging
|
|
198
|
+
for anonym_param in data:
|
|
199
|
+
key = anonym_param
|
|
200
|
+
if key == "cups":
|
|
201
|
+
value = "xxxx" + data[anonym_param][-5:]
|
|
202
|
+
elif key == "authorizedNif":
|
|
203
|
+
value = "xxxx"
|
|
204
|
+
else:
|
|
205
|
+
value = data[anonym_param]
|
|
206
|
+
anonym_params = anonym_params + f"{key}={value}&"
|
|
207
|
+
|
|
208
|
+
# Check diskcache first (unless ignoring cache)
|
|
209
|
+
if not ignore_recent_queries:
|
|
210
|
+
cache_data = {
|
|
211
|
+
"url": url,
|
|
212
|
+
"request_data": request_data,
|
|
213
|
+
"refresh_token": refresh_token,
|
|
214
|
+
"is_retry": is_retry,
|
|
215
|
+
}
|
|
216
|
+
cache_key = hashlib.sha256(str(cache_data).encode()).hexdigest()
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
# Run cache get operation in thread to avoid blocking
|
|
220
|
+
cached_result = await asyncio.to_thread(self._cache.get, cache_key)
|
|
221
|
+
if cached_result is not None and isinstance(
|
|
222
|
+
cached_result, (list, dict)
|
|
223
|
+
):
|
|
224
|
+
_LOGGER.info(
|
|
225
|
+
"Returning cached response for '%s'", url + anonym_params
|
|
226
|
+
)
|
|
227
|
+
return cached_result
|
|
228
|
+
except Exception as e:
|
|
229
|
+
_LOGGER.warning("Error reading cache: %s", e)
|
|
230
|
+
|
|
231
|
+
# refresh token if needed (recursive approach)
|
|
232
|
+
is_valid_token = False
|
|
233
|
+
response = []
|
|
234
|
+
if refresh_token:
|
|
235
|
+
is_valid_token = await self._get_token()
|
|
236
|
+
if is_valid_token or not refresh_token:
|
|
237
|
+
|
|
238
|
+
# run the query
|
|
239
|
+
timeout = aiohttp.ClientTimeout(total=REQUESTS_TIMEOUT)
|
|
240
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
241
|
+
try:
|
|
242
|
+
_LOGGER.info("GET %s", url + anonym_params)
|
|
243
|
+
headers = {"Accept-Encoding": "identity"}
|
|
244
|
+
|
|
245
|
+
# Ensure we have a token
|
|
246
|
+
if not self._token.get("encoded"):
|
|
247
|
+
await self._get_token()
|
|
248
|
+
|
|
249
|
+
headers["Authorization"] = f"Bearer {self._token['encoded']}"
|
|
250
|
+
|
|
251
|
+
async with session.get(url + params, headers=headers) as reply:
|
|
252
|
+
# eval response
|
|
253
|
+
if reply.status == 200:
|
|
254
|
+
# we're here if reply seems valid
|
|
255
|
+
_LOGGER.info("Got 200 OK")
|
|
256
|
+
try:
|
|
257
|
+
response_json = await reply.json(content_type=None)
|
|
258
|
+
if response_json:
|
|
259
|
+
response = response_json
|
|
260
|
+
# Store in diskcache with 24h TTL
|
|
261
|
+
if not ignore_recent_queries and isinstance(
|
|
262
|
+
response, (list, dict)
|
|
263
|
+
):
|
|
264
|
+
try:
|
|
265
|
+
cache_data = {
|
|
266
|
+
"url": url,
|
|
267
|
+
"request_data": request_data,
|
|
268
|
+
"refresh_token": refresh_token,
|
|
269
|
+
"is_retry": is_retry,
|
|
270
|
+
}
|
|
271
|
+
cache_key = hashlib.sha256(
|
|
272
|
+
str(cache_data).encode()
|
|
273
|
+
).hexdigest()
|
|
274
|
+
ttl_seconds = int(
|
|
275
|
+
QUERY_LIMIT.total_seconds()
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Run cache set operation in thread to avoid blocking
|
|
279
|
+
await asyncio.to_thread(
|
|
280
|
+
self._cache.set,
|
|
281
|
+
cache_key,
|
|
282
|
+
response,
|
|
283
|
+
expire=ttl_seconds,
|
|
284
|
+
)
|
|
285
|
+
_LOGGER.info(
|
|
286
|
+
"Cached response for %s with TTL %d seconds",
|
|
287
|
+
url,
|
|
288
|
+
ttl_seconds,
|
|
289
|
+
)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
_LOGGER.warning(
|
|
292
|
+
"Error storing in cache: %s", e
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
# this mostly happens when datadis provides an empty response
|
|
296
|
+
_LOGGER.info("Got an empty response")
|
|
297
|
+
except Exception as e:
|
|
298
|
+
# Handle non-JSON responses
|
|
299
|
+
_LOGGER.info("Got an empty or non-JSON response")
|
|
300
|
+
_LOGGER.exception(e)
|
|
301
|
+
elif reply.status == 401 and not refresh_token:
|
|
302
|
+
# we're here if we were unauthorized so we will refresh the token
|
|
303
|
+
response = await self._get(
|
|
304
|
+
url,
|
|
305
|
+
request_data=data,
|
|
306
|
+
refresh_token=True,
|
|
307
|
+
ignore_recent_queries=ignore_recent_queries,
|
|
308
|
+
)
|
|
309
|
+
elif reply.status == 429:
|
|
310
|
+
# we're here if we exceeded datadis API rates (24h)
|
|
311
|
+
_LOGGER.warning(
|
|
312
|
+
"Got status code '%s' with message '%s'",
|
|
313
|
+
reply.status,
|
|
314
|
+
await reply.text(),
|
|
315
|
+
)
|
|
316
|
+
elif is_retry:
|
|
317
|
+
# otherwise, if this was a retried request... warn the user
|
|
318
|
+
if (url + params) not in self._warned_queries:
|
|
319
|
+
_LOGGER.warning(
|
|
320
|
+
"Got status code '%s' with message '%s'. %s. %s",
|
|
321
|
+
reply.status,
|
|
322
|
+
await reply.text(),
|
|
323
|
+
"Query temporary disabled",
|
|
324
|
+
"Future 500 code errors for this query will be silenced until restart",
|
|
325
|
+
)
|
|
326
|
+
self._warned_queries.append(url + params)
|
|
327
|
+
else:
|
|
328
|
+
# finally, retry since an unexpected error took place (mostly 500 errors - server fault)
|
|
329
|
+
response = await self._get(
|
|
330
|
+
url,
|
|
331
|
+
request_data,
|
|
332
|
+
is_retry=True,
|
|
333
|
+
ignore_recent_queries=ignore_recent_queries,
|
|
334
|
+
)
|
|
335
|
+
except asyncio.TimeoutError:
|
|
336
|
+
_LOGGER.warning("Timeout at %s", url + anonym_params)
|
|
337
|
+
return []
|
|
338
|
+
except Exception as e:
|
|
339
|
+
_LOGGER.error(
|
|
340
|
+
"Error during async request to %s: %s", url + anonym_params, e
|
|
341
|
+
)
|
|
342
|
+
return []
|
|
343
|
+
|
|
344
|
+
return response
|
|
345
|
+
|
|
346
|
+
async def get_contract_detail(
|
|
347
|
+
self, cups: str, distributor_code: str, authorized_nif: str | None = None
|
|
348
|
+
) -> list[Contract]:
|
|
349
|
+
"""Datadis get_contract_detail query (async version)."""
|
|
350
|
+
data = {"cups": cups, "distributorCode": distributor_code}
|
|
351
|
+
if authorized_nif is not None:
|
|
352
|
+
data["authorizedNif"] = authorized_nif
|
|
353
|
+
response = await self._get(
|
|
354
|
+
URL_GET_CONTRACT_DETAIL, request_data=data, ignore_recent_queries=True
|
|
355
|
+
)
|
|
356
|
+
contracts = []
|
|
357
|
+
tomorrow_str = (datetime.today() + timedelta(days=1)).strftime("%Y/%m/%d")
|
|
358
|
+
for i in response:
|
|
359
|
+
if all(k in i for k in GET_CONTRACT_DETAIL_MANDATORY_FIELDS):
|
|
360
|
+
contracts.append(
|
|
361
|
+
Contract(
|
|
362
|
+
date_start=datetime.strptime(
|
|
363
|
+
i["startDate"] if i["startDate"] != "" else "1970/01/01",
|
|
364
|
+
"%Y/%m/%d",
|
|
365
|
+
),
|
|
366
|
+
date_end=datetime.strptime(
|
|
367
|
+
i["endDate"] if i["endDate"] != "" else tomorrow_str,
|
|
368
|
+
"%Y/%m/%d",
|
|
369
|
+
),
|
|
370
|
+
marketer=i["marketer"],
|
|
371
|
+
distributor_code=distributor_code,
|
|
372
|
+
power_p1=(
|
|
373
|
+
i["contractedPowerkW"][0]
|
|
374
|
+
if isinstance(i["contractedPowerkW"], list)
|
|
375
|
+
else None
|
|
376
|
+
),
|
|
377
|
+
power_p2=(
|
|
378
|
+
i["contractedPowerkW"][1]
|
|
379
|
+
if (len(i["contractedPowerkW"]) > 1)
|
|
380
|
+
else None
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
else:
|
|
385
|
+
_LOGGER.warning(
|
|
386
|
+
"Weird data structure while fetching contracts data, got %s",
|
|
387
|
+
response,
|
|
388
|
+
)
|
|
389
|
+
return contracts
|
|
390
|
+
|
|
391
|
+
async def get_consumption_data(
|
|
392
|
+
self,
|
|
393
|
+
cups: str,
|
|
394
|
+
distributor_code: str,
|
|
395
|
+
start_date: datetime,
|
|
396
|
+
end_date: datetime,
|
|
397
|
+
measurement_type: str,
|
|
398
|
+
point_type: int,
|
|
399
|
+
authorized_nif: str | None = None,
|
|
400
|
+
is_smart_fetch: bool = False,
|
|
401
|
+
) -> list[Consumption]:
|
|
402
|
+
"""Datadis get_consumption_data query (async version)."""
|
|
403
|
+
|
|
404
|
+
if self._smart_fetch and not is_smart_fetch:
|
|
405
|
+
_start = start_date
|
|
406
|
+
consumptions_dicts = []
|
|
407
|
+
while _start < end_date:
|
|
408
|
+
_end = min(
|
|
409
|
+
_start + relativedelta(months=MAX_CONSUMPTIONS_MONTHS), end_date
|
|
410
|
+
)
|
|
411
|
+
batch_consumptions = await self.get_consumption_data(
|
|
412
|
+
cups,
|
|
413
|
+
distributor_code,
|
|
414
|
+
_start,
|
|
415
|
+
_end,
|
|
416
|
+
measurement_type,
|
|
417
|
+
point_type,
|
|
418
|
+
authorized_nif,
|
|
419
|
+
is_smart_fetch=True,
|
|
420
|
+
)
|
|
421
|
+
# Convert to dicts for extend_by_key function
|
|
422
|
+
batch_dicts = [c.model_dump() for c in batch_consumptions]
|
|
423
|
+
consumptions_dicts = utils.extend_by_key(
|
|
424
|
+
consumptions_dicts,
|
|
425
|
+
batch_dicts,
|
|
426
|
+
"datetime",
|
|
427
|
+
)
|
|
428
|
+
_start = _end
|
|
429
|
+
# Convert back to Pydantic models
|
|
430
|
+
return [Consumption(**c) for c in consumptions_dicts]
|
|
431
|
+
|
|
432
|
+
data = {
|
|
433
|
+
"cups": cups,
|
|
434
|
+
"distributorCode": distributor_code,
|
|
435
|
+
"startDate": datetime.strftime(start_date, "%Y/%m"),
|
|
436
|
+
"endDate": datetime.strftime(end_date, "%Y/%m"),
|
|
437
|
+
"measurementType": measurement_type,
|
|
438
|
+
"pointType": point_type,
|
|
439
|
+
}
|
|
440
|
+
if authorized_nif is not None:
|
|
441
|
+
data["authorizedNif"] = authorized_nif
|
|
442
|
+
|
|
443
|
+
response = await self._get(URL_GET_CONSUMPTION_DATA, request_data=data)
|
|
444
|
+
|
|
445
|
+
consumptions = []
|
|
446
|
+
for i in response:
|
|
447
|
+
if "consumptionKWh" in i:
|
|
448
|
+
if all(k in i for k in GET_CONSUMPTION_DATA_MANDATORY_FIELDS):
|
|
449
|
+
hour = str(int(i["time"].split(":")[0]) - 1)
|
|
450
|
+
date_as_dt = datetime.strptime(
|
|
451
|
+
f"{i['date']} {hour.zfill(2)}:00", "%Y/%m/%d %H:%M"
|
|
452
|
+
)
|
|
453
|
+
if not (start_date <= date_as_dt <= end_date):
|
|
454
|
+
continue # skip element if dt is out of range
|
|
455
|
+
_surplus = i.get("surplusEnergyKWh", 0)
|
|
456
|
+
if _surplus is None:
|
|
457
|
+
_surplus = 0
|
|
458
|
+
consumptions.append(
|
|
459
|
+
Consumption(
|
|
460
|
+
datetime=date_as_dt,
|
|
461
|
+
delta_h=1,
|
|
462
|
+
value_kwh=i["consumptionKWh"],
|
|
463
|
+
surplus_kwh=_surplus,
|
|
464
|
+
real=i["obtainMethod"] == "Real",
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
else:
|
|
468
|
+
_LOGGER.warning(
|
|
469
|
+
"Weird data structure while fetching consumption data, got %s",
|
|
470
|
+
response,
|
|
471
|
+
)
|
|
472
|
+
return consumptions
|
|
473
|
+
|
|
474
|
+
async def get_max_power(
|
|
475
|
+
self,
|
|
476
|
+
cups: str,
|
|
477
|
+
distributor_code: str,
|
|
478
|
+
start_date: datetime,
|
|
479
|
+
end_date: datetime,
|
|
480
|
+
authorized_nif: str | None = None,
|
|
481
|
+
) -> list[MaxPower]:
|
|
482
|
+
"""Datadis get_max_power query (async version)."""
|
|
483
|
+
|
|
484
|
+
data = {
|
|
485
|
+
"cups": cups,
|
|
486
|
+
"distributorCode": distributor_code,
|
|
487
|
+
"startDate": datetime.strftime(start_date, "%Y/%m"),
|
|
488
|
+
"endDate": datetime.strftime(end_date, "%Y/%m"),
|
|
489
|
+
}
|
|
490
|
+
if authorized_nif is not None:
|
|
491
|
+
data["authorizedNif"] = authorized_nif
|
|
492
|
+
response = await self._get(URL_GET_MAX_POWER, request_data=data)
|
|
493
|
+
maxpower_values = []
|
|
494
|
+
for i in response:
|
|
495
|
+
if all(k in i for k in GET_MAX_POWER_MANDATORY_FIELDS):
|
|
496
|
+
maxpower_values.append(
|
|
497
|
+
MaxPower(
|
|
498
|
+
datetime=datetime.strptime(
|
|
499
|
+
f"{i['date']} {i['time']}", "%Y/%m/%d %H:%M"
|
|
500
|
+
),
|
|
501
|
+
value_kw=i["maxPower"],
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
else:
|
|
505
|
+
_LOGGER.warning(
|
|
506
|
+
"Weird data structure while fetching maximeter data, got %s",
|
|
507
|
+
response,
|
|
508
|
+
)
|
|
509
|
+
return maxpower_values
|
|
510
|
+
|
|
511
|
+
async def _get_token(self):
|
|
512
|
+
"""Private method that fetches a new token if needed (async version)."""
|
|
513
|
+
|
|
514
|
+
_LOGGER.info("Fetching token for async requests")
|
|
515
|
+
is_valid_token = False
|
|
516
|
+
|
|
517
|
+
timeout = aiohttp.ClientTimeout(total=REQUESTS_TIMEOUT)
|
|
518
|
+
|
|
519
|
+
# Prepare data as URL-encoded string, same as sync version
|
|
520
|
+
form_data = {
|
|
521
|
+
TOKEN_USERNAME: self._usr,
|
|
522
|
+
TOKEN_PASSWD: self._pwd,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
526
|
+
try:
|
|
527
|
+
async with session.post(
|
|
528
|
+
URL_TOKEN,
|
|
529
|
+
data=form_data,
|
|
530
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
531
|
+
) as response:
|
|
532
|
+
if response.status == 200:
|
|
533
|
+
# store token encoded
|
|
534
|
+
self._token["encoded"] = await response.text()
|
|
535
|
+
is_valid_token = True
|
|
536
|
+
else:
|
|
537
|
+
_LOGGER.error(
|
|
538
|
+
"Unknown error while retrieving async token, got %s",
|
|
539
|
+
await response.text(),
|
|
540
|
+
)
|
|
541
|
+
except Exception as e:
|
|
542
|
+
_LOGGER.error("Error during async token fetch: %s", e)
|
|
543
|
+
|
|
544
|
+
return is_valid_token
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""A REData API connector"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
from dateutil import parser
|
|
9
|
+
|
|
10
|
+
from edata.models.pricing import PricingData
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
REQUESTS_TIMEOUT = 15
|
|
15
|
+
|
|
16
|
+
URL_REALTIME_PRICES = (
|
|
17
|
+
"https://apidatos.ree.es/es/datos/mercados/precios-mercados-tiempo-real"
|
|
18
|
+
"?time_trunc=hour"
|
|
19
|
+
"&geo_ids={geo_id}"
|
|
20
|
+
"&start_date={start:%Y-%m-%dT%H:%M}&end_date={end:%Y-%m-%dT%H:%M}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class REDataConnector:
|
|
25
|
+
"""Main class for REData connector"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Init method for REDataConnector"""
|
|
31
|
+
|
|
32
|
+
async def get_realtime_prices(
|
|
33
|
+
self, dt_from: dt.datetime, dt_to: dt.datetime, is_ceuta_melilla: bool = False
|
|
34
|
+
) -> list:
|
|
35
|
+
"""GET query to fetch realtime pvpc prices, historical data is limited to current month (async version)"""
|
|
36
|
+
url = URL_REALTIME_PRICES.format(
|
|
37
|
+
geo_id=8744 if is_ceuta_melilla else 8741,
|
|
38
|
+
start=dt_from,
|
|
39
|
+
end=dt_to,
|
|
40
|
+
)
|
|
41
|
+
data = []
|
|
42
|
+
|
|
43
|
+
timeout = aiohttp.ClientTimeout(total=REQUESTS_TIMEOUT)
|
|
44
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
45
|
+
try:
|
|
46
|
+
async with session.get(url) as response:
|
|
47
|
+
if response.status == 200:
|
|
48
|
+
res_json = await response.json()
|
|
49
|
+
if res_json:
|
|
50
|
+
try:
|
|
51
|
+
res_list = res_json["included"][0]["attributes"][
|
|
52
|
+
"values"
|
|
53
|
+
]
|
|
54
|
+
except (IndexError, KeyError):
|
|
55
|
+
_LOGGER.error(
|
|
56
|
+
"%s returned a malformed response: %s ",
|
|
57
|
+
url,
|
|
58
|
+
await response.text(),
|
|
59
|
+
)
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
for element in res_list:
|
|
63
|
+
data.append(
|
|
64
|
+
PricingData(
|
|
65
|
+
datetime=parser.parse(
|
|
66
|
+
element["datetime"]
|
|
67
|
+
).replace(tzinfo=None),
|
|
68
|
+
value_eur_kwh=element["value"] / 1000,
|
|
69
|
+
delta_h=1,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
_LOGGER.error(
|
|
74
|
+
"%s returned %s with code %s",
|
|
75
|
+
url,
|
|
76
|
+
await response.text(),
|
|
77
|
+
response.status,
|
|
78
|
+
)
|
|
79
|
+
except asyncio.TimeoutError:
|
|
80
|
+
_LOGGER.error("Timeout error when fetching data from %s", url)
|
|
81
|
+
except aiohttp.ClientError as e:
|
|
82
|
+
_LOGGER.error(
|
|
83
|
+
"HTTP client error when fetching data from %s: %s", url, e
|
|
84
|
+
)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
_LOGGER.error("Unexpected error when fetching data from %s: %s", url, e)
|
|
87
|
+
|
|
88
|
+
return data
|