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/consumers.py CHANGED
@@ -26,6 +26,7 @@ from .models import (
26
26
  ChargerConfiguration,
27
27
  MeterValue,
28
28
  DataTransferMessage,
29
+ CPReservation,
29
30
  )
30
31
  from .reference_utils import host_is_local_loopback
31
32
  from .evcs_discovery import (
@@ -903,6 +904,43 @@ class CSMSConsumer(AsyncWebsocketConsumer):
903
904
  payload=payload_data,
904
905
  )
905
906
  return
907
+ if action == "ReserveNow":
908
+ status_value = str(payload_data.get("status") or "").strip()
909
+ message = "ReserveNow result"
910
+ if status_value:
911
+ message += f": status={status_value}"
912
+ store.add_log(log_key, message, log_type="charger")
913
+
914
+ reservation_pk = metadata.get("reservation_pk")
915
+
916
+ def _apply():
917
+ if not reservation_pk:
918
+ return
919
+ reservation = CPReservation.objects.filter(pk=reservation_pk).first()
920
+ if not reservation:
921
+ return
922
+ reservation.evcs_status = status_value
923
+ reservation.evcs_error = ""
924
+ confirmed = status_value.casefold() == "accepted"
925
+ reservation.evcs_confirmed = confirmed
926
+ reservation.evcs_confirmed_at = timezone.now() if confirmed else None
927
+ reservation.save(
928
+ update_fields=[
929
+ "evcs_status",
930
+ "evcs_error",
931
+ "evcs_confirmed",
932
+ "evcs_confirmed_at",
933
+ "updated_on",
934
+ ]
935
+ )
936
+
937
+ await database_sync_to_async(_apply)()
938
+ store.record_pending_call_result(
939
+ message_id,
940
+ metadata=metadata,
941
+ payload=payload_data,
942
+ )
943
+ return
906
944
  if action == "RemoteStartTransaction":
907
945
  status_value = str(payload_data.get("status") or "").strip()
908
946
  message = "RemoteStartTransaction result"
@@ -1084,6 +1122,66 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1084
1122
  error_details=details,
1085
1123
  )
1086
1124
  return
1125
+ if action == "ReserveNow":
1126
+ parts: list[str] = []
1127
+ code_text = (error_code or "").strip() if error_code else ""
1128
+ if code_text:
1129
+ parts.append(f"code={code_text}")
1130
+ description_text = (description or "").strip() if description else ""
1131
+ if description_text:
1132
+ parts.append(f"description={description_text}")
1133
+ details_text = ""
1134
+ if details:
1135
+ try:
1136
+ details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
1137
+ except TypeError:
1138
+ details_text = str(details)
1139
+ if details_text:
1140
+ parts.append(f"details={details_text}")
1141
+ message = "ReserveNow error"
1142
+ if parts:
1143
+ message += ": " + ", ".join(parts)
1144
+ store.add_log(log_key, message, log_type="charger")
1145
+
1146
+ reservation_pk = metadata.get("reservation_pk")
1147
+
1148
+ def _apply():
1149
+ if not reservation_pk:
1150
+ return
1151
+ reservation = CPReservation.objects.filter(pk=reservation_pk).first()
1152
+ if not reservation:
1153
+ return
1154
+ summary_parts = []
1155
+ if code_text:
1156
+ summary_parts.append(code_text)
1157
+ if description_text:
1158
+ summary_parts.append(description_text)
1159
+ if details_text:
1160
+ summary_parts.append(details_text)
1161
+ reservation.evcs_status = ""
1162
+ reservation.evcs_error = "; ".join(summary_parts)
1163
+ reservation.evcs_confirmed = False
1164
+ reservation.evcs_confirmed_at = None
1165
+ reservation.save(
1166
+ update_fields=[
1167
+ "evcs_status",
1168
+ "evcs_error",
1169
+ "evcs_confirmed",
1170
+ "evcs_confirmed_at",
1171
+ "updated_on",
1172
+ ]
1173
+ )
1174
+
1175
+ await database_sync_to_async(_apply)()
1176
+ store.record_pending_call_result(
1177
+ message_id,
1178
+ metadata=metadata,
1179
+ success=False,
1180
+ error_code=error_code,
1181
+ error_description=description,
1182
+ error_details=details,
1183
+ )
1184
+ return
1087
1185
  if action == "RemoteStartTransaction":
1088
1186
  message = "RemoteStartTransaction error"
1089
1187
  if error_code:
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
 
@@ -232,6 +239,13 @@ class Charger(Entity):
232
239
  "Latest GetConfiguration response received from this charge point."
233
240
  ),
234
241
  )
242
+ node_origin = models.ForeignKey(
243
+ "nodes.Node",
244
+ on_delete=models.SET_NULL,
245
+ null=True,
246
+ blank=True,
247
+ related_name="origin_chargers",
248
+ )
235
249
  manager_node = models.ForeignKey(
236
250
  "nodes.Node",
237
251
  on_delete=models.SET_NULL,
@@ -239,6 +253,22 @@ class Charger(Entity):
239
253
  blank=True,
240
254
  related_name="managed_chargers",
241
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
+ )
269
+ allow_remote = models.BooleanField(default=False)
270
+ export_transactions = models.BooleanField(default=False)
271
+ last_online_at = models.DateTimeField(null=True, blank=True)
242
272
  owner_users = models.ManyToManyField(
243
273
  settings.AUTH_USER_MODEL,
244
274
  blank=True,
@@ -293,6 +323,24 @@ class Charger(Entity):
293
323
  user_group_ids = user.groups.values_list("pk", flat=True)
294
324
  return self.owner_groups.filter(pk__in=user_group_ids).exists()
295
325
 
326
+ @property
327
+ def is_local(self) -> bool:
328
+ """Return ``True`` when this charger originates from the local node."""
329
+
330
+ local = Node.get_local()
331
+ if not local:
332
+ return False
333
+ if self.node_origin_id is None:
334
+ return True
335
+ return self.node_origin_id == local.pk
336
+
337
+ def save(self, *args, **kwargs):
338
+ if self.node_origin_id is None:
339
+ local = Node.get_local()
340
+ if local:
341
+ self.node_origin = local
342
+ super().save(*args, **kwargs)
343
+
296
344
  class Meta:
297
345
  verbose_name = _("Charge Point")
298
346
  verbose_name_plural = _("Charge Points")
@@ -1086,6 +1134,257 @@ class DataTransferMessage(models.Model):
1086
1134
  return f"{self.get_direction_display()} {self.vendor_id or 'DataTransfer'}"
1087
1135
 
1088
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
+
1089
1388
  class RFID(CoreRFID):
1090
1389
  class Meta:
1091
1390
  proxy = True