arthexis 0.1.24__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.24.dist-info → arthexis-0.1.25.dist-info}/METADATA +35 -14
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/RECORD +30 -29
- config/settings.py +6 -3
- config/urls.py +2 -0
- core/admin.py +1 -186
- core/backends.py +3 -1
- core/models.py +74 -8
- core/system.py +67 -2
- core/views.py +0 -3
- nodes/admin.py +444 -251
- nodes/models.py +299 -23
- nodes/tasks.py +13 -16
- nodes/tests.py +211 -1
- nodes/urls.py +5 -0
- nodes/utils.py +9 -2
- nodes/views.py +128 -80
- ocpp/admin.py +190 -2
- ocpp/consumers.py +98 -0
- ocpp/models.py +271 -0
- ocpp/network.py +398 -0
- ocpp/tasks.py +108 -267
- 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 +4 -2
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/WHEEL +0 -0
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/top_level.txt +0 -0
ocpp/models.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import re
|
|
2
3
|
import socket
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import timedelta
|
|
3
6
|
from decimal import Decimal, InvalidOperation
|
|
4
7
|
|
|
5
8
|
from django.conf import settings
|
|
@@ -9,6 +12,9 @@ from django.db.models import Q
|
|
|
9
12
|
from django.core.exceptions import ValidationError
|
|
10
13
|
from django.urls import reverse
|
|
11
14
|
from django.utils.translation import gettext_lazy as _
|
|
15
|
+
from django.utils import timezone
|
|
16
|
+
|
|
17
|
+
from asgiref.sync import async_to_sync
|
|
12
18
|
|
|
13
19
|
from core.entity import Entity, EntityManager
|
|
14
20
|
from nodes.models import Node
|
|
@@ -23,6 +29,7 @@ from core.models import (
|
|
|
23
29
|
EVModel as CoreEVModel,
|
|
24
30
|
SecurityGroup,
|
|
25
31
|
)
|
|
32
|
+
from . import store
|
|
26
33
|
from .reference_utils import url_targets_local_loopback
|
|
27
34
|
|
|
28
35
|
|
|
@@ -246,6 +253,19 @@ class Charger(Entity):
|
|
|
246
253
|
blank=True,
|
|
247
254
|
related_name="managed_chargers",
|
|
248
255
|
)
|
|
256
|
+
forwarded_to = models.ForeignKey(
|
|
257
|
+
"nodes.Node",
|
|
258
|
+
on_delete=models.SET_NULL,
|
|
259
|
+
null=True,
|
|
260
|
+
blank=True,
|
|
261
|
+
related_name="forwarded_chargers",
|
|
262
|
+
help_text=_("Remote node receiving forwarded transactions."),
|
|
263
|
+
)
|
|
264
|
+
forwarding_watermark = models.DateTimeField(
|
|
265
|
+
null=True,
|
|
266
|
+
blank=True,
|
|
267
|
+
help_text=_("Timestamp of the last forwarded transaction."),
|
|
268
|
+
)
|
|
249
269
|
allow_remote = models.BooleanField(default=False)
|
|
250
270
|
export_transactions = models.BooleanField(default=False)
|
|
251
271
|
last_online_at = models.DateTimeField(null=True, blank=True)
|
|
@@ -1114,6 +1134,257 @@ class DataTransferMessage(models.Model):
|
|
|
1114
1134
|
return f"{self.get_direction_display()} {self.vendor_id or 'DataTransfer'}"
|
|
1115
1135
|
|
|
1116
1136
|
|
|
1137
|
+
class CPReservation(Entity):
|
|
1138
|
+
"""Track connector reservations dispatched to an EVCS."""
|
|
1139
|
+
|
|
1140
|
+
location = models.ForeignKey(
|
|
1141
|
+
Location,
|
|
1142
|
+
on_delete=models.PROTECT,
|
|
1143
|
+
related_name="reservations",
|
|
1144
|
+
verbose_name=_("Location"),
|
|
1145
|
+
)
|
|
1146
|
+
connector = models.ForeignKey(
|
|
1147
|
+
Charger,
|
|
1148
|
+
on_delete=models.PROTECT,
|
|
1149
|
+
related_name="reservations",
|
|
1150
|
+
verbose_name=_("Connector"),
|
|
1151
|
+
)
|
|
1152
|
+
account = models.ForeignKey(
|
|
1153
|
+
EnergyAccount,
|
|
1154
|
+
on_delete=models.SET_NULL,
|
|
1155
|
+
null=True,
|
|
1156
|
+
blank=True,
|
|
1157
|
+
related_name="cp_reservations",
|
|
1158
|
+
verbose_name=_("Energy account"),
|
|
1159
|
+
)
|
|
1160
|
+
rfid = models.ForeignKey(
|
|
1161
|
+
CoreRFID,
|
|
1162
|
+
on_delete=models.SET_NULL,
|
|
1163
|
+
null=True,
|
|
1164
|
+
blank=True,
|
|
1165
|
+
related_name="cp_reservations",
|
|
1166
|
+
verbose_name=_("RFID"),
|
|
1167
|
+
)
|
|
1168
|
+
id_tag = models.CharField(
|
|
1169
|
+
_("Id Tag"),
|
|
1170
|
+
max_length=255,
|
|
1171
|
+
blank=True,
|
|
1172
|
+
default="",
|
|
1173
|
+
help_text=_("Identifier sent to the EVCS when reserving the connector."),
|
|
1174
|
+
)
|
|
1175
|
+
start_time = models.DateTimeField(verbose_name=_("Start time"))
|
|
1176
|
+
duration_minutes = models.PositiveIntegerField(
|
|
1177
|
+
verbose_name=_("Duration (minutes)"),
|
|
1178
|
+
default=120,
|
|
1179
|
+
help_text=_("Reservation window length in minutes."),
|
|
1180
|
+
)
|
|
1181
|
+
evcs_status = models.CharField(
|
|
1182
|
+
max_length=32,
|
|
1183
|
+
blank=True,
|
|
1184
|
+
default="",
|
|
1185
|
+
verbose_name=_("EVCS status"),
|
|
1186
|
+
)
|
|
1187
|
+
evcs_error = models.CharField(
|
|
1188
|
+
max_length=255,
|
|
1189
|
+
blank=True,
|
|
1190
|
+
default="",
|
|
1191
|
+
verbose_name=_("EVCS error"),
|
|
1192
|
+
)
|
|
1193
|
+
evcs_confirmed = models.BooleanField(
|
|
1194
|
+
default=False,
|
|
1195
|
+
verbose_name=_("Reservation confirmed"),
|
|
1196
|
+
)
|
|
1197
|
+
evcs_confirmed_at = models.DateTimeField(
|
|
1198
|
+
null=True,
|
|
1199
|
+
blank=True,
|
|
1200
|
+
verbose_name=_("Confirmed at"),
|
|
1201
|
+
)
|
|
1202
|
+
ocpp_message_id = models.CharField(
|
|
1203
|
+
max_length=36,
|
|
1204
|
+
blank=True,
|
|
1205
|
+
default="",
|
|
1206
|
+
editable=False,
|
|
1207
|
+
verbose_name=_("OCPP message id"),
|
|
1208
|
+
)
|
|
1209
|
+
created_on = models.DateTimeField(auto_now_add=True, verbose_name=_("Created on"))
|
|
1210
|
+
updated_on = models.DateTimeField(auto_now=True, verbose_name=_("Updated on"))
|
|
1211
|
+
|
|
1212
|
+
class Meta:
|
|
1213
|
+
ordering = ["-start_time"]
|
|
1214
|
+
verbose_name = _("CP Reservation")
|
|
1215
|
+
verbose_name_plural = _("CP Reservations")
|
|
1216
|
+
|
|
1217
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1218
|
+
start = timezone.localtime(self.start_time) if self.start_time else ""
|
|
1219
|
+
return f"{self.location} @ {start}" if self.location else str(start)
|
|
1220
|
+
|
|
1221
|
+
@property
|
|
1222
|
+
def end_time(self):
|
|
1223
|
+
duration = max(int(self.duration_minutes or 0), 0)
|
|
1224
|
+
return self.start_time + timedelta(minutes=duration)
|
|
1225
|
+
|
|
1226
|
+
@property
|
|
1227
|
+
def connector_label(self) -> str:
|
|
1228
|
+
if self.connector_id:
|
|
1229
|
+
return self.connector.connector_label
|
|
1230
|
+
return ""
|
|
1231
|
+
|
|
1232
|
+
@property
|
|
1233
|
+
def id_tag_value(self) -> str:
|
|
1234
|
+
if self.id_tag:
|
|
1235
|
+
return self.id_tag.strip()
|
|
1236
|
+
if self.rfid_id:
|
|
1237
|
+
return (self.rfid.rfid or "").strip()
|
|
1238
|
+
return ""
|
|
1239
|
+
|
|
1240
|
+
def allocate_connector(self, *, force: bool = False) -> Charger:
|
|
1241
|
+
"""Select an available connector for this reservation."""
|
|
1242
|
+
|
|
1243
|
+
if not self.location_id:
|
|
1244
|
+
raise ValidationError({"location": _("Select a location for the reservation.")})
|
|
1245
|
+
if not self.start_time:
|
|
1246
|
+
raise ValidationError({"start_time": _("Provide a start time for the reservation.")})
|
|
1247
|
+
if self.duration_minutes <= 0:
|
|
1248
|
+
raise ValidationError(
|
|
1249
|
+
{"duration_minutes": _("Reservation window must be at least one minute.")}
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
candidates = list(
|
|
1253
|
+
Charger.objects.filter(
|
|
1254
|
+
location=self.location, connector_id__isnull=False
|
|
1255
|
+
).order_by("connector_id")
|
|
1256
|
+
)
|
|
1257
|
+
if not candidates:
|
|
1258
|
+
raise ValidationError(
|
|
1259
|
+
{"location": _("No connectors are configured for the selected location.")}
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
def _priority(charger: Charger) -> tuple[int, int]:
|
|
1263
|
+
connector_id = charger.connector_id or 0
|
|
1264
|
+
if connector_id == 2:
|
|
1265
|
+
return (0, connector_id)
|
|
1266
|
+
if connector_id == 1:
|
|
1267
|
+
return (1, connector_id)
|
|
1268
|
+
return (2, connector_id)
|
|
1269
|
+
|
|
1270
|
+
def _is_available(charger: Charger) -> bool:
|
|
1271
|
+
existing = type(self).objects.filter(connector=charger).exclude(pk=self.pk)
|
|
1272
|
+
start = self.start_time
|
|
1273
|
+
end = self.end_time
|
|
1274
|
+
for entry in existing:
|
|
1275
|
+
if entry.start_time < end and entry.end_time > start:
|
|
1276
|
+
return False
|
|
1277
|
+
return True
|
|
1278
|
+
|
|
1279
|
+
if self.connector_id:
|
|
1280
|
+
current = next((c for c in candidates if c.pk == self.connector_id), None)
|
|
1281
|
+
if current and _is_available(current) and not force:
|
|
1282
|
+
return current
|
|
1283
|
+
|
|
1284
|
+
for charger in sorted(candidates, key=_priority):
|
|
1285
|
+
if _is_available(charger):
|
|
1286
|
+
self.connector = charger
|
|
1287
|
+
return charger
|
|
1288
|
+
|
|
1289
|
+
raise ValidationError(
|
|
1290
|
+
_("All connectors at this location are reserved for the selected time window.")
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
def clean(self):
|
|
1294
|
+
super().clean()
|
|
1295
|
+
if self.start_time and timezone.is_naive(self.start_time):
|
|
1296
|
+
self.start_time = timezone.make_aware(
|
|
1297
|
+
self.start_time, timezone.get_current_timezone()
|
|
1298
|
+
)
|
|
1299
|
+
if self.duration_minutes <= 0:
|
|
1300
|
+
raise ValidationError(
|
|
1301
|
+
{"duration_minutes": _("Reservation window must be at least one minute.")}
|
|
1302
|
+
)
|
|
1303
|
+
try:
|
|
1304
|
+
self.allocate_connector(force=bool(self.pk))
|
|
1305
|
+
except ValidationError as exc:
|
|
1306
|
+
raise ValidationError(exc) from exc
|
|
1307
|
+
|
|
1308
|
+
def save(self, *args, **kwargs):
|
|
1309
|
+
if self.start_time and timezone.is_naive(self.start_time):
|
|
1310
|
+
self.start_time = timezone.make_aware(
|
|
1311
|
+
self.start_time, timezone.get_current_timezone()
|
|
1312
|
+
)
|
|
1313
|
+
update_fields = kwargs.get("update_fields")
|
|
1314
|
+
relevant_fields = {"location", "start_time", "duration_minutes", "connector"}
|
|
1315
|
+
should_allocate = True
|
|
1316
|
+
if update_fields is not None and not relevant_fields.intersection(update_fields):
|
|
1317
|
+
should_allocate = False
|
|
1318
|
+
if should_allocate:
|
|
1319
|
+
self.allocate_connector(force=bool(self.pk))
|
|
1320
|
+
super().save(*args, **kwargs)
|
|
1321
|
+
|
|
1322
|
+
def send_reservation_request(self) -> str:
|
|
1323
|
+
"""Dispatch a ReserveNow request to the associated connector."""
|
|
1324
|
+
|
|
1325
|
+
if not self.pk:
|
|
1326
|
+
raise ValidationError(_("Save the reservation before sending it to the EVCS."))
|
|
1327
|
+
connector = self.connector
|
|
1328
|
+
if connector is None or connector.connector_id is None:
|
|
1329
|
+
raise ValidationError(_("Unable to determine which connector to reserve."))
|
|
1330
|
+
id_tag = self.id_tag_value
|
|
1331
|
+
if not id_tag:
|
|
1332
|
+
raise ValidationError(
|
|
1333
|
+
_("Provide an RFID or idTag before creating the reservation.")
|
|
1334
|
+
)
|
|
1335
|
+
connection = store.get_connection(connector.charger_id, connector.connector_id)
|
|
1336
|
+
if connection is None:
|
|
1337
|
+
raise ValidationError(
|
|
1338
|
+
_("The selected charge point is not currently connected to the system.")
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
message_id = uuid.uuid4().hex
|
|
1342
|
+
expiry = timezone.localtime(self.end_time)
|
|
1343
|
+
payload = {
|
|
1344
|
+
"connectorId": connector.connector_id,
|
|
1345
|
+
"expiryDate": expiry.isoformat(),
|
|
1346
|
+
"idTag": id_tag,
|
|
1347
|
+
"reservationId": self.pk,
|
|
1348
|
+
}
|
|
1349
|
+
frame = json.dumps([2, message_id, "ReserveNow", payload])
|
|
1350
|
+
|
|
1351
|
+
log_key = store.identity_key(connector.charger_id, connector.connector_id)
|
|
1352
|
+
store.add_log(
|
|
1353
|
+
log_key,
|
|
1354
|
+
f"ReserveNow request: reservation={self.pk}, expiry={expiry.isoformat()}",
|
|
1355
|
+
log_type="charger",
|
|
1356
|
+
)
|
|
1357
|
+
async_to_sync(connection.send)(frame)
|
|
1358
|
+
|
|
1359
|
+
metadata = {
|
|
1360
|
+
"action": "ReserveNow",
|
|
1361
|
+
"charger_id": connector.charger_id,
|
|
1362
|
+
"connector_id": connector.connector_id,
|
|
1363
|
+
"log_key": log_key,
|
|
1364
|
+
"reservation_pk": self.pk,
|
|
1365
|
+
"requested_at": timezone.now(),
|
|
1366
|
+
}
|
|
1367
|
+
store.register_pending_call(message_id, metadata)
|
|
1368
|
+
store.schedule_call_timeout(message_id, action="ReserveNow", log_key=log_key)
|
|
1369
|
+
|
|
1370
|
+
self.ocpp_message_id = message_id
|
|
1371
|
+
self.evcs_status = ""
|
|
1372
|
+
self.evcs_error = ""
|
|
1373
|
+
self.evcs_confirmed = False
|
|
1374
|
+
self.evcs_confirmed_at = None
|
|
1375
|
+
super().save(
|
|
1376
|
+
update_fields=[
|
|
1377
|
+
"ocpp_message_id",
|
|
1378
|
+
"evcs_status",
|
|
1379
|
+
"evcs_error",
|
|
1380
|
+
"evcs_confirmed",
|
|
1381
|
+
"evcs_confirmed_at",
|
|
1382
|
+
"updated_on",
|
|
1383
|
+
]
|
|
1384
|
+
)
|
|
1385
|
+
return message_id
|
|
1386
|
+
|
|
1387
|
+
|
|
1117
1388
|
class RFID(CoreRFID):
|
|
1118
1389
|
class Meta:
|
|
1119
1390
|
proxy = True
|
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
|