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.

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