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 +0 -0
- connectors/datadis.py +533 -0
- connectors/redata.py +81 -0
- e_data-1.3.1.dist-info/METADATA +808 -0
- e_data-1.3.1.dist-info/RECORD +19 -0
- e_data-1.3.1.dist-info/WHEEL +5 -0
- e_data-1.3.1.dist-info/licenses/LICENSE +674 -0
- e_data-1.3.1.dist-info/top_level.txt +3 -0
- processors/__init__.py +0 -0
- processors/base.py +27 -0
- processors/billing.py +215 -0
- processors/consumption.py +139 -0
- processors/maximeter.py +56 -0
- processors/utils.py +148 -0
- tests/__init__.py +0 -0
- tests/test_datadis_connector.py +242 -0
- tests/test_helpers.py +52 -0
- tests/test_processors.py +106 -0
- tests/test_redata_connector.py +14 -0
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))
|