arthexis 0.1.23__py3-none-any.whl → 0.1.25__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.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/METADATA +39 -18
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/RECORD +31 -30
- config/settings.py +7 -0
- config/urls.py +2 -0
- core/admin.py +140 -213
- core/backends.py +3 -1
- core/models.py +612 -207
- core/system.py +67 -2
- core/tasks.py +25 -0
- core/views.py +0 -3
- nodes/admin.py +465 -292
- nodes/models.py +299 -23
- nodes/tasks.py +13 -16
- nodes/tests.py +291 -130
- nodes/urls.py +11 -0
- nodes/utils.py +9 -2
- nodes/views.py +588 -20
- ocpp/admin.py +729 -175
- ocpp/consumers.py +98 -0
- ocpp/models.py +299 -0
- ocpp/network.py +398 -0
- ocpp/tasks.py +177 -1
- ocpp/tests.py +179 -0
- ocpp/views.py +2 -0
- pages/middleware.py +3 -2
- pages/tests.py +40 -0
- pages/utils.py +70 -0
- pages/views.py +64 -32
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/WHEEL +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/top_level.txt +0 -0
ocpp/network.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from decimal import Decimal, InvalidOperation
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
from django.utils import timezone
|
|
9
|
+
from django.utils.dateparse import parse_datetime
|
|
10
|
+
|
|
11
|
+
from .models import Charger, Location, MeterValue, Transaction
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_remote_datetime(value) -> datetime | None:
|
|
15
|
+
if not value:
|
|
16
|
+
return None
|
|
17
|
+
if isinstance(value, datetime):
|
|
18
|
+
timestamp = value
|
|
19
|
+
else:
|
|
20
|
+
timestamp = parse_datetime(str(value))
|
|
21
|
+
if not timestamp:
|
|
22
|
+
return None
|
|
23
|
+
if timezone.is_naive(timestamp):
|
|
24
|
+
timestamp = timezone.make_aware(timestamp, timezone.get_current_timezone())
|
|
25
|
+
return timestamp
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _to_decimal(value) -> Decimal | None:
|
|
29
|
+
if value in (None, ""):
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
return Decimal(str(value))
|
|
33
|
+
except (InvalidOperation, TypeError, ValueError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serialize_charger_for_network(charger: Charger) -> dict[str, object]:
|
|
38
|
+
simple_fields = [
|
|
39
|
+
"display_name",
|
|
40
|
+
"language",
|
|
41
|
+
"public_display",
|
|
42
|
+
"require_rfid",
|
|
43
|
+
"firmware_status",
|
|
44
|
+
"firmware_status_info",
|
|
45
|
+
"last_status",
|
|
46
|
+
"last_error_code",
|
|
47
|
+
"last_status_vendor_info",
|
|
48
|
+
"availability_state",
|
|
49
|
+
"availability_requested_state",
|
|
50
|
+
"availability_request_status",
|
|
51
|
+
"availability_request_details",
|
|
52
|
+
"temperature",
|
|
53
|
+
"temperature_unit",
|
|
54
|
+
"diagnostics_status",
|
|
55
|
+
"diagnostics_location",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
datetime_fields = [
|
|
59
|
+
"firmware_timestamp",
|
|
60
|
+
"last_heartbeat",
|
|
61
|
+
"availability_state_updated_at",
|
|
62
|
+
"availability_requested_at",
|
|
63
|
+
"availability_request_status_at",
|
|
64
|
+
"diagnostics_timestamp",
|
|
65
|
+
"last_status_timestamp",
|
|
66
|
+
"last_online_at",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
data: dict[str, object] = {
|
|
70
|
+
"charger_id": charger.charger_id,
|
|
71
|
+
"connector_id": charger.connector_id,
|
|
72
|
+
"allow_remote": charger.allow_remote,
|
|
73
|
+
"export_transactions": charger.export_transactions,
|
|
74
|
+
"last_meter_values": charger.last_meter_values or {},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for field in simple_fields:
|
|
78
|
+
data[field] = getattr(charger, field)
|
|
79
|
+
|
|
80
|
+
for field in datetime_fields:
|
|
81
|
+
value = getattr(charger, field)
|
|
82
|
+
data[field] = value.isoformat() if value else None
|
|
83
|
+
|
|
84
|
+
if charger.location:
|
|
85
|
+
location = charger.location
|
|
86
|
+
data["location"] = {
|
|
87
|
+
"name": location.name,
|
|
88
|
+
"latitude": location.latitude,
|
|
89
|
+
"longitude": location.longitude,
|
|
90
|
+
"zone": location.zone,
|
|
91
|
+
"contract_type": location.contract_type,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return data
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def apply_remote_charger_payload(
|
|
98
|
+
node,
|
|
99
|
+
payload: Mapping,
|
|
100
|
+
*,
|
|
101
|
+
create_missing: bool = True,
|
|
102
|
+
) -> Charger | None:
|
|
103
|
+
serial = Charger.normalize_serial(payload.get("charger_id"))
|
|
104
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
connector = payload.get("connector_id")
|
|
108
|
+
if connector in (None, ""):
|
|
109
|
+
connector_value = None
|
|
110
|
+
elif isinstance(connector, int):
|
|
111
|
+
connector_value = connector
|
|
112
|
+
else:
|
|
113
|
+
try:
|
|
114
|
+
connector_value = int(str(connector))
|
|
115
|
+
except (TypeError, ValueError):
|
|
116
|
+
connector_value = None
|
|
117
|
+
|
|
118
|
+
charger = Charger.objects.filter(
|
|
119
|
+
charger_id=serial, connector_id=connector_value
|
|
120
|
+
).select_related("location").first()
|
|
121
|
+
|
|
122
|
+
if charger is None and not create_missing:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
if charger is None:
|
|
126
|
+
charger = Charger.objects.create(
|
|
127
|
+
charger_id=serial,
|
|
128
|
+
connector_id=connector_value,
|
|
129
|
+
node_origin=node,
|
|
130
|
+
forwarded_to=None,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
location_obj = None
|
|
134
|
+
location_payload = payload.get("location")
|
|
135
|
+
if isinstance(location_payload, Mapping):
|
|
136
|
+
name = location_payload.get("name")
|
|
137
|
+
if name:
|
|
138
|
+
location_obj, _ = Location.objects.get_or_create(name=name)
|
|
139
|
+
for field in ("latitude", "longitude", "zone", "contract_type"):
|
|
140
|
+
setattr(location_obj, field, location_payload.get(field))
|
|
141
|
+
location_obj.save()
|
|
142
|
+
|
|
143
|
+
datetime_fields = [
|
|
144
|
+
"firmware_timestamp",
|
|
145
|
+
"last_heartbeat",
|
|
146
|
+
"availability_state_updated_at",
|
|
147
|
+
"availability_requested_at",
|
|
148
|
+
"availability_request_status_at",
|
|
149
|
+
"diagnostics_timestamp",
|
|
150
|
+
"last_status_timestamp",
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
updates: dict[str, object] = {
|
|
154
|
+
"node_origin": node,
|
|
155
|
+
"allow_remote": bool(payload.get("allow_remote", False)),
|
|
156
|
+
"export_transactions": bool(payload.get("export_transactions", False)),
|
|
157
|
+
"last_online_at": timezone.now(),
|
|
158
|
+
"forwarded_to": None,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
simple_fields = [
|
|
162
|
+
"display_name",
|
|
163
|
+
"language",
|
|
164
|
+
"public_display",
|
|
165
|
+
"require_rfid",
|
|
166
|
+
"firmware_status",
|
|
167
|
+
"firmware_status_info",
|
|
168
|
+
"last_status",
|
|
169
|
+
"last_error_code",
|
|
170
|
+
"last_status_vendor_info",
|
|
171
|
+
"availability_state",
|
|
172
|
+
"availability_requested_state",
|
|
173
|
+
"availability_request_status",
|
|
174
|
+
"availability_request_details",
|
|
175
|
+
"temperature",
|
|
176
|
+
"temperature_unit",
|
|
177
|
+
"diagnostics_status",
|
|
178
|
+
"diagnostics_location",
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
for field in simple_fields:
|
|
182
|
+
if field in payload:
|
|
183
|
+
value = payload.get(field)
|
|
184
|
+
if field in {"require_rfid", "public_display"} and value is None:
|
|
185
|
+
value = False
|
|
186
|
+
updates[field] = value
|
|
187
|
+
else:
|
|
188
|
+
updates[field] = getattr(charger, field)
|
|
189
|
+
|
|
190
|
+
for field in datetime_fields:
|
|
191
|
+
updates[field] = _parse_remote_datetime(payload.get(field))
|
|
192
|
+
|
|
193
|
+
updates["last_meter_values"] = payload.get("last_meter_values") or {}
|
|
194
|
+
|
|
195
|
+
if location_obj is not None:
|
|
196
|
+
updates["location"] = location_obj
|
|
197
|
+
|
|
198
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
199
|
+
charger.refresh_from_db()
|
|
200
|
+
return charger
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _normalize_connector(value) -> int | None:
|
|
204
|
+
if value in (None, ""):
|
|
205
|
+
return None
|
|
206
|
+
if isinstance(value, int):
|
|
207
|
+
return value
|
|
208
|
+
try:
|
|
209
|
+
return int(str(value))
|
|
210
|
+
except (TypeError, ValueError):
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def sync_transactions_payload(payload: Mapping) -> int:
|
|
215
|
+
if not isinstance(payload, Mapping):
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
chargers_map: dict[tuple[str, int | None], Charger] = {}
|
|
219
|
+
charger_entries = payload.get("chargers", [])
|
|
220
|
+
if isinstance(charger_entries, Iterable):
|
|
221
|
+
for entry in charger_entries:
|
|
222
|
+
if not isinstance(entry, Mapping):
|
|
223
|
+
continue
|
|
224
|
+
serial = Charger.normalize_serial(entry.get("charger_id"))
|
|
225
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
226
|
+
continue
|
|
227
|
+
connector_value = _normalize_connector(entry.get("connector_id"))
|
|
228
|
+
charger = Charger.objects.filter(
|
|
229
|
+
charger_id=serial, connector_id=connector_value
|
|
230
|
+
).first()
|
|
231
|
+
if charger:
|
|
232
|
+
chargers_map[(serial, connector_value)] = charger
|
|
233
|
+
|
|
234
|
+
imported = 0
|
|
235
|
+
transaction_entries = payload.get("transactions", [])
|
|
236
|
+
if not isinstance(transaction_entries, Iterable):
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
for tx in transaction_entries:
|
|
240
|
+
if not isinstance(tx, Mapping):
|
|
241
|
+
continue
|
|
242
|
+
serial = Charger.normalize_serial(tx.get("charger"))
|
|
243
|
+
if not serial:
|
|
244
|
+
continue
|
|
245
|
+
connector_value = _normalize_connector(tx.get("connector_id"))
|
|
246
|
+
|
|
247
|
+
charger = chargers_map.get((serial, connector_value))
|
|
248
|
+
if charger is None:
|
|
249
|
+
charger = chargers_map.get((serial, None))
|
|
250
|
+
if charger is None:
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
start_time = _parse_remote_datetime(tx.get("start_time"))
|
|
254
|
+
if start_time is None:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
defaults = {
|
|
258
|
+
"connector_id": connector_value,
|
|
259
|
+
"account_id": tx.get("account"),
|
|
260
|
+
"rfid": tx.get("rfid", ""),
|
|
261
|
+
"vid": tx.get("vid", ""),
|
|
262
|
+
"vin": tx.get("vin", ""),
|
|
263
|
+
"meter_start": tx.get("meter_start"),
|
|
264
|
+
"meter_stop": tx.get("meter_stop"),
|
|
265
|
+
"voltage_start": _to_decimal(tx.get("voltage_start")),
|
|
266
|
+
"voltage_stop": _to_decimal(tx.get("voltage_stop")),
|
|
267
|
+
"current_import_start": _to_decimal(tx.get("current_import_start")),
|
|
268
|
+
"current_import_stop": _to_decimal(tx.get("current_import_stop")),
|
|
269
|
+
"current_offered_start": _to_decimal(tx.get("current_offered_start")),
|
|
270
|
+
"current_offered_stop": _to_decimal(tx.get("current_offered_stop")),
|
|
271
|
+
"temperature_start": _to_decimal(tx.get("temperature_start")),
|
|
272
|
+
"temperature_stop": _to_decimal(tx.get("temperature_stop")),
|
|
273
|
+
"soc_start": _to_decimal(tx.get("soc_start")),
|
|
274
|
+
"soc_stop": _to_decimal(tx.get("soc_stop")),
|
|
275
|
+
"stop_time": _parse_remote_datetime(tx.get("stop_time")),
|
|
276
|
+
"received_start_time": _parse_remote_datetime(tx.get("received_start_time")),
|
|
277
|
+
"received_stop_time": _parse_remote_datetime(tx.get("received_stop_time")),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
transaction, created = Transaction.objects.update_or_create(
|
|
281
|
+
charger=charger,
|
|
282
|
+
start_time=start_time,
|
|
283
|
+
defaults=defaults,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if not created:
|
|
287
|
+
transaction.meter_values.all().delete()
|
|
288
|
+
|
|
289
|
+
meter_values = tx.get("meter_values", [])
|
|
290
|
+
if isinstance(meter_values, Iterable):
|
|
291
|
+
for mv in meter_values:
|
|
292
|
+
if not isinstance(mv, Mapping):
|
|
293
|
+
continue
|
|
294
|
+
timestamp = _parse_remote_datetime(mv.get("timestamp"))
|
|
295
|
+
if timestamp is None:
|
|
296
|
+
continue
|
|
297
|
+
connector_mv = _normalize_connector(mv.get("connector_id"))
|
|
298
|
+
MeterValue.objects.create(
|
|
299
|
+
charger=charger,
|
|
300
|
+
transaction=transaction,
|
|
301
|
+
connector_id=connector_mv,
|
|
302
|
+
timestamp=timestamp,
|
|
303
|
+
context=mv.get("context", ""),
|
|
304
|
+
energy=_to_decimal(mv.get("energy")),
|
|
305
|
+
voltage=_to_decimal(mv.get("voltage")),
|
|
306
|
+
current_import=_to_decimal(mv.get("current_import")),
|
|
307
|
+
current_offered=_to_decimal(mv.get("current_offered")),
|
|
308
|
+
temperature=_to_decimal(mv.get("temperature")),
|
|
309
|
+
soc=_to_decimal(mv.get("soc")),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
imported += 1
|
|
313
|
+
|
|
314
|
+
return imported
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def serialize_transactions_for_forwarding(
|
|
318
|
+
transactions: Iterable[Transaction],
|
|
319
|
+
) -> list[dict[str, object]]:
|
|
320
|
+
serialized: list[dict[str, object]] = []
|
|
321
|
+
for tx in transactions:
|
|
322
|
+
serialized.append(
|
|
323
|
+
{
|
|
324
|
+
"charger": tx.charger.charger_id if tx.charger else None,
|
|
325
|
+
"connector_id": tx.connector_id,
|
|
326
|
+
"account": tx.account_id,
|
|
327
|
+
"rfid": tx.rfid,
|
|
328
|
+
"vid": tx.vehicle_identifier,
|
|
329
|
+
"vin": tx.vin,
|
|
330
|
+
"meter_start": tx.meter_start,
|
|
331
|
+
"meter_stop": tx.meter_stop,
|
|
332
|
+
"voltage_start": str(tx.voltage_start)
|
|
333
|
+
if tx.voltage_start is not None
|
|
334
|
+
else None,
|
|
335
|
+
"voltage_stop": str(tx.voltage_stop)
|
|
336
|
+
if tx.voltage_stop is not None
|
|
337
|
+
else None,
|
|
338
|
+
"current_import_start": str(tx.current_import_start)
|
|
339
|
+
if tx.current_import_start is not None
|
|
340
|
+
else None,
|
|
341
|
+
"current_import_stop": str(tx.current_import_stop)
|
|
342
|
+
if tx.current_import_stop is not None
|
|
343
|
+
else None,
|
|
344
|
+
"current_offered_start": str(tx.current_offered_start)
|
|
345
|
+
if tx.current_offered_start is not None
|
|
346
|
+
else None,
|
|
347
|
+
"current_offered_stop": str(tx.current_offered_stop)
|
|
348
|
+
if tx.current_offered_stop is not None
|
|
349
|
+
else None,
|
|
350
|
+
"temperature_start": str(tx.temperature_start)
|
|
351
|
+
if tx.temperature_start is not None
|
|
352
|
+
else None,
|
|
353
|
+
"temperature_stop": str(tx.temperature_stop)
|
|
354
|
+
if tx.temperature_stop is not None
|
|
355
|
+
else None,
|
|
356
|
+
"soc_start": str(tx.soc_start) if tx.soc_start is not None else None,
|
|
357
|
+
"soc_stop": str(tx.soc_stop) if tx.soc_stop is not None else None,
|
|
358
|
+
"start_time": tx.start_time.isoformat() if tx.start_time else None,
|
|
359
|
+
"stop_time": tx.stop_time.isoformat() if tx.stop_time else None,
|
|
360
|
+
"received_start_time": tx.received_start_time.isoformat()
|
|
361
|
+
if tx.received_start_time
|
|
362
|
+
else None,
|
|
363
|
+
"received_stop_time": tx.received_stop_time.isoformat()
|
|
364
|
+
if tx.received_stop_time
|
|
365
|
+
else None,
|
|
366
|
+
"meter_values": [
|
|
367
|
+
{
|
|
368
|
+
"connector_id": mv.connector_id,
|
|
369
|
+
"timestamp": mv.timestamp.isoformat(),
|
|
370
|
+
"context": mv.context,
|
|
371
|
+
"energy": str(mv.energy) if mv.energy is not None else None,
|
|
372
|
+
"voltage": str(mv.voltage) if mv.voltage is not None else None,
|
|
373
|
+
"current_import": str(mv.current_import)
|
|
374
|
+
if mv.current_import is not None
|
|
375
|
+
else None,
|
|
376
|
+
"current_offered": str(mv.current_offered)
|
|
377
|
+
if mv.current_offered is not None
|
|
378
|
+
else None,
|
|
379
|
+
"temperature": str(mv.temperature)
|
|
380
|
+
if mv.temperature is not None
|
|
381
|
+
else None,
|
|
382
|
+
"soc": str(mv.soc) if mv.soc is not None else None,
|
|
383
|
+
}
|
|
384
|
+
for mv in tx.meter_values.all()
|
|
385
|
+
],
|
|
386
|
+
}
|
|
387
|
+
)
|
|
388
|
+
return serialized
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def newest_transaction_timestamp(transactions: Iterable[Transaction]) -> datetime | None:
|
|
392
|
+
latest: datetime | None = None
|
|
393
|
+
for tx in transactions:
|
|
394
|
+
if tx.start_time and (
|
|
395
|
+
latest is None or tx.start_time > latest
|
|
396
|
+
):
|
|
397
|
+
latest = tx.start_time
|
|
398
|
+
return latest
|
ocpp/tasks.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
import json
|
|
2
3
|
import logging
|
|
3
4
|
import uuid
|
|
@@ -8,18 +9,41 @@ from asgiref.sync import async_to_sync
|
|
|
8
9
|
from celery import shared_task
|
|
9
10
|
from django.conf import settings
|
|
10
11
|
from django.contrib.auth import get_user_model
|
|
11
|
-
from django.utils import timezone
|
|
12
12
|
from django.db.models import Q
|
|
13
|
+
from django.utils import timezone
|
|
14
|
+
import requests
|
|
15
|
+
from requests import RequestException
|
|
16
|
+
from cryptography.hazmat.primitives import hashes
|
|
17
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
13
18
|
|
|
14
19
|
from core import mailer
|
|
15
20
|
from nodes.models import Node
|
|
16
21
|
|
|
17
22
|
from . import store
|
|
18
23
|
from .models import Charger, MeterValue, Transaction
|
|
24
|
+
from .network import (
|
|
25
|
+
newest_transaction_timestamp,
|
|
26
|
+
serialize_charger_for_network,
|
|
27
|
+
serialize_transactions_for_forwarding,
|
|
28
|
+
)
|
|
19
29
|
|
|
20
30
|
logger = logging.getLogger(__name__)
|
|
21
31
|
|
|
22
32
|
|
|
33
|
+
def _sign_payload(payload_json: str, private_key) -> str | None:
|
|
34
|
+
if not private_key:
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
signature = private_key.sign(
|
|
38
|
+
payload_json.encode(),
|
|
39
|
+
padding.PKCS1v15(),
|
|
40
|
+
hashes.SHA256(),
|
|
41
|
+
)
|
|
42
|
+
except Exception:
|
|
43
|
+
return None
|
|
44
|
+
return base64.b64encode(signature).decode()
|
|
45
|
+
|
|
46
|
+
|
|
23
47
|
@shared_task
|
|
24
48
|
def check_charge_point_configuration(charger_pk: int) -> bool:
|
|
25
49
|
"""Request the latest configuration from a connected charge point."""
|
|
@@ -135,6 +159,158 @@ def purge_meter_values() -> int:
|
|
|
135
159
|
purge_meter_readings = purge_meter_values
|
|
136
160
|
|
|
137
161
|
|
|
162
|
+
@shared_task
|
|
163
|
+
def push_forwarded_charge_points() -> int:
|
|
164
|
+
"""Push local charge point sessions to configured upstream nodes."""
|
|
165
|
+
|
|
166
|
+
local = Node.get_local()
|
|
167
|
+
if not local:
|
|
168
|
+
logger.debug("Forwarding skipped: local node not registered")
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
private_key = local.get_private_key()
|
|
172
|
+
if private_key is None:
|
|
173
|
+
logger.warning("Forwarding skipped: missing local node private key")
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
chargers_qs = (
|
|
177
|
+
Charger.objects.filter(export_transactions=True, forwarded_to__isnull=False)
|
|
178
|
+
.select_related("forwarded_to", "node_origin")
|
|
179
|
+
.order_by("pk")
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
node_filter = Q(node_origin__isnull=True)
|
|
183
|
+
if local.pk:
|
|
184
|
+
node_filter |= Q(node_origin=local)
|
|
185
|
+
|
|
186
|
+
chargers = list(chargers_qs.filter(node_filter))
|
|
187
|
+
if not chargers:
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
grouped: dict[Node, list[Charger]] = {}
|
|
191
|
+
for charger in chargers:
|
|
192
|
+
target = charger.forwarded_to
|
|
193
|
+
if not target:
|
|
194
|
+
continue
|
|
195
|
+
if local.pk and target.pk == local.pk:
|
|
196
|
+
continue
|
|
197
|
+
grouped.setdefault(target, []).append(charger)
|
|
198
|
+
|
|
199
|
+
if not grouped:
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
forwarded_total = 0
|
|
203
|
+
|
|
204
|
+
for node, node_chargers in grouped.items():
|
|
205
|
+
if not node_chargers:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
initializing = [ch for ch in node_chargers if ch.forwarding_watermark is None]
|
|
209
|
+
charger_by_pk = {ch.pk: ch for ch in node_chargers}
|
|
210
|
+
transactions_map: dict[int, list[Transaction]] = {}
|
|
211
|
+
|
|
212
|
+
for charger in node_chargers:
|
|
213
|
+
watermark = charger.forwarding_watermark
|
|
214
|
+
if watermark is None:
|
|
215
|
+
continue
|
|
216
|
+
tx_queryset = (
|
|
217
|
+
Transaction.objects.filter(charger=charger, start_time__gt=watermark)
|
|
218
|
+
.select_related("charger")
|
|
219
|
+
.prefetch_related("meter_values")
|
|
220
|
+
.order_by("start_time")
|
|
221
|
+
)
|
|
222
|
+
txs = list(tx_queryset)
|
|
223
|
+
if txs:
|
|
224
|
+
transactions_map[charger.pk] = txs
|
|
225
|
+
|
|
226
|
+
transaction_payload = {"chargers": [], "transactions": []}
|
|
227
|
+
for charger_pk, txs in transactions_map.items():
|
|
228
|
+
charger = charger_by_pk[charger_pk]
|
|
229
|
+
transaction_payload["chargers"].append(
|
|
230
|
+
{
|
|
231
|
+
"charger_id": charger.charger_id,
|
|
232
|
+
"connector_id": charger.connector_id,
|
|
233
|
+
"require_rfid": charger.require_rfid,
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
transaction_payload["transactions"].extend(
|
|
237
|
+
serialize_transactions_for_forwarding(txs)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
payload = {
|
|
241
|
+
"requester": str(local.uuid),
|
|
242
|
+
"requester_mac": local.mac_address,
|
|
243
|
+
"requester_public_key": local.public_key,
|
|
244
|
+
"chargers": [serialize_charger_for_network(ch) for ch in initializing],
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
has_transactions = bool(transaction_payload["transactions"])
|
|
248
|
+
if has_transactions or payload["chargers"]:
|
|
249
|
+
payload["transactions"] = transaction_payload
|
|
250
|
+
else:
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
254
|
+
signature = _sign_payload(payload_json, private_key)
|
|
255
|
+
headers = {"Content-Type": "application/json"}
|
|
256
|
+
if signature:
|
|
257
|
+
headers["X-Signature"] = signature
|
|
258
|
+
|
|
259
|
+
url = next(node.iter_remote_urls("/nodes/network/chargers/forward/"), "")
|
|
260
|
+
if not url:
|
|
261
|
+
logger.warning(
|
|
262
|
+
"No reachable host found for %s when forwarding chargers", node
|
|
263
|
+
)
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
response = requests.post(url, data=payload_json, headers=headers, timeout=5)
|
|
268
|
+
except RequestException as exc:
|
|
269
|
+
logger.warning("Failed to forward chargers to %s: %s", node, exc)
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
if not response.ok:
|
|
273
|
+
logger.warning(
|
|
274
|
+
"Forwarding request to %s returned %s", node, response.status_code
|
|
275
|
+
)
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
data = response.json()
|
|
280
|
+
except ValueError:
|
|
281
|
+
logger.warning("Invalid JSON payload received from %s", node)
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
if data.get("status") != "ok":
|
|
285
|
+
detail = data.get("detail") if isinstance(data, dict) else None
|
|
286
|
+
logger.warning(
|
|
287
|
+
"Forwarding rejected by %s: %s",
|
|
288
|
+
node,
|
|
289
|
+
detail or response.text or "Remote node rejected the request.",
|
|
290
|
+
)
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
updates: dict[int, datetime] = {}
|
|
294
|
+
now = timezone.now()
|
|
295
|
+
for charger in initializing:
|
|
296
|
+
updates[charger.pk] = now
|
|
297
|
+
for charger_pk, txs in transactions_map.items():
|
|
298
|
+
latest = newest_transaction_timestamp(txs)
|
|
299
|
+
if latest:
|
|
300
|
+
updates[charger_pk] = latest
|
|
301
|
+
|
|
302
|
+
for pk, timestamp in updates.items():
|
|
303
|
+
Charger.objects.filter(pk=pk).update(forwarding_watermark=timestamp)
|
|
304
|
+
|
|
305
|
+
forwarded_total += len(transaction_payload["transactions"])
|
|
306
|
+
|
|
307
|
+
return forwarded_total
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# Backwards compatibility alias for legacy schedules
|
|
311
|
+
sync_remote_chargers = push_forwarded_charge_points
|
|
312
|
+
|
|
313
|
+
|
|
138
314
|
def _resolve_report_window() -> tuple[datetime, datetime, date]:
|
|
139
315
|
"""Return the start/end datetimes for today's reporting window."""
|
|
140
316
|
|