e-data 1.3.1__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.
connectors/__init__.py ADDED
File without changes
connectors/datadis.py ADDED
@@ -0,0 +1,533 @@
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 contextlib
10
+ from datetime import datetime, timedelta
11
+
12
+ import hashlib
13
+ import logging
14
+ import os
15
+ import tempfile
16
+ import diskcache
17
+
18
+ from dateutil.relativedelta import relativedelta
19
+
20
+ import aiohttp
21
+ import asyncio
22
+
23
+ from ..definitions import ConsumptionData, ContractData, MaxPowerData, SupplyData
24
+ from ..processors import utils
25
+
26
+ _LOGGER = logging.getLogger(__name__)
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
+
73
+ # Cache-related constants
74
+ RECENT_CACHE_SUBDIR = "cache"
75
+
76
+
77
+
78
+ def migrate_storage(storage_dir):
79
+ """Migrate storage from older versions."""
80
+ with contextlib.suppress(FileNotFoundError):
81
+ os.remove(os.path.join(storage_dir, "edata_recent_queries.json"))
82
+ os.remove(os.path.join(storage_dir, "edata_recent_queries_cache.json"))
83
+
84
+
85
+
86
+ class DatadisConnector:
87
+ """A Datadis private API connector."""
88
+
89
+ def __init__(
90
+ self,
91
+ username: str,
92
+ password: str,
93
+ enable_smart_fetch: bool = True,
94
+ storage_path: str | None = None,
95
+ ) -> None:
96
+ self._usr = username
97
+ self._pwd = password
98
+ self._token = {}
99
+ self._smart_fetch = enable_smart_fetch
100
+ self._recent_queries = {}
101
+ self._recent_cache = {}
102
+ self._warned_queries = []
103
+ if storage_path is not None:
104
+ self._recent_cache_dir = os.path.join(storage_path, RECENT_CACHE_SUBDIR)
105
+ migrate_storage(storage_path)
106
+ else:
107
+ self._recent_cache_dir = os.path.join(
108
+ tempfile.gettempdir(), RECENT_CACHE_SUBDIR
109
+ )
110
+ os.makedirs(self._recent_cache_dir, exist_ok=True)
111
+ self._cache = diskcache.Cache(self._recent_cache_dir)
112
+
113
+ def _update_recent_queries(self, query: str, data: dict | None = None) -> None:
114
+ """Cache a successful query to avoid exceeding query limits (diskcache)."""
115
+ hash_query = hashlib.md5(query.encode()).hexdigest()
116
+ try:
117
+ self._cache.set(hash_query, data, expire=QUERY_LIMIT.total_seconds())
118
+ _LOGGER.info("Updating cache item '%s'", hash_query)
119
+ except Exception as e:
120
+ _LOGGER.warning("Unknown error while updating cache: %s", e)
121
+
122
+ def _is_recent_query(self, query: str) -> bool:
123
+ """Check if a query has been done recently to avoid exceeding query limits (diskcache)."""
124
+ hash_query = hashlib.md5(query.encode()).hexdigest()
125
+ return hash_query in self._cache
126
+
127
+ def _get_cache_for_query(self, query: str) -> dict | None:
128
+ """Return cached response for a query (diskcache)."""
129
+ hash_query = hashlib.md5(query.encode()).hexdigest()
130
+ try:
131
+ return self._cache.get(hash_query, default=None)
132
+ except Exception:
133
+ return None
134
+
135
+
136
+ async def _async_get_token(self):
137
+ """Private async method that fetches a new token if needed."""
138
+ _LOGGER.info("No token found, fetching a new one")
139
+ is_valid_token = False
140
+ timeout = aiohttp.ClientTimeout(total=TIMEOUT)
141
+ async with aiohttp.ClientSession(timeout=timeout) as session:
142
+ try:
143
+ async with session.post(
144
+ URL_TOKEN,
145
+ data={
146
+ TOKEN_USERNAME: self._usr.encode("utf-8"),
147
+ TOKEN_PASSWD: self._pwd.encode("utf-8"),
148
+ },
149
+ ) as response:
150
+ text = await response.text()
151
+ if response.status == 200:
152
+ self._token["encoded"] = text
153
+ self._token["headers"] = {"Authorization": "Bearer " + self._token["encoded"]}
154
+ is_valid_token = True
155
+ else:
156
+ _LOGGER.error("Unknown error while retrieving token, got %s", text)
157
+ except Exception as e:
158
+ _LOGGER.error("Exception while retrieving token: %s", e)
159
+ return is_valid_token
160
+
161
+
162
+ def login(self):
163
+ """Test to login with provided credentials (sync wrapper)."""
164
+ return asyncio.run(self._async_get_token())
165
+
166
+
167
+ async def _async_get(
168
+ self,
169
+ url: str,
170
+ request_data: dict | None = None,
171
+ refresh_token: bool = False,
172
+ is_retry: bool = False,
173
+ ignore_recent_queries: bool = False,
174
+ ):
175
+ """Async get request for Datadis API."""
176
+ if request_data is None:
177
+ data = {}
178
+ else:
179
+ data = request_data
180
+
181
+ is_valid_token = False
182
+ response = []
183
+ if refresh_token:
184
+ is_valid_token = await self._async_get_token()
185
+ if is_valid_token or not refresh_token:
186
+ params = "?" if len(data) > 0 else ""
187
+ for param in data:
188
+ key = param
189
+ value = data[param]
190
+ params = params + f"{key}={value}&"
191
+ anonym_params = "?" if len(data) > 0 else ""
192
+ for anonym_param in data:
193
+ key = anonym_param
194
+ if key == "cups":
195
+ value = "xxxx" + str(data[anonym_param])[-5:]
196
+ elif key == "authorizedNif":
197
+ value = "xxxx"
198
+ else:
199
+ value = data[anonym_param]
200
+ anonym_params = anonym_params + f"{key}={value}&"
201
+
202
+ if not ignore_recent_queries and self._is_recent_query(url + params):
203
+ _cache = self._get_cache_for_query(url + params)
204
+ if _cache is not None:
205
+ _LOGGER.info(
206
+ "Returning cached response for '%s'", url + anonym_params
207
+ )
208
+ return _cache
209
+ return []
210
+
211
+ try:
212
+ _LOGGER.info("GET %s", url + anonym_params)
213
+ headers = {"Accept-Encoding": "identity"}
214
+ if self._token.get("headers"):
215
+ headers.update(self._token["headers"])
216
+ timeout = aiohttp.ClientTimeout(total=TIMEOUT)
217
+ async with aiohttp.ClientSession(timeout=timeout) as session:
218
+ async with session.get(
219
+ url + params,
220
+ headers=headers,
221
+ ) as reply:
222
+ text = await reply.text()
223
+ if reply.status == 200:
224
+ _LOGGER.info("Got 200 OK")
225
+ try:
226
+ json_data = await reply.json()
227
+ if json_data:
228
+ response = json_data
229
+ if not ignore_recent_queries:
230
+ self._update_recent_queries(url + params, response)
231
+ else:
232
+ _LOGGER.info("Got an empty response")
233
+ if not ignore_recent_queries:
234
+ self._update_recent_queries(url + params)
235
+ except Exception:
236
+ _LOGGER.warning("Failed to parse JSON response")
237
+ elif reply.status == 401 and not refresh_token:
238
+ response = await self._async_get(
239
+ url,
240
+ request_data=data,
241
+ refresh_token=True,
242
+ ignore_recent_queries=ignore_recent_queries,
243
+ )
244
+ elif reply.status == 429:
245
+ _LOGGER.warning(
246
+ "Got status code '%s' with message '%s'",
247
+ reply.status,
248
+ text,
249
+ )
250
+ if not ignore_recent_queries:
251
+ self._update_recent_queries(url + params)
252
+ elif is_retry:
253
+ if (url + params) not in self._warned_queries:
254
+ _LOGGER.warning(
255
+ "Got status code '%s' with message '%s'. %s. %s",
256
+ reply.status,
257
+ text,
258
+ "Query temporary disabled",
259
+ "Future 500 code errors for this query will be silenced until restart",
260
+ )
261
+ if not ignore_recent_queries:
262
+ self._update_recent_queries(url + params)
263
+ self._warned_queries.append(url + params)
264
+ else:
265
+ response = await self._async_get(
266
+ url,
267
+ request_data,
268
+ is_retry=True,
269
+ ignore_recent_queries=ignore_recent_queries,
270
+ )
271
+ except asyncio.TimeoutError:
272
+ _LOGGER.warning("Timeout at %s", url + anonym_params)
273
+ return []
274
+ except Exception as e:
275
+ _LOGGER.warning("Exception at %s: %s", url + anonym_params, e)
276
+ return []
277
+ return response
278
+
279
+ async def async_get_supplies(self, authorized_nif: str | None = None):
280
+ data = {}
281
+ if authorized_nif is not None:
282
+ data["authorizedNif"] = authorized_nif
283
+ response = await self._async_get(
284
+ URL_GET_SUPPLIES, request_data=data, ignore_recent_queries=True
285
+ )
286
+ supplies = []
287
+ tomorrow_str = (datetime.today() + timedelta(days=1)).strftime("%Y/%m/%d")
288
+ for i in response:
289
+ if all(k in i for k in GET_SUPPLIES_MANDATORY_FIELDS):
290
+ supplies.append(
291
+ SupplyData(
292
+ cups=i["cups"],
293
+ date_start=datetime.strptime(
294
+ (
295
+ i["validDateFrom"]
296
+ if i["validDateFrom"] != ""
297
+ else "1970/01/01"
298
+ ),
299
+ "%Y/%m/%d",
300
+ ),
301
+ date_end=datetime.strptime(
302
+ (
303
+ i["validDateTo"]
304
+ if i["validDateTo"] != ""
305
+ else tomorrow_str
306
+ ),
307
+ "%Y/%m/%d",
308
+ ),
309
+ address=i.get("address", None),
310
+ postal_code=i.get("postalCode", None),
311
+ province=i.get("province", None),
312
+ municipality=i.get("municipality", None),
313
+ distributor=i.get("distributor", None),
314
+ pointType=i["pointType"],
315
+ distributorCode=i["distributorCode"],
316
+ )
317
+ )
318
+ else:
319
+ _LOGGER.warning(
320
+ "Weird data structure while fetching supplies data, got %s",
321
+ response,
322
+ )
323
+ return supplies
324
+
325
+ def get_supplies(self, authorized_nif: str | None = None):
326
+ """Datadis 'get_supplies' query (sync wrapper)."""
327
+ return asyncio.run(self.async_get_supplies(authorized_nif=authorized_nif))
328
+
329
+
330
+ async def async_get_contract_detail(
331
+ self, cups: str, distributor_code: str, authorized_nif: str | None = None
332
+ ):
333
+ data = {"cups": cups, "distributorCode": distributor_code}
334
+ if authorized_nif is not None:
335
+ data["authorizedNif"] = authorized_nif
336
+ response = await self._async_get(
337
+ URL_GET_CONTRACT_DETAIL, request_data=data, ignore_recent_queries=True
338
+ )
339
+ contracts = []
340
+ tomorrow_str = (datetime.today() + timedelta(days=1)).strftime("%Y/%m/%d")
341
+ for i in response:
342
+ if all(k in i for k in GET_CONTRACT_DETAIL_MANDATORY_FIELDS):
343
+ contracts.append(
344
+ ContractData(
345
+ date_start=datetime.strptime(
346
+ i["startDate"] if i["startDate"] != "" else "1970/01/01",
347
+ "%Y/%m/%d",
348
+ ),
349
+ date_end=datetime.strptime(
350
+ i["endDate"] if i["endDate"] != "" else tomorrow_str,
351
+ "%Y/%m/%d",
352
+ ),
353
+ marketer=i["marketer"],
354
+ distributorCode=distributor_code,
355
+ power_p1=(
356
+ i["contractedPowerkW"][0]
357
+ if isinstance(i["contractedPowerkW"], list)
358
+ else None
359
+ ),
360
+ power_p2=(
361
+ i["contractedPowerkW"][1]
362
+ if (len(i["contractedPowerkW"]) > 1)
363
+ else None
364
+ ),
365
+ )
366
+ )
367
+ else:
368
+ _LOGGER.warning(
369
+ "Weird data structure while fetching contracts data, got %s",
370
+ response,
371
+ )
372
+ return contracts
373
+
374
+ def get_contract_detail(
375
+ self, cups: str, distributor_code: str, authorized_nif: str | None = None
376
+ ):
377
+ """Datadis get_contract_detail query (sync wrapper)."""
378
+ return asyncio.run(self.async_get_contract_detail(cups, distributor_code, authorized_nif))
379
+
380
+
381
+ async def async_get_consumption_data(
382
+ self,
383
+ cups: str,
384
+ distributor_code: str,
385
+ start_date: datetime,
386
+ end_date: datetime,
387
+ measurement_type: str,
388
+ point_type: int,
389
+ authorized_nif: str | None = None,
390
+ is_smart_fetch: bool = False,
391
+ ):
392
+ if self._smart_fetch and not is_smart_fetch:
393
+ _start = start_date
394
+ consumptions = []
395
+ while _start < end_date:
396
+ _end = min(
397
+ _start + relativedelta(months=MAX_CONSUMPTIONS_MONTHS), end_date
398
+ )
399
+ sub_consumptions = await self.async_get_consumption_data(
400
+ cups,
401
+ distributor_code,
402
+ _start,
403
+ _end,
404
+ measurement_type,
405
+ point_type,
406
+ authorized_nif,
407
+ is_smart_fetch=True,
408
+ )
409
+ consumptions = utils.extend_by_key(
410
+ consumptions,
411
+ sub_consumptions,
412
+ "datetime",
413
+ )
414
+ _start = _end
415
+ return consumptions
416
+
417
+ data = {
418
+ "cups": cups,
419
+ "distributorCode": distributor_code,
420
+ "startDate": datetime.strftime(start_date, "%Y/%m"),
421
+ "endDate": datetime.strftime(end_date, "%Y/%m"),
422
+ "measurementType": measurement_type,
423
+ "pointType": point_type,
424
+ }
425
+ if authorized_nif is not None:
426
+ data["authorizedNif"] = authorized_nif
427
+
428
+ response = await self._async_get(URL_GET_CONSUMPTION_DATA, request_data=data)
429
+
430
+ consumptions = []
431
+ for i in response:
432
+ if "consumptionKWh" in i:
433
+ if all(k in i for k in GET_CONSUMPTION_DATA_MANDATORY_FIELDS):
434
+ hour = str(int(i["time"].split(":")[0]) - 1)
435
+ date_as_dt = datetime.strptime(
436
+ f"{i['date']} {hour.zfill(2)}:00", "%Y/%m/%d %H:%M"
437
+ )
438
+ if not (start_date <= date_as_dt <= end_date):
439
+ continue # skip element if dt is out of range
440
+ _surplus = i.get("surplusEnergyKWh", 0)
441
+ if _surplus is None:
442
+ _surplus = 0
443
+ consumptions.append(
444
+ ConsumptionData(
445
+ datetime=date_as_dt,
446
+ delta_h=1,
447
+ value_kWh=i["consumptionKWh"],
448
+ surplus_kWh=_surplus,
449
+ real=i["obtainMethod"] == "Real",
450
+ )
451
+ )
452
+ else:
453
+ _LOGGER.warning(
454
+ "Weird data structure while fetching consumption data, got %s",
455
+ response,
456
+ )
457
+ return consumptions
458
+
459
+ def get_consumption_data(
460
+ self,
461
+ cups: str,
462
+ distributor_code: str,
463
+ start_date: datetime,
464
+ end_date: datetime,
465
+ measurement_type: str,
466
+ point_type: int,
467
+ authorized_nif: str | None = None,
468
+ is_smart_fetch: bool = False,
469
+ ):
470
+ """Datadis get_consumption_data query (sync wrapper)."""
471
+ return asyncio.run(self.async_get_consumption_data(
472
+ cups,
473
+ distributor_code,
474
+ start_date,
475
+ end_date,
476
+ measurement_type,
477
+ point_type,
478
+ authorized_nif,
479
+ is_smart_fetch,
480
+ ))
481
+
482
+
483
+ async def async_get_max_power(
484
+ self,
485
+ cups: str,
486
+ distributor_code: str,
487
+ start_date: datetime,
488
+ end_date: datetime,
489
+ authorized_nif: str | None = None,
490
+ ):
491
+ data = {
492
+ "cups": cups,
493
+ "distributorCode": distributor_code,
494
+ "startDate": datetime.strftime(start_date, "%Y/%m"),
495
+ "endDate": datetime.strftime(end_date, "%Y/%m"),
496
+ }
497
+ if authorized_nif is not None:
498
+ data["authorizedNif"] = authorized_nif
499
+ response = await self._async_get(URL_GET_MAX_POWER, request_data=data)
500
+ maxpower_values = []
501
+ for i in response:
502
+ if all(k in i for k in GET_MAX_POWER_MANDATORY_FIELDS):
503
+ maxpower_values.append(
504
+ MaxPowerData(
505
+ datetime=datetime.strptime(
506
+ f"{i['date']} {i['time']}", "%Y/%m/%d %H:%M"
507
+ ),
508
+ value_kW=i["maxPower"],
509
+ )
510
+ )
511
+ else:
512
+ _LOGGER.warning(
513
+ "Weird data structure while fetching maximeter data, got %s",
514
+ response,
515
+ )
516
+ return maxpower_values
517
+
518
+ def get_max_power(
519
+ self,
520
+ cups: str,
521
+ distributor_code: str,
522
+ start_date: datetime,
523
+ end_date: datetime,
524
+ authorized_nif: str | None = None,
525
+ ):
526
+ """Datadis get_max_power query (sync wrapper)."""
527
+ return asyncio.run(self.async_get_max_power(
528
+ cups,
529
+ distributor_code,
530
+ start_date,
531
+ end_date,
532
+ authorized_nif,
533
+ ))
connectors/redata.py ADDED
@@ -0,0 +1,81 @@
1
+ """A REData API connector"""
2
+
3
+ import datetime as dt
4
+ import logging
5
+
6
+ import aiohttp
7
+ import asyncio
8
+ from dateutil import parser
9
+
10
+ from ..definitions 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 async_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)"""
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
+ timeout = aiohttp.ClientTimeout(total=REQUESTS_TIMEOUT)
43
+ async with aiohttp.ClientSession(timeout=timeout) as session:
44
+ try:
45
+ async with session.get(url) as res:
46
+ text = await res.text()
47
+ if res.status == 200:
48
+ try:
49
+ res_json = await res.json()
50
+ res_list = res_json["included"][0]["attributes"]["values"]
51
+ except (IndexError, KeyError):
52
+ _LOGGER.error(
53
+ "%s returned a malformed response: %s ",
54
+ url,
55
+ text,
56
+ )
57
+ return data
58
+ for element in res_list:
59
+ data.append(
60
+ PricingData(
61
+ datetime=parser.parse(element["datetime"]).replace(tzinfo=None),
62
+ value_eur_kWh=element["value"] / 1000,
63
+ delta_h=1,
64
+ )
65
+ )
66
+ else:
67
+ _LOGGER.error(
68
+ "%s returned %s with code %s",
69
+ url,
70
+ text,
71
+ res.status,
72
+ )
73
+ except Exception as e:
74
+ _LOGGER.error("Exception fetching realtime prices: %s", e)
75
+ return data
76
+
77
+ def get_realtime_prices(
78
+ self, dt_from: dt.datetime, dt_to: dt.datetime, is_ceuta_melilla: bool = False
79
+ ) -> list:
80
+ """GET query to fetch realtime pvpc prices, historical data is limited to current month (sync wrapper)"""
81
+ return asyncio.run(self.async_get_realtime_prices(dt_from, dt_to, is_ceuta_melilla))