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.

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