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.
Files changed (47) hide show
  1. {e_data-2.0.0b1 → e_data-2.0.0b2}/PKG-INFO +4 -2
  2. e_data-2.0.0b2/e_data.egg-info/SOURCES.txt +43 -0
  3. e_data-2.0.0b2/edata/connectors/datadis.py +544 -0
  4. e_data-2.0.0b2/edata/connectors/redata.py +88 -0
  5. e_data-2.0.0b2/edata/models/__init__.py +22 -0
  6. e_data-2.0.0b2/edata/models/base.py +105 -0
  7. e_data-2.0.0b2/edata/models/consumption.py +101 -0
  8. e_data-2.0.0b2/edata/models/contract.py +52 -0
  9. e_data-2.0.0b2/edata/models/database.py +136 -0
  10. e_data-2.0.0b2/edata/models/maximeter.py +39 -0
  11. e_data-2.0.0b2/edata/models/pricing.py +174 -0
  12. e_data-2.0.0b2/edata/models/supply.py +71 -0
  13. e_data-2.0.0b2/edata/scripts/__init__.py +1 -0
  14. e_data-2.0.0b2/edata/scripts/dump.py +408 -0
  15. e_data-2.0.0b2/edata/services/__init__.py +18 -0
  16. e_data-2.0.0b2/edata/services/billing.py +1046 -0
  17. e_data-2.0.0b2/edata/services/consumption.py +742 -0
  18. e_data-2.0.0b2/edata/services/contract.py +343 -0
  19. e_data-2.0.0b2/edata/services/database.py +468 -0
  20. e_data-2.0.0b2/edata/services/maximeter.py +409 -0
  21. e_data-2.0.0b2/edata/services/supply.py +389 -0
  22. e_data-2.0.0b2/edata/tests/connectors/__init__.py +0 -0
  23. e_data-2.0.0b2/edata/tests/services/__init__.py +0 -0
  24. {e_data-2.0.0b1 → e_data-2.0.0b2}/pyproject.toml +8 -3
  25. e_data-2.0.0b1/e_data.egg-info/SOURCES.txt +0 -22
  26. {e_data-2.0.0b1 → e_data-2.0.0b2}/.python-version +0 -0
  27. {e_data-2.0.0b1 → e_data-2.0.0b2}/LICENSE +0 -0
  28. {e_data-2.0.0b1 → e_data-2.0.0b2}/MANIFEST.in +0 -0
  29. {e_data-2.0.0b1 → e_data-2.0.0b2}/Makefile +0 -0
  30. {e_data-2.0.0b1 → e_data-2.0.0b2}/README.md +0 -0
  31. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/__init__.py +0 -0
  32. {e_data-2.0.0b1/edata/tests → e_data-2.0.0b2/edata/connectors}/__init__.py +0 -0
  33. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/const.py +0 -0
  34. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/helpers.py +0 -0
  35. /e_data-2.0.0b1/edata/tests/connectors/__init__.py → /e_data-2.0.0b2/edata/scripts/__main__.py +0 -0
  36. {e_data-2.0.0b1/edata/tests/services → e_data-2.0.0b2/edata/tests}/__init__.py +0 -0
  37. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/connectors/test_datadis_connector.py +0 -0
  38. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/connectors/test_redata_connector.py +0 -0
  39. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/services/test_billing_service.py +0 -0
  40. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/services/test_consumption_service.py +0 -0
  41. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/services/test_contract_service.py +0 -0
  42. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/services/test_database_service.py +0 -0
  43. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/services/test_maximeter_service.py +0 -0
  44. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/services/test_supply_service.py +0 -0
  45. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/tests/test_helpers.py +0 -0
  46. {e_data-2.0.0b1 → e_data-2.0.0b2}/edata/utils.py +0 -0
  47. {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.0b1
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